소스 검색

feat(failure-detection): add OctoEverywhere as an AI failure detection provider (#3169)

Quinn Damerell 3 주 전
부모
커밋
afebea37d1
52개의 변경된 파일과 6825개의 추가작업 그리고 611개의 파일을 삭제
  1. 1 1
      README.md
  2. 89 0
      backend/app/api/routes/octoeverywhere.py
  3. 27 1
      backend/app/api/routes/settings.py
  4. 9 0
      backend/app/main.py
  5. 1 1
      backend/app/schemas/notification.py
  6. 15 3
      backend/app/schemas/notification_template.py
  7. 60 10
      backend/app/schemas/settings.py
  8. 74 0
      backend/app/services/failure_detection_camera.py
  9. 19 4
      backend/app/services/notification_service.py
  10. 22 8
      backend/app/services/obico_actions.py
  11. 23 61
      backend/app/services/obico_detection.py
  12. 8 0
      backend/app/services/octoeverywhere_actions.py
  13. 807 0
      backend/app/services/octoeverywhere_detection.py
  14. 36 0
      backend/app/services/octoeverywhere_images.py
  15. 1 0
      backend/tests/conftest.py
  16. 388 0
      backend/tests/integration/test_octoeverywhere_api.py
  17. 5 1
      backend/tests/unit/services/test_obico_actions.py
  18. 109 0
      backend/tests/unit/services/test_octoeverywhere_actions.py
  19. 23 0
      backend/tests/unit/test_obico_detection.py
  20. 2513 0
      backend/tests/unit/test_octoeverywhere_detection.py
  21. 47 0
      backend/tests/unit/test_octoeverywhere_images.py
  22. 89 0
      docs/octoeverywhere.md
  23. 1 1
      frontend/scripts/check-i18n-parity.mjs
  24. 82 0
      frontend/src/__tests__/components/AiDetectionModal.test.tsx
  25. 485 0
      frontend/src/__tests__/components/OctoEverywhereSettings.test.tsx
  26. 18 0
      frontend/src/__tests__/mocks/handlers.ts
  27. 85 0
      frontend/src/__tests__/pages/PrintersPageAiDetection.test.tsx
  28. 19 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  29. 70 1
      frontend/src/api/client.ts
  30. 21 6
      frontend/src/components/AiDetectionModal.tsx
  31. 30 474
      frontend/src/components/FailureDetectionSettings.tsx
  32. 494 0
      frontend/src/components/ObicoSettings.tsx
  33. 27 0
      frontend/src/components/OctoEverywhereLimitNotice.tsx
  34. 470 0
      frontend/src/components/OctoEverywhereSettings.tsx
  35. 40 1
      frontend/src/i18n/locales/de.ts
  36. 40 1
      frontend/src/i18n/locales/en.ts
  37. 40 1
      frontend/src/i18n/locales/es.ts
  38. 40 1
      frontend/src/i18n/locales/fr.ts
  39. 40 1
      frontend/src/i18n/locales/it.ts
  40. 40 1
      frontend/src/i18n/locales/ja.ts
  41. 40 1
      frontend/src/i18n/locales/ko.ts
  42. 40 1
      frontend/src/i18n/locales/nl.ts
  43. 40 1
      frontend/src/i18n/locales/pt-BR.ts
  44. 40 1
      frontend/src/i18n/locales/ru.ts
  45. 40 1
      frontend/src/i18n/locales/sv.ts
  46. 40 1
      frontend/src/i18n/locales/tr.ts
  47. 40 1
      frontend/src/i18n/locales/uk.ts
  48. 40 1
      frontend/src/i18n/locales/zh-CN.ts
  49. 40 1
      frontend/src/i18n/locales/zh-TW.ts
  50. 33 13
      frontend/src/pages/PrintersPage.tsx
  51. 15 8
      frontend/src/pages/SettingsPage.tsx
  52. 9 3
      frontend/src/utils/aiDetection.ts

+ 1 - 1
README.md

@@ -247,7 +247,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
 - 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)
+- **AI print-failure detection** — Choose a self-hosted [Obico](https://github.com/TheSpaghettiDetective/obico-server) ML API or [OctoEverywhere cloud detection](https://docs.octoeverywhere.com/ai-failure-detection-apis/overview/) with a Gadget API key. Watches running prints through their cameras and supports notifications, pausing, or pausing and turning off linked smart plugs. Obico smooths scores locally; OctoEverywhere provides print quality and warning/pause decisions.
 - Per-user statistics filtering (admin permission gated)
 - Statistics grouped by your own material number
 - CSV/Excel export

+ 89 - 0
backend/app/api/routes/octoeverywhere.py

@@ -0,0 +1,89 @@
+"""API routes for OctoEverywhere AI failure detection."""
+
+from typing import Literal
+
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import ApiKeyActor, RequestActor, RequestPrinterScope, RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
+from backend.app.models.printer import Printer
+from backend.app.models.user import User
+from backend.app.services.notification_service import notification_service
+from backend.app.services.octoeverywhere_detection import octoeverywhere_detection_service
+
+router = APIRouter(prefix="/octoeverywhere", tags=["octoeverywhere"])
+
+
+class TestConnectionRequest(BaseModel):
+    # Omitted = test the saved key; empty = test with no key.
+    api_key: str | None = None
+    confidence: Literal["lowest", "low", "medium", "high", "highest"] | None = None
+
+
+@router.get("/status")
+async def get_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Scheduler status, print quality, and recent detection history."""
+    settings = await octoeverywhere_detection_service._load_settings()
+    providers = await notification_service._get_providers_for_event(db, "on_ai_failure_detection")
+    printers = select(Printer.id).where(Printer.is_active.is_(True))
+    if settings["enabled_printers"] is not None:
+        printers = printers.where(Printer.id.in_(settings["enabled_printers"]))
+    printer_ids = (await db.execute(printers)).scalars().all()
+    # Use the same per-printer subscription rules as notification delivery.
+    covered_printers = {provider.printer_id for provider in providers}
+    uncovered_printers = (
+        sorted(pid for pid in printer_ids if pid not in covered_printers) if None not in covered_printers else []
+    )
+    return {
+        **octoeverywhere_detection_service.get_status(),
+        "enabled": settings["enabled"],
+        "api_key_configured": bool(settings["api_key"]),
+        "confidence": settings["confidence"],
+        "action": settings["action"],
+        "poll_interval": settings["poll_interval"],
+        "notifications": {"configured": bool(providers), "uncovered_printers": uncovered_printers},
+    }
+
+
+@router.get("/printer-status")
+async def get_printer_status(
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    actor: User | ApiKeyActor | None = RequestActor,
+):
+    """Live printer-card status without exposing provider configuration."""
+    settings = await octoeverywhere_detection_service._load_settings()
+    enabled_printers = settings["enabled_printers"]
+    can_see_error = actor is None or actor.has_permission(Permission.SETTINGS_READ.value)
+    per_printer = octoeverywhere_detection_service.get_per_printer()
+    if not can_see_error:
+        per_printer = {pid: {**entry, "error": None, "error_code": None} for pid, entry in per_printer.items()}
+    per_printer = {pid: entry for pid, entry in per_printer.items() if printer_scope.allows(int(pid))}
+    if enabled_printers is not None:
+        enabled_printers = [pid for pid in enabled_printers if printer_scope.allows(int(pid))]
+    return {
+        "enabled": settings["enabled"],
+        "monitored_printers": sorted(enabled_printers) if enabled_printers is not None else None,
+        "per_printer": per_printer,
+        "last_error": octoeverywhere_detection_service._last_error if can_see_error else None,
+        "last_error_code": octoeverywhere_detection_service._last_error_code if can_see_error else None,
+    }
+
+
+@router.post("/test-connection")
+async def test_connection(
+    req: TestConnectionRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Validate the key by creating a context, without uploading a camera image."""
+    settings = await octoeverywhere_detection_service._load_settings()
+    api_key = settings["api_key"] if req.api_key is None else req.api_key
+    return await octoeverywhere_detection_service.test_connection(api_key, req.confidence or settings["confidence"])

+ 27 - 1
backend/app/api/routes/settings.py

@@ -40,7 +40,7 @@ DEFAULT_SETTINGS = AppSettings()
 
 # Sensitive credential fields blanked for API-key callers. Everyone else sees
 # them as the placeholder (write-only secrets, see services/secret_fields).
-_SENSITIVE_FIELDS_FOR_API_KEY = tuple(SETTINGS_SECRETS)
+_SENSITIVE_FIELDS_FOR_API_KEY = (*SETTINGS_SECRETS, "octoeverywhere_api_key")
 
 
 async def get_setting(db: AsyncSession, key: str) -> str | None:
@@ -262,6 +262,7 @@ _INT_SETTING_KEYS = frozenset(
         "queue_max_concurrent_uploads",
         "ambient_drying_sustained_minutes",
         "pause_warning_minutes",
+        "octoeverywhere_poll_interval",
     }
 )
 
@@ -317,6 +318,10 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False, m
     if mask or is_api_key:
         settings_dict["ldap_bind_password"] = ""
 
+    # The saved key is write-only, including for authenticated administrators.
+    settings_dict["octoeverywhere_api_key_configured"] = bool(settings_dict["octoeverywhere_api_key"].strip())
+    settings_dict["octoeverywhere_api_key"] = ""
+
     if is_api_key:
         for field in _SENSITIVE_FIELDS_FOR_API_KEY:
             if field in settings_dict:
@@ -374,6 +379,18 @@ async def update_settings(
     except SecretConfigError as exc:
         raise HTTPException(status_code=400, detail=str(exc)) from None
 
+    # Omitted/null preserves the saved key; an explicit empty string removes it.
+    if update_data.get("octoeverywhere_api_key") is None:
+        update_data.pop("octoeverywhere_api_key", None)
+
+    # Only one failure detection provider may control a print at a time.
+    if update_data.get("obico_enabled") and update_data.get("octoeverywhere_enabled"):
+        raise HTTPException(status_code=400, detail="Enable only one AI failure detection provider at a time.")
+    if update_data.get("octoeverywhere_enabled"):
+        update_data["obico_enabled"] = False
+    elif update_data.get("obico_enabled"):
+        update_data["octoeverywhere_enabled"] = False
+
     # An explicit null for a boolean or numeric setting has no meaning -- these
     # are not clearable -- and would be stored as the literal "None".
     null_keys = sorted(
@@ -470,6 +487,15 @@ async def update_settings(
     # Expire all objects to ensure fresh reads after commit
     db.expire_all()
 
+    if update_data.get("obico_enabled") is False:
+        from backend.app.services.obico_detection import obico_detection_service
+
+        await obico_detection_service.refresh_settings()
+    if any(key.startswith("octoeverywhere_") for key in update_data):
+        from backend.app.services.octoeverywhere_detection import octoeverywhere_detection_service
+
+        await octoeverywhere_detection_service.refresh_settings()
+
     if {"camera_light_mode", "camera_light_delay"} & set(update_data.keys()):
         camera_light.invalidate_settings()
 

+ 9 - 0
backend/app/main.py

@@ -65,6 +65,7 @@ from backend.app.api.routes import (
     notification_templates,
     notifications,
     obico,
+    octoeverywhere,
     orca_cloud,
     overlay_branding,
     pending_uploads,
@@ -140,6 +141,7 @@ from backend.app.services.notification_service import notification_service
 from backend.app.services.notify_live_activities import notify_live_activities
 from backend.app.services.notify_widgets import notify_widgets
 from backend.app.services.obico_detection import obico_detection_service
+from backend.app.services.octoeverywhere_detection import octoeverywhere_detection_service
 from backend.app.services.plug_energy import get_plug_energy as _get_plug_energy
 from backend.app.services.print_cost_estimate import plate_scoped_run_estimate as _plate_scoped_run_estimate
 from backend.app.services.print_scheduler import scheduler as print_scheduler
@@ -4943,6 +4945,8 @@ async def on_print_start(printer_id: int, data: dict):
 
     logger.info("[CALLBACK] on_print_start called for printer %s, data keys: %s", printer_id, list(data.keys()))
 
+    octoeverywhere_detection_service.reset_printer(printer_id)
+
     try:
         notify_live_activities.print_started(printer_id, printer_manager.get_status(printer_id), data)
     except Exception:
@@ -9066,6 +9070,8 @@ async def _close_out_print(printer_id: int, data: dict):
 
     logger.info("[CALLBACK] on_print_complete started for printer %s", printer_id)
 
+    octoeverywhere_detection_service.reset_printer(printer_id)
+
     # A kill-switch stop sends its provider notification immediately. Keep the
     # task so the later notification path can await it and avoid a duplicate;
     # if that immediate attempt failed, the regular completion path retries.
@@ -12257,6 +12263,7 @@ async def lifespan(app: FastAPI):
     # Start the local backup scheduler
     await local_backup_service.start_scheduler()
     await obico_detection_service.start()
+    await octoeverywhere_detection_service.start()
 
     # Start the library trash sweeper (#1008)
     await library_trash_service.start_scheduler()
@@ -12343,6 +12350,7 @@ async def lifespan(app: FastAPI):
     library_trash_service.stop_scheduler()
     archive_purge_service.stop_scheduler()
     obico_detection_service.stop()
+    octoeverywhere_detection_service.stop()
     stop_ams_history_recording()
     stop_printer_sensor_history_recording()
     stop_runtime_tracking()
@@ -13000,6 +13008,7 @@ app.include_router(bulk.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(octoeverywhere.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)

+ 1 - 1
backend/app/schemas/notification.py

@@ -53,7 +53,7 @@ class NotificationProviderBase(BaseModel):
     on_printer_error: bool = Field(default=False, description="Notify on printer errors (AMS, etc.)")
     on_ai_failure_detection: bool = Field(
         default=False,
-        description="Notify when Obico AI detects a possible print failure (spaghetti)",
+        description="Notify when AI failure detection identifies a possible print failure (spaghetti)",
     )
     on_filament_low: bool = Field(default=False, description="Notify when filament is running low")
     on_maintenance_due: bool = Field(default=False, description="Notify when maintenance is due")

+ 15 - 3
backend/app/schemas/notification_template.py

@@ -20,6 +20,7 @@ class EventType(StrEnum):
     BILLING_CHARGE_FAILED = "billing_charge_failed"
     PRINTER_OFFLINE = "printer_offline"
     PRINTER_ERROR = "printer_error"
+    AI_FAILURE_DETECTION = "ai_failure_detection"
     FILAMENT_LOW = "filament_low"
     MAINTENANCE_DUE = "maintenance_due"
     AMS_HUMIDITY_HIGH = "ams_humidity_high"
@@ -96,7 +97,16 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "billing_charge_failed": ["printer", "filename", "archive_id", "error", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
-    "ai_failure_detection": ["printer", "task_name", "confidence", "action", "timestamp", "app_name"],
+    "ai_failure_detection": [
+        "printer",
+        "task_name",
+        "provider",
+        "confidence",
+        "print_quality",
+        "action",
+        "timestamp",
+        "app_name",
+    ],
     "plate_not_empty": ["printer", "difference_percent", "timestamp", "app_name"],
     "plate_clear_required": ["printer", "timestamp", "app_name"],
     "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
@@ -275,9 +285,11 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
     "ai_failure_detection": {
         "printer": "Bambu X1C",
         "task_name": "Benchy.3mf",
-        "confidence": "0.87",
+        "provider": "OctoEverywhere",
+        "confidence": "N/A",
+        "print_quality": "2/10",
         "action": "pause",
-        "timestamp": "2024-01-15 15:10",
+        "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
     "print_confirm_request": {

+ 60 - 10
backend/app/schemas/settings.py

@@ -670,6 +670,29 @@ class AppSettings(BaseModel):
         description="JSON array of printer IDs to monitor (empty = all connected printers)",
     )
 
+    # OctoEverywhere AI failure detection
+    octoeverywhere_enabled: bool = Field(default=False, description="Enable OctoEverywhere AI print failure detection")
+    octoeverywhere_api_key: str = Field(default="", description="OctoEverywhere Gadget API key")
+    octoeverywhere_api_key_configured: bool = Field(
+        default=False, description="Whether an OctoEverywhere Gadget API key is saved"
+    )
+    octoeverywhere_poll_interval: int = Field(
+        default=20, ge=5, le=30, description="Seconds between inspections, subject to the server's minimum interval"
+    )
+    octoeverywhere_confidence: str = Field(
+        default="medium",
+        description=(
+            "Confidence OctoEverywhere needs before suggesting a warning or pause: lowest, low, medium, high, "
+            "or highest (API levels 1-5). Lower reports sooner with more false positives."
+        ),
+    )
+    octoeverywhere_action: str = Field(
+        default="notify", description="Action on failure: notify, pause, or pause_and_off"
+    )
+    octoeverywhere_enabled_printers: str = Field(
+        default="", description="JSON array of printer IDs to monitor (empty = all connected printers)"
+    )
+
     # Inventory forecasting
     forecast_global_lead_time_days: int = Field(
         default=0,
@@ -867,6 +890,14 @@ class AppSettingsUpdate(BaseModel):
     obico_action: str | None = None
     obico_poll_interval: int | None = Field(default=None, ge=5, le=120)
     obico_enabled_printers: str | None = None
+    octoeverywhere_enabled: bool | None = None
+    octoeverywhere_api_key: str | None = None
+    # None only while unset, like the fields around it. An explicit null is
+    # rejected: it would be stored as "None" and break reading settings back.
+    octoeverywhere_poll_interval: int = Field(default=None, ge=5, le=30, strict=True)
+    octoeverywhere_confidence: str | None = None
+    octoeverywhere_action: str | None = None
+    octoeverywhere_enabled_printers: str | None = None
     default_sidebar_order: str | None = None
     forecast_global_lead_time_days: int | None = Field(default=None, ge=0)
     location_sensor_poll_interval: int | None = Field(default=None, ge=60, le=3600)
@@ -973,17 +1004,27 @@ 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")
+    @field_validator("octoeverywhere_api_key")
     @classmethod
-    def validate_obico_enabled_printers(cls, v: str | None) -> str | None:
+    def validate_octoeverywhere_api_key(cls, v: str | None) -> str | None:
+        if v is None:
+            return v
+        v = v.strip()
+        if any(ord(char) < 33 or ord(char) > 126 for char in v):
+            raise ValueError("octoeverywhere_api_key must contain only printable ASCII characters without spaces")
+        return v
+
+    @field_validator("obico_enabled_printers", "octoeverywhere_enabled_printers")
+    @classmethod
+    def validate_obico_enabled_printers(cls, v: str | None, info: ValidationInfo) -> 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)")
+            raise ValueError(f"{info.field_name} must be valid JSON or empty")
+        if not isinstance(parsed, list) or not all(type(item) is int and item > 0 for item in parsed):
+            raise ValueError(f"{info.field_name} must be a JSON array of printer IDs (positive integers)")
         return v
 
     @field_validator("bambuddy_internal_url")
@@ -1053,20 +1094,29 @@ class AppSettingsUpdate(BaseModel):
 
     @field_validator("obico_sensitivity")
     @classmethod
-    def validate_obico_sensitivity(cls, v: str | None) -> str | None:
+    def validate_obico_sensitivity(cls, v: str | None, info: ValidationInfo) -> 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'")
+            raise ValueError(f"{info.field_name} must be 'low', 'medium', or 'high'")
+        return v
+
+    @field_validator("octoeverywhere_confidence")
+    @classmethod
+    def validate_octoeverywhere_confidence(cls, v: str | None) -> str | None:
+        if v is None:
+            return v
+        if v not in ("lowest", "low", "medium", "high", "highest"):
+            raise ValueError("octoeverywhere_confidence must be 'lowest', 'low', 'medium', 'high', or 'highest'")
         return v
 
-    @field_validator("obico_action")
+    @field_validator("obico_action", "octoeverywhere_action")
     @classmethod
-    def validate_obico_action(cls, v: str | None) -> str | None:
+    def validate_obico_action(cls, v: str | None, info: ValidationInfo) -> 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'")
+            raise ValueError(f"{info.field_name} must be 'notify', 'pause', or 'pause_and_off'")
         return v
 
     @field_validator("default_sidebar_order")

+ 74 - 0
backend/app/services/failure_detection_camera.py

@@ -0,0 +1,74 @@
+"""Shared camera capture for AI failure detection providers."""
+
+import logging
+
+from backend.app.core.database import async_session
+from backend.app.models.printer import Printer
+
+logger = logging.getLogger(__name__)
+
+
+async def capture_detection_frame(printer_id: int, *, session_factory=async_session, timeout: int = 20) -> bytes | None:
+    """Capture a JPEG, sharing live camera buffers to avoid competing readers."""
+    # Late import to avoid cycles at module load time
+    from backend.app.services.camera import capture_camera_frame_bytes
+    from backend.app.services.external_camera import capture_frame as capture_external_frame
+
+    async with session_factory() as db:
+        printer = await db.get(Printer, printer_id)
+    if printer is None:
+        raise ValueError(f"Printer {printer_id} not found")
+
+    if printer.external_camera_enabled and printer.external_camera_url:
+        # Same rule as the built-in branch below, which this used to skip:
+        # an external camera is single-reader too, so polling while a viewer
+        # is attached just fails (#2707).
+        from backend.app.api.routes.camera import live_frame_for_capture
+
+        defer, buffered = live_frame_for_capture(printer_id)
+        if defer:
+            if buffered:
+                return buffered
+            logger.info(
+                "AI failure detection: viewer attached for printer %s but buffer empty; "
+                "skipping this poll to avoid competing camera handle (#2707)",
+                printer_id,
+            )
+            return None
+        return await capture_external_frame(
+            printer.external_camera_url,
+            printer.external_camera_type,
+            timeout=timeout,
+            snapshot_url=printer.external_camera_snapshot_url,
+        )
+
+    # Reuse the fan-out broadcaster's buffered frame when a viewer is
+    # already watching — avoids opening a second concurrent RTSP socket
+    # on printers that allow only one camera connection (e.g. X2D
+    # firmware 01.01.00.00; see #1271). Buffered frame is <1s old while
+    # a viewer is connected.
+    #
+    # When a viewer is attached but no frame is buffered yet (startup
+    # race, mid-reconnect), we DELIBERATELY skip this poll cycle instead
+    # of falling through to capture_camera_frame_bytes. Opening a fresh
+    # RTSP/chamber socket would compete with the live viewer and kick
+    # the fan-out connection on most firmwares — exactly the freeze
+    # reported in #1348. The poll loop retries in ~10s.
+    from backend.app.api.routes.camera import is_stream_active, try_get_active_buffered_frame
+
+    if is_stream_active(printer_id):
+        buffered = try_get_active_buffered_frame(printer_id)
+        if buffered:
+            return buffered
+        logger.info(
+            "AI failure detection: viewer attached for printer %s but buffer empty; skipping this poll to avoid competing camera socket (#1348)",
+            printer_id,
+        )
+        return None
+
+    return await capture_camera_frame_bytes(
+        ip_address=printer.ip_address,
+        access_code=printer.access_code,
+        model=printer.model,
+        timeout=timeout,
+    )

+ 19 - 4
backend/app/services/notification_service.py

@@ -26,7 +26,7 @@ from backend.app.models.notification import (
     NotificationProvider,
     TelegramPendingVerdict,
 )
-from backend.app.models.notification_template import NotificationTemplate
+from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
 from backend.app.services.email_service import add_required_headers
 from backend.app.services.notify_client import NotifyClient, NotifyError, notify_credentials, notify_supports_photos
 from backend.app.services.print_confirmation import one_tap_url
@@ -2192,12 +2192,13 @@ class NotificationService:
         printer_id: int,
         printer_name: str,
         task_name: str,
-        confidence: float,
+        confidence: float | None,
         action: str,
         db: AsyncSession,
         image_data: bytes | None = None,
+        print_quality: int | None = None,
     ):
-        """Handle AI failure-detection event (Obico spaghetti / print-failure ML).
+        """Handle AI failure-detection events from the configured provider.
 
         Split out of on_printer_error (#1794) so a user can subscribe to AI
         alerts without also being paged for every HMS hardware code.
@@ -2209,11 +2210,25 @@ class NotificationService:
         variables = {
             "printer": printer_name,
             "task_name": task_name or "current job",
-            "confidence": f"{confidence:.2f}",
+            "confidence": f"{confidence:.2f}" if confidence is not None else "N/A",
             "action": action,
+            "provider": "OctoEverywhere" if print_quality is not None else "Obico",
+            "print_quality": f"{print_quality}/10" if print_quality is not None else "N/A",
         }
 
         title, message = await self._build_message_from_template(db, "ai_failure_detection", variables)
+        if print_quality is not None:
+            template = await self._get_template(db, "ai_failure_detection")
+            default_body = next(
+                t["body_template"] for t in DEFAULT_TEMPLATES if t["event_type"] == "ai_failure_detection"
+            )
+            # Adapt the untouched Obico default without replacing custom text
+            # or changing the saved template used by either provider.
+            if template and template.body_template == default_body:
+                message = self._render_template(
+                    "{printer}: {task_name}\nOctoEverywhere print quality: {print_quality}\nAction taken: {action}",
+                    variables,
+                )
         await self._send_to_providers(
             providers,
             title,

+ 22 - 8
backend/app/services/obico_actions.py

@@ -14,7 +14,13 @@ logger = logging.getLogger(__name__)
 
 
 async def execute_action(
-    printer_id: int, action: str, task_name: str, score: float, frame: bytes | None = None
+    printer_id: int,
+    action: str,
+    task_name: str,
+    score: float | None,
+    frame: bytes | None = None,
+    *,
+    print_quality: int | None = None,
 ) -> None:
     """Run the configured action for a detected print failure.
 
@@ -30,7 +36,7 @@ async def execute_action(
     if action == "pause_and_off":
         await _turn_off_linked_plugs(printer_id)
 
-    await _notify(printer_id, printer_name, task_name, score, action, frame)
+    await _notify(printer_id, printer_name, task_name, score, action, frame, print_quality=print_quality)
 
 
 async def _get_printer_name(printer_id: int) -> str:
@@ -45,10 +51,10 @@ def _pause_print(printer_id: int) -> None:
 
     client = printer_manager.get_client(printer_id)
     if not client:
-        logger.warning("Obico pause: no MQTT client for printer %s", printer_id)
+        logger.warning("AI failure detection: 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)
+        logger.warning("AI failure detection: pause_print() returned False for printer %s", printer_id)
 
 
 async def _turn_off_linked_plugs(printer_id: int) -> None:
@@ -75,16 +81,23 @@ async def _turn_off_linked_plugs(printer_id: int) -> None:
                     )
                     if plug.controls_printer_power:
                         power_plug_off = plug
-                logger.info("Obico action: turned off plug %s for printer %s", plug.name, printer_id)
+                logger.info("AI failure detection: 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)
+                logger.error("AI failure detection: failed to turn off plug %s: %s", plug.name, e)
     if power_plug_off is not None:
         # Once, however many plugs feed the printer.
         await run_linked_scripts_after_automatic_switch(printer_id, power_plug_off, "OFF")
 
 
 async def _notify(
-    printer_id: int, printer_name: str, task_name: str, score: float, action: str, frame: bytes | None = None
+    printer_id: int,
+    printer_name: str,
+    task_name: str,
+    score: float | None,
+    action: str,
+    frame: bytes | None = None,
+    *,
+    print_quality: int | None = None,
 ) -> None:
     """Fire the AI Failure Detection notification (#1794).
 
@@ -103,7 +116,8 @@ async def _notify(
                 confidence=score,
                 action=action,
                 db=db,
+                print_quality=print_quality,
                 image_data=frame,
             )
         except Exception as e:
-            logger.error("Obico notify failed for printer %s: %s", printer_id, e)
+            logger.error("AI failure detection notify failed for printer %s: %s", printer_id, e)

+ 23 - 61
backend/app/services/obico_detection.py

@@ -19,7 +19,6 @@ import httpx
 from sqlalchemy import select
 
 from backend.app.core.database import async_session
-from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.services.obico_smoothing import (
     PrintState,
@@ -129,6 +128,23 @@ class ObicoDetectionService:
             self._task = None
             logger.info("Stopped Obico detection service")
 
+    async def refresh_settings(self):
+        """Cancel an in-flight check when another provider takes over."""
+        task = self._task
+        self.stop()
+        self._states.clear()
+        self._state_keys.clear()
+        self._action_fired.clear()
+        self._last_class.clear()
+        self._errors.clear()
+        self._last_error = None
+        if task is not None:
+            try:
+                await task
+            except asyncio.CancelledError:
+                pass
+            await self.start()
+
     # ---- settings ----
 
     async def _load_settings(self) -> dict:
@@ -215,70 +231,16 @@ class ObicoDetectionService:
 
     async def _capture_frame(self, printer_id: int) -> bytes | None:
         """Capture one JPEG frame from the printer camera. Returns None on failure."""
-        # Late import to avoid cycles at module load time
-        from backend.app.services.camera import capture_camera_frame_bytes
-        from backend.app.services.external_camera import capture_frame as capture_external_frame
-
-        async with async_session() as db:
-            printer = await db.get(Printer, printer_id)
-        if printer is None:
-            self._last_error = f"Printer {printer_id} not found"
-            return None
+        from backend.app.services.failure_detection_camera import capture_detection_frame
 
-        if printer.external_camera_enabled and printer.external_camera_url:
-            # Same rule as the built-in branch below, which this used to skip:
-            # an external camera is single-reader too, so polling while a viewer
-            # is attached just fails (#2707).
-            from backend.app.api.routes.camera import live_frame_for_capture
-
-            defer, buffered = live_frame_for_capture(printer_id)
-            if defer:
-                if buffered:
-                    return buffered
-                logger.info(
-                    "Obico: viewer attached for printer %s but buffer empty; "
-                    "skipping this poll to avoid competing camera handle (#2707)",
-                    printer_id,
-                )
-                return None
-            return await capture_external_frame(
-                printer.external_camera_url,
-                printer.external_camera_type,
-                timeout=SNAPSHOT_CAPTURE_TIMEOUT,
-                snapshot_url=printer.external_camera_snapshot_url,
-            )
-
-        # Reuse the fan-out broadcaster's buffered frame when a viewer is
-        # already watching — avoids opening a second concurrent RTSP socket
-        # on printers that allow only one camera connection (e.g. X2D
-        # firmware 01.01.00.00; see #1271). Buffered frame is <1s old while
-        # a viewer is connected.
-        #
-        # When a viewer is attached but no frame is buffered yet (startup
-        # race, mid-reconnect), we DELIBERATELY skip this poll cycle instead
-        # of falling through to capture_camera_frame_bytes. Opening a fresh
-        # RTSP/chamber socket would compete with the live viewer and kick
-        # the fan-out connection on most firmwares — exactly the freeze
-        # reported in #1348. The poll loop retries in ~10s.
-        from backend.app.api.routes.camera import is_stream_active, try_get_active_buffered_frame
-
-        if is_stream_active(printer_id):
-            buffered = try_get_active_buffered_frame(printer_id)
-            if buffered:
-                return buffered
-            logger.info(
-                "Obico: viewer attached for printer %s but buffer empty; skipping this poll to avoid competing camera socket (#1348)",
-                printer_id,
+        try:
+            return await capture_detection_frame(
+                printer_id, session_factory=async_session, timeout=SNAPSHOT_CAPTURE_TIMEOUT
             )
+        except ValueError as e:
+            self._last_error = str(e)
             return None
 
-        return await capture_camera_frame_bytes(
-            ip_address=printer.ip_address,
-            access_code=printer.access_code,
-            model=printer.model,
-            timeout=SNAPSHOT_CAPTURE_TIMEOUT,
-        )
-
     def _no_verdict(self, printer_id: int, reason: str) -> None:
         """Record that this poll produced no verdict for ``printer_id``.
 

+ 8 - 0
backend/app/services/octoeverywhere_actions.py

@@ -0,0 +1,8 @@
+"""OctoEverywhere actions using Bambuddy's existing AI failure notification event."""
+
+from backend.app.services.obico_actions import execute_action as execute_detection_action
+
+
+async def execute_action(printer_id: int, action: str, task_name: str, print_quality: int) -> None:
+    """Dispatch a suggested action without treating print quality as confidence."""
+    await execute_detection_action(printer_id, action, task_name, None, print_quality=print_quality)

+ 807 - 0
backend/app/services/octoeverywhere_detection.py

@@ -0,0 +1,807 @@
+"""OctoEverywhere AI print-failure detection service.
+
+Each print owns a context so OctoEverywhere can combine camera snapshots over
+time. Snapshots follow the configured interval, sped up to the API's
+recommended interval while it suggests faster inspection, always respecting its
+minimum. The API decides when to warn or pause; raw model scores are not used
+for classification or actions.
+"""
+
+import asyncio
+import json
+import logging
+import math
+import time
+from collections import deque
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from email.utils import parsedate_to_datetime
+from functools import partial
+from urllib.parse import urlsplit
+
+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.octoeverywhere_images import MAX_IMAGE_BYTES, prepare_frame
+
+logger = logging.getLogger(__name__)
+
+CREATE_CONTEXT_URL = "https://gadget-pv1-oeapi.octoeverywhere.com/api/gadget/v1/createcontext"
+DETECTION_TIMEOUT = 30.0
+POLL_INTERVAL = 5
+DEFAULT_POLL_INTERVAL = 20
+ERROR_RETRY_INTERVAL = 60
+HISTORY_MAX = 50
+# Gadget's confidence levels: 1 suggests a warning or pause soonest, with more
+# false positives; 5 waits until the model is most certain.
+CONFIDENCE_LEVELS = {"lowest": 1, "low": 2, "medium": 3, "high": 4, "highest": 5}
+IP_RESTRICTED_ERROR = "OE_API_KEY_IP_RESTRICTED"
+ACCOUNT_ERROR_CODES = {
+    "OE_INVALID_API_KEY",
+    "OE_API_KEY_DISABLED",
+    "OE_API_KEY_BLOCKED_PAYMENT_FAILED",
+    IP_RESTRICTED_ERROR,
+    "OE_FREE_USAGE_LIMIT_REACHED",
+}
+
+API_ERRORS = {
+    "OE_BAD_ARGS": "OctoEverywhere rejected the request arguments.",
+    "OE_ARGS_PARSE_FAILED": "OctoEverywhere could not parse the request.",
+    "OE_CONTEXT_RATE_LIMITED": "OctoEverywhere requested a delay before the next check. Detection will retry automatically.",
+    "OE_IMAGE_DECODE_FAILED": "OctoEverywhere could not decode the camera snapshot.",
+    "OE_INVALID_API_KEY": "OctoEverywhere rejected the Gadget API key. Check your API key in AI Failure Detection settings.",
+    "OE_API_KEY_DISABLED": "The OctoEverywhere Gadget API key is disabled. Contact OctoEverywhere support to restore access.",
+    "OE_API_KEY_BLOCKED_PAYMENT_FAILED": ("The OctoEverywhere API account is unavailable. Check the account status."),
+    IP_RESTRICTED_ERROR: (
+        "This IP address is already in use by another OctoEverywhere Gadget API key. "
+        "Use the original key, or contact OctoEverywhere support."
+    ),
+    "OE_FREE_USAGE_LIMIT_REACHED": "API usage limit reached. Set up billing to continue.",
+    "OE_BACKEND_THROTTLED": "OctoEverywhere is temporarily busy. Detection will retry automatically.",
+    "OE_INTERNAL_ERROR": "OctoEverywhere encountered a service error. Detection will retry automatically.",
+}
+
+
+class _ApiError(Exception):
+    """An actionable error with no remote response bodies or request secrets."""
+
+    def __init__(
+        self,
+        message: str,
+        status_code: int | None = None,
+        retry_after: float = 0,
+        *,
+        context_missing: bool = False,
+        error_code: str | None = None,
+    ):
+        super().__init__(message)
+        self.status_code = status_code
+        self.retry_after = retry_after
+        self.context_missing = context_missing
+        self.error_code = _known_error_code(error_code)
+
+
+@dataclass
+class _Context:
+    context_id: str
+    process_url: str
+    fallback_url: str
+
+
+@dataclass
+class _PrintState:
+    print_id: str | None = None
+    context: _Context | None = None
+    next_check_at: float = 0
+    last_processed_at: float | None = None
+    interval: int | None = None
+    recommended_interval: int | None = None  # Active only while faster inspections are suggested.
+    frame_count: int = 0
+    print_quality: int | None = None
+    verdict: str = "unknown"
+    error: str | None = None
+    error_code: str | None = None
+    failures: int = 0
+    warning_fired: bool = False
+    action_fired: bool = False
+
+
+def _process_interval(server_interval: int | None, poll_interval: int, recommended_interval: int | None = None) -> int:
+    """Use the configured cadence, sped up by an active timing hint, respecting the minimum."""
+    # A faster-inspection hint may only shorten the interval the user chose.
+    interval = poll_interval if recommended_interval is None else min(recommended_interval, poll_interval)
+    return max(server_interval or interval, interval)
+
+
+def _known_error_code(value) -> str | None:
+    """Only expose documented, recognized codes, never arbitrary remote text."""
+    return value if isinstance(value, str) and value in API_ERRORS else None
+
+
+def _process_url(value) -> str:
+    """Only send the API key to HTTPS endpoints owned by OctoEverywhere."""
+    if not isinstance(value, str):
+        raise _ApiError("OctoEverywhere returned an invalid processing URL.")
+    try:
+        parsed = urlsplit(value)
+        valid = (
+            parsed.scheme == "https"
+            and parsed.hostname is not None
+            and parsed.hostname.endswith(".octoeverywhere.com")
+            and parsed.port in (None, 443)
+            and parsed.username is None
+            and parsed.password is None
+            and not parsed.fragment
+            and not any(character.isspace() for character in value)
+        )
+    except ValueError:
+        valid = False
+    if not valid:
+        raise _ApiError("OctoEverywhere returned an invalid processing URL.")
+    return value
+
+
+def _retry_after(response: httpx.Response) -> float:
+    value = response.headers.get("Retry-After", "")
+    try:
+        seconds = float(value)
+    except ValueError:
+        try:
+            seconds = (parsedate_to_datetime(value) - datetime.now(timezone.utc)).total_seconds()
+        except (TypeError, ValueError, OverflowError):
+            return 0
+    return max(0, seconds) if math.isfinite(seconds) else 0
+
+
+def _response_payload(response: httpx.Response) -> dict:
+    try:
+        payload = response.json()
+    except ValueError:
+        payload = None
+    if not 200 <= response.status_code < 300:
+        error_type = payload.get("ErrorType") if isinstance(payload, dict) else None
+        # The v1 server uses this specific 500 response for a missing context,
+        # including one removed by its 14-day TTL. Other internal errors must
+        # retain their context so a transient outage does not reset the model.
+        context_missing = (
+            response.status_code == 500
+            and error_type == "OE_INTERNAL_ERROR"
+            and payload.get("ErrorDetails") == "Failed to get context object."
+        )
+        message = API_ERRORS.get(error_type) if isinstance(error_type, str) else None
+        if context_missing:
+            message = "OctoEverywhere print context is no longer available. Detection will reconnect automatically."
+        if not message:
+            if response.status_code in (401, 403):
+                message = API_ERRORS["OE_INVALID_API_KEY"]
+                error_type = "OE_INVALID_API_KEY"
+            elif response.status_code == 429:
+                message = API_ERRORS["OE_CONTEXT_RATE_LIMITED"]
+            else:
+                message = f"OctoEverywhere API request failed (HTTP {response.status_code})."
+        raise _ApiError(
+            message,
+            response.status_code,
+            _retry_after(response),
+            context_missing=context_missing,
+            error_code=error_type,
+        )
+    if not isinstance(payload, dict):
+        raise _ApiError("OctoEverywhere returned an invalid response.", response.status_code)
+    return payload
+
+
+class OctoEverywhereDetectionService:
+    """Singleton service that uploads snapshots from monitored, running prints."""
+
+    def __init__(self):
+        self._task: asyncio.Task | None = None
+        self._checks: dict[int, asyncio.Task] = {}
+        self._states: dict[int, _PrintState] = {}
+        self._configuration: tuple[str, str] | None = None
+        self._generation = 0
+        self._blocked_api_key: str | None = None
+        self._blocked_error_code: str | None = None
+        self._history: deque = deque(maxlen=HISTORY_MAX)
+        self._last_error: str | None = None
+        self._last_error_code: str | None = None
+
+    def _set_last_error(self, reason: str | None, error_code: str | None = None):
+        if self._blocked_error_code:
+            reason = API_ERRORS[self._blocked_error_code]
+            error_code = self._blocked_error_code
+        self._last_error = reason
+        self._last_error_code = _known_error_code(error_code) if reason else None
+
+    def _refresh_last_error(self):
+        state = next((state for state in self._states.values() if state.error), None)
+        self._set_last_error(state.error if state else None, state.error_code if state else None)
+
+    def _block_account(self, api_key: str, error_code: str):
+        """Account failures stop every printer until the user explicitly retries."""
+        self._blocked_api_key = api_key
+        self._blocked_error_code = error_code
+        self._generation += 1
+        for task in self._checks.values():
+            if task is not asyncio.current_task():
+                task.cancel()
+        self._refresh_last_error()
+        logger.warning("OctoEverywhere inspections stopped: %s", API_ERRORS[error_code])
+
+    def _clear_account_error(self):
+        # Keep contexts, action tracking, and reserved server deadlines intact.
+        self._blocked_api_key = None
+        self._blocked_error_code = None
+        self._refresh_last_error()
+
+    async def start(self):
+        if self._task is not None and not self._task.done():
+            return
+        logger.info("Starting OctoEverywhere detection service")
+        self._task = asyncio.create_task(self._loop())
+
+    def stop(self):
+        if self._task:
+            self._task.cancel()
+            self._task = None
+            logger.info("Stopped OctoEverywhere detection service")
+        for task in self._checks.values():
+            task.cancel()
+        self._checks.clear()
+        self._states.clear()
+        self._generation += 1
+        self._configuration = None
+        self._clear_account_error()
+        self._set_last_error(None)
+
+    def reset_printer(self, printer_id: int):
+        """Discard a completed print, including any inference still in flight."""
+        task = self._checks.pop(printer_id, None)
+        if task is not None and task is not asyncio.current_task():
+            task.cancel()
+        self._states.pop(printer_id, None)
+        self._refresh_last_error()
+
+    # ---- settings ----
+
+    async def refresh_settings(self):
+        """Invalidate in-flight decisions immediately after settings are saved."""
+        self._generation += 1
+        generation = self._generation
+        task = self._task
+        restart = task is not None and not task.done()
+        self._task = None
+        if task is not None:
+            task.cancel()
+            await asyncio.gather(task, return_exceptions=True)
+        # Printer checks run independently of the loop, so cancel and drain
+        # them before applying settings or restarting the scheduler.
+        checks = [task for task in self._checks.values() if task is not asyncio.current_task()]
+        for task in checks:
+            task.cancel()
+        if checks:
+            await asyncio.gather(*checks, return_exceptions=True)
+        settings = await self._load_settings()
+        if self._generation == generation:
+            self._apply_settings(settings)
+        if restart and self._task is None:
+            await self.start()
+
+    def _apply_settings(self, settings: dict):
+        configuration = (settings["api_key"], settings["confidence"])
+        if self._configuration != configuration:
+            self._generation += 1
+            for task in self._checks.values():
+                if task is not asyncio.current_task():
+                    task.cancel()
+            if settings["api_key"] != self._blocked_api_key:
+                self._clear_account_error()
+            # A settings edit is still the same print. Replace its remote
+            # context, but keep server pacing and actions already performed.
+            for state in self._states.values():
+                state.context = None
+                state.recommended_interval = None
+                state.frame_count = 0
+                state.print_quality = None
+                state.verdict = "unknown"
+                state.error = None
+                state.error_code = None
+                state.failures = 0
+            self._configuration = configuration
+            self._set_last_error(None)
+        if not settings["enabled"] or not settings["api_key"]:
+            for printer_id in self._states.keys() | self._checks.keys():
+                self.reset_printer(printer_id)
+            self._set_last_error(None)
+        if settings["enabled"] and not settings["api_key"]:
+            self._set_last_error(
+                "An OctoEverywhere Gadget API key is required. Add a key in Failure Detection settings."
+            )
+        if settings["enabled_printers"] is not None:
+            for printer_id in self._states.keys() | self._checks.keys():
+                if printer_id not in settings["enabled_printers"]:
+                    self.reset_printer(printer_id)
+
+    async def _load_settings(self) -> dict:
+        keys = [
+            "octoeverywhere_enabled",
+            "octoeverywhere_api_key",
+            "octoeverywhere_confidence",
+            "octoeverywhere_action",
+            "octoeverywhere_enabled_printers",
+            "octoeverywhere_poll_interval",
+        ]
+        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("octoeverywhere_enabled_printers", "")
+        enabled_printers = None
+        if enabled_printers_raw:
+            try:
+                printer_ids = json.loads(enabled_printers_raw)
+                enabled_printers = (
+                    set(printer_ids)
+                    if isinstance(printer_ids, list) and all(type(pid) is int for pid in printer_ids)
+                    else set()
+                )
+            except (TypeError, ValueError):
+                enabled_printers = set()
+
+        try:
+            poll_interval = int(rows.get("octoeverywhere_poll_interval", DEFAULT_POLL_INTERVAL))
+            if not 5 <= poll_interval <= 30:
+                poll_interval = DEFAULT_POLL_INTERVAL
+        except (TypeError, ValueError):
+            poll_interval = DEFAULT_POLL_INTERVAL
+
+        return {
+            "enabled": rows.get("octoeverywhere_enabled", "false").lower() == "true",
+            "api_key": (rows.get("octoeverywhere_api_key") or "").strip(),
+            "confidence": rows.get("octoeverywhere_confidence", "medium"),
+            "action": rows.get("octoeverywhere_action", "notify"),
+            "enabled_printers": enabled_printers,
+            "poll_interval": poll_interval,
+        }
+
+    # ---- main loop ----
+
+    async def _loop(self):
+        while True:
+            try:
+                generation = self._generation
+                settings = await self._load_settings()
+                if generation != self._generation:
+                    continue
+                await self._poll_once(settings)
+                # Enabled printers need whole-second scheduling for values such
+                # as 21 seconds; disabled detection can check settings less often.
+                await asyncio.sleep(1 if settings["enabled"] else POLL_INTERVAL)
+            except asyncio.CancelledError:
+                break
+            except Exception as exc:
+                # Never include exception text: transport errors can contain
+                # request URLs and user-supplied credentials.
+                self._set_last_error("OctoEverywhere detection is temporarily unavailable. Retrying automatically.")
+                logger.error("OctoEverywhere detection loop error: %s", type(exc).__name__)
+                await asyncio.sleep(ERROR_RETRY_INTERVAL)
+
+    async def _poll_once(self, settings: dict):
+        from backend.app.services.printer_manager import printer_manager
+
+        self._apply_settings(settings)
+        if not settings["enabled"] or not settings["api_key"]:
+            return
+
+        statuses = printer_manager.get_all_statuses()
+        for printer_id in self._states.keys() | self._checks.keys():
+            if printer_id not in statuses:
+                self.reset_printer(printer_id)
+
+        for printer_id, status in list(statuses.items()):
+            if settings["enabled_printers"] is not None and printer_id not in settings["enabled_printers"]:
+                self.reset_printer(printer_id)
+                continue
+            if not printer_manager.is_connected(printer_id):
+                task = self._checks.get(printer_id)
+                if task:
+                    task.cancel()
+                state = self._states.get(printer_id)
+                if state:
+                    self._no_verdict(printer_id, state, "Printer is disconnected. Detection will resume on reconnect.")
+                continue
+            print_state = getattr(status, "state", None)
+            if print_state in ("FINISH", "FAILED", "IDLE"):
+                self.reset_printer(printer_id)
+                continue
+            # Unknown or transitional status is not evidence that a print ended.
+            # Keep its context, but only upload while the printer is running.
+            if print_state != "RUNNING":
+                task = self._checks.get(printer_id)
+                if task:
+                    task.cancel()
+                continue
+            state = self._states.get(printer_id)
+            if state and not self._same_print(state, status):
+                self.reset_printer(printer_id)
+            task = self._checks.get(printer_id)
+            if task is not None and not task.done():
+                continue
+            # One check per printer; a slow camera or API request must not
+            # delay another printer's next inspection or lifecycle updates.
+            task = asyncio.create_task(self._check_printer(printer_id, status, settings))
+            self._checks[printer_id] = task
+            task.add_done_callback(partial(self._check_done, printer_id))
+
+    def _check_done(self, printer_id: int, task: asyncio.Task):
+        # A canceled check may finish after its printer has started a new one.
+        if self._checks.get(printer_id) is task:
+            self._checks.pop(printer_id, None)
+        if not task.cancelled():
+            error = task.exception()
+            if error is not None:
+                logger.error("OctoEverywhere printer check failed: %s", type(error).__name__)
+
+    @staticmethod
+    def _print_id(status) -> str | None:
+        subtask_id = getattr(status, "subtask_id", None)
+        if subtask_id and str(subtask_id) != "0":
+            return str(subtask_id)
+        return None
+
+    def _same_print(self, state: _PrintState, status) -> bool:
+        """Learn late job IDs without discarding the context or forgetting a known ID."""
+        print_id = self._print_id(status)
+        if state.print_id and print_id and state.print_id != print_id:
+            return False
+        # Names and filenames may arrive late or change independently. Without
+        # two distinct job IDs, rely on the print start/end callbacks instead.
+        state.print_id = print_id or state.print_id
+        return True
+
+    def _is_current(self, printer_id: int, state: _PrintState, status, generation: int) -> bool:
+        return (
+            self._states.get(printer_id) is state
+            and self._generation == generation
+            and getattr(status, "state", None) == "RUNNING"
+            and self._same_print(state, status)
+        )
+
+    async def _capture_frame(self, printer_id: int) -> bytes | None:
+        from backend.app.services.failure_detection_camera import capture_detection_frame
+
+        return await capture_detection_frame(printer_id)
+
+    async def _create_context(self, client: httpx.AsyncClient, api_key: str, confidence: str) -> tuple[_Context, int]:
+        level = CONFIDENCE_LEVELS.get(confidence, 3)
+        response = await client.post(
+            CREATE_CONTEXT_URL,
+            headers={"X-API-Key": api_key},
+            json={"WarningConfidenceLevel": level, "PauseConfidenceLevel": level},
+        )
+        payload = _response_payload(response)
+        context_id = payload.get("ContextId")
+        if not isinstance(context_id, str) or not context_id.strip():
+            raise _ApiError("OctoEverywhere returned an invalid context.", response.status_code)
+        return (
+            _Context(
+                context_id=context_id,
+                process_url=_process_url(payload.get("ProcessRequestUrl")),
+                fallback_url=_process_url(payload.get("FallbackProcessRequestUrl")),
+            ),
+            response.status_code,
+        )
+
+    def _no_verdict(self, printer_id: int, state: _PrintState, reason: str, error_code: str | None = None):
+        if self._states.get(printer_id) is not state:
+            return
+        if state.error != reason:
+            logger.warning("OctoEverywhere printer %s: %s", printer_id, reason)
+        state.error = reason
+        state.error_code = _known_error_code(error_code)
+        self._set_last_error(reason, state.error_code)
+
+    async def _check_printer(self, printer_id: int, status, settings: dict):
+        generation = self._generation
+        state = self._states.get(printer_id)
+        if state is None or not self._same_print(state, status):
+            state = _PrintState(print_id=self._print_id(status))
+            self._states[printer_id] = state
+            self._refresh_last_error()
+        if self._blocked_error_code:
+            return
+        if state.last_processed_at is not None and not state.error:
+            # Apply interval edits to an existing context on the next poll.
+            # Failed/canceled requests clear last_processed_at, preserving their
+            # reserved retry deadline regardless of the configured interval.
+            state.next_check_at = state.last_processed_at + _process_interval(
+                state.interval, settings["poll_interval"], state.recommended_interval
+            )
+        if time.monotonic() < state.next_check_at:
+            return
+
+        # Reserve this poll before awaiting camera/network I/O. Even a failed
+        # capture must not create a hot loop or compete with a live camera.
+        state.next_check_at = time.monotonic() + _process_interval(
+            state.interval, settings["poll_interval"], state.recommended_interval
+        )
+        logger.debug(
+            "OctoEverywhere inspection starting for printer %s (frame=%s, context_reused=%s)",
+            printer_id,
+            state.frame_count + 1,
+            state.context is not None,
+        )
+        processing = False
+        try:
+            frame = await self._capture_frame(printer_id)
+            if not frame:
+                raise _ApiError("Could not capture a camera snapshot. Detection will retry automatically.")
+            if len(frame) > MAX_IMAGE_BYTES:
+                try:
+                    frame = await asyncio.to_thread(prepare_frame, frame)
+                except ValueError:
+                    raise _ApiError("Could not reduce the camera snapshot to OctoEverywhere's 6 MiB image limit.")
+            if not self._is_current(printer_id, state, status, generation):
+                logger.debug(
+                    "OctoEverywhere inspection discarded for printer %s: print status or settings changed", printer_id
+                )
+                return
+            async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT, follow_redirects=False) as client:
+                if state.context is None:
+                    context, _ = await self._create_context(client, settings["api_key"], settings["confidence"])
+                    if not self._is_current(printer_id, state, status, generation):
+                        logger.debug(
+                            "OctoEverywhere inspection discarded for printer %s: print status or settings changed",
+                            printer_id,
+                        )
+                        return
+                    state.context = context
+                    logger.debug("OctoEverywhere context created for printer %s", printer_id)
+                if not self._is_current(printer_id, state, status, generation):
+                    logger.debug(
+                        "OctoEverywhere inspection discarded for printer %s: print status or settings changed",
+                        printer_id,
+                    )
+                    return
+                processing = True
+                response = await client.post(
+                    state.context.process_url,
+                    headers={"X-API-Key": settings["api_key"]},
+                    files={"image": ("snapshot.jpg", frame, "image/jpeg")},
+                )
+                payload = _response_payload(response)
+                interval = payload.get("NextProcessIntervalSec")
+                minimum_interval = interval.get("Minimum") if isinstance(interval, dict) else None
+                if type(minimum_interval) is int and minimum_interval > 0:
+                    # Preserve the latest minimum even if the assessment is
+                    # invalid or canceled before its timing hint is accepted.
+                    state.interval = minimum_interval
+                    state.next_check_at = time.monotonic() + _process_interval(
+                        minimum_interval, settings["poll_interval"], state.recommended_interval
+                    )
+                else:
+                    raise _ApiError("OctoEverywhere returned an invalid processing interval.")
+                faster_inspection_suggested = payload.get("FasterInspectionSuggested") is True
+                recommended_interval = interval.get("Recommended") if faster_inspection_suggested else None
+                # This hint is optional. Older responses and malformed hints
+                # keep fixed timing without discarding a valid assessment.
+                if type(recommended_interval) is not int or recommended_interval <= 0:
+                    recommended_interval = None
+                print_quality = payload.get("PrintQuality")
+                warning = payload.get("WarningSuggested")
+                pause = payload.get("PauseSuggested")
+                if (
+                    type(print_quality) is not int
+                    or not 1 <= print_quality <= 10
+                    or type(warning) is not bool
+                    or type(pause) is not bool
+                ):
+                    raise _ApiError("OctoEverywhere returned an invalid print assessment.")
+        except asyncio.CancelledError:
+            # A request canceled during a settings edit may already have been
+            # processed remotely. Preserve the previous minimum before retrying.
+            state.next_check_at = max(
+                state.next_check_at,
+                time.monotonic()
+                + _process_interval(state.interval, settings["poll_interval"], state.recommended_interval),
+            )
+            state.last_processed_at = None
+            logger.debug("OctoEverywhere inspection canceled for printer %s", printer_id)
+            raise
+        except Exception as exc:
+            state.last_processed_at = None
+            # Account failures still apply if this print ended during the
+            # request, but an old key or generation must not block new work.
+            if (
+                isinstance(exc, _ApiError)
+                and exc.error_code in ACCOUNT_ERROR_CODES
+                and generation == self._generation
+                and (self._configuration is None or settings["api_key"] == self._configuration[0])
+            ):
+                self._block_account(settings["api_key"], exc.error_code)
+                logger.debug(
+                    "OctoEverywhere inspection failed for printer %s (error_code=%s); inspections stopped",
+                    printer_id,
+                    exc.error_code,
+                )
+                return
+            if not self._is_current(printer_id, state, status, generation):
+                state.next_check_at = max(
+                    state.next_check_at,
+                    time.monotonic()
+                    + _process_interval(state.interval, settings["poll_interval"], state.recommended_interval),
+                )
+                logger.debug(
+                    "OctoEverywhere inspection discarded for printer %s: print status or settings changed", printer_id
+                )
+                return
+            if processing and state.context:
+                # Keep the fallback for the rest of this context. Retrying on
+                # the next scheduled poll also respects the API's rate limit.
+                if isinstance(exc, _ApiError) and exc.context_missing:
+                    state.context = None  # Recreate after backoff; preserve actions already performed.
+                    state.recommended_interval = None
+                elif isinstance(exc, httpx.RequestError) or (
+                    isinstance(exc, _ApiError)
+                    and exc.status_code is not None
+                    and exc.status_code >= 500
+                    and exc.error_code in (None, "OE_INTERNAL_ERROR", "OE_BACKEND_THROTTLED")
+                ):
+                    state.context.process_url = state.context.fallback_url
+            state.failures += 1
+            retry_after = exc.retry_after if isinstance(exc, _ApiError) else 0
+            delay = max(
+                _process_interval(state.interval, settings["poll_interval"], state.recommended_interval),
+                ERROR_RETRY_INTERVAL * 2 ** min(state.failures - 1, 4),
+                retry_after,
+            )
+            state.next_check_at = time.monotonic() + delay
+            if isinstance(exc, _ApiError):
+                reason = str(exc)
+            elif isinstance(exc, httpx.TimeoutException):
+                reason = "OctoEverywhere request timed out. Detection will retry automatically."
+            elif isinstance(exc, httpx.RequestError):
+                reason = "Could not reach OctoEverywhere. Check the internet connection."
+            else:
+                reason = "OctoEverywhere detection failed. Detection will retry automatically."
+            self._no_verdict(printer_id, state, reason, exc.error_code if isinstance(exc, _ApiError) else None)
+            logger.debug(
+                "OctoEverywhere inspection failed for printer %s (error_code=%s, retry_in=%.1fs): %s",
+                printer_id,
+                state.error_code,
+                delay,
+                reason,
+            )
+            return
+
+        if not self._is_current(printer_id, state, status, generation):
+            logger.debug(
+                "OctoEverywhere inspection discarded for printer %s: print status or settings changed", printer_id
+            )
+            return
+        state.recommended_interval = recommended_interval
+        state.last_processed_at = time.monotonic()
+        state.next_check_at = state.last_processed_at + _process_interval(
+            state.interval, settings["poll_interval"], state.recommended_interval
+        )
+        state.frame_count += 1
+        state.print_quality = print_quality
+        state.verdict = "failure" if pause else "warning" if warning else "safe"
+        state.error = None
+        state.error_code = None
+        state.failures = 0
+        self._refresh_last_error()
+        logger.debug(
+            "OctoEverywhere inspection result for printer %s: frame=%s, verdict=%s, print_quality=%s, "
+            "warning_suggested=%s, pause_suggested=%s, faster_inspection_suggested=%s, "
+            "minimum_interval=%ss, next_check_in=%ss",
+            printer_id,
+            state.frame_count,
+            state.verdict,
+            print_quality,
+            warning,
+            pause,
+            faster_inspection_suggested,
+            state.interval,
+            _process_interval(state.interval, settings["poll_interval"], state.recommended_interval),
+        )
+        task_name = getattr(status, "task_name", None) or getattr(status, "subtask_name", "") or ""
+        if state.verdict != "safe":
+            self._history.appendleft(
+                {
+                    "printer_id": printer_id,
+                    "task_name": task_name,
+                    "timestamp": datetime.now(timezone.utc).isoformat(),
+                    "class": state.verdict,
+                    "print_quality": print_quality,
+                }
+            )
+
+        if pause and not state.action_fired:
+            action = settings["action"]
+            # A pause or power cut always notifies, even after a warning: the
+            # user needs to know the printer stopped. Notify-only would just
+            # repeat the warning, so it stays silent.
+            if action != "notify" or not state.warning_fired:
+                await self._dispatch_action(printer_id, action, task_name, print_quality)
+            state.action_fired = True
+            state.warning_fired = True
+        elif warning and not state.warning_fired:
+            await self._dispatch_action(printer_id, "notify", task_name, print_quality)
+            state.warning_fired = True
+
+    async def _dispatch_action(self, printer_id: int, action: str, task_name: str, print_quality: int):
+        from backend.app.services.octoeverywhere_actions import execute_action
+
+        logger.warning(
+            "OctoEverywhere: print issue on printer %s (quality=%s) — action=%s", printer_id, print_quality, action
+        )
+        try:
+            await execute_action(printer_id, action, task_name, print_quality)
+        except Exception as exc:
+            self._set_last_error("OctoEverywhere could not execute the configured failure action.")
+            logger.error("OctoEverywhere action dispatch failed: %s", type(exc).__name__)
+
+    # ---- queries ----
+
+    def get_per_printer(self) -> dict:
+        return {
+            printer_id: {
+                "class": "error" if self._blocked_error_code or state.error else state.verdict,
+                "print_quality": state.print_quality,
+                "frame_count": state.frame_count,
+                "error": API_ERRORS[self._blocked_error_code] if self._blocked_error_code else state.error,
+                "error_code": self._blocked_error_code or state.error_code,
+            }
+            for printer_id, state in self._states.items()
+        }
+
+    def get_status(self) -> dict:
+        return {
+            "is_running": self._task is not None and not self._task.done(),
+            "last_error": self._last_error,
+            "last_error_code": self._last_error_code,
+            "per_printer": self.get_per_printer(),
+            "history": list(self._history),
+        }
+
+    async def test_connection(self, api_key: str, confidence: str = "medium") -> dict:
+        """Validate a key with a context request; never upload a camera frame."""
+        generation = self._generation
+        api_key = api_key.strip()
+        if not api_key:
+            return {
+                "ok": False,
+                "status_code": None,
+                "error": "An OctoEverywhere Gadget API key is required.",
+                "error_code": None,
+            }
+        try:
+            async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT, follow_redirects=False) as client:
+                _, status_code = await self._create_context(client, api_key, confidence)
+        except _ApiError as exc:
+            if (
+                generation == self._generation
+                and self._configuration
+                and api_key == self._configuration[0]
+                and exc.error_code in ACCOUNT_ERROR_CODES
+            ):
+                self._block_account(api_key, exc.error_code)
+            return {"ok": False, "status_code": exc.status_code, "error": str(exc), "error_code": exc.error_code}
+        except httpx.TimeoutException:
+            return {"ok": False, "status_code": None, "error": "OctoEverywhere request timed out.", "error_code": None}
+        except Exception:
+            return {
+                "ok": False,
+                "status_code": None,
+                "error": "Could not connect to OctoEverywhere.",
+                "error_code": None,
+            }
+        if generation == self._generation and self._configuration and api_key == self._configuration[0]:
+            self._clear_account_error()
+        return {"ok": True, "status_code": status_code, "error": None, "error_code": None}
+
+
+octoeverywhere_detection_service = OctoEverywhereDetectionService()

+ 36 - 0
backend/app/services/octoeverywhere_images.py

@@ -0,0 +1,36 @@
+"""Prepare oversized camera snapshots for the Gadget API's image limit."""
+
+from io import BytesIO
+
+from PIL import Image, ImageOps
+
+MAX_IMAGE_BYTES = 6 * 1024 * 1024
+MAX_IMAGE_PIXELS = 64_000_000
+MAX_IMAGE_DIMENSION = 2048
+INVALID_IMAGE_MESSAGE = "Could not prepare the camera snapshot for OctoEverywhere."
+
+
+def prepare_frame(frame: bytes) -> bytes:
+    """Keep normal frames unchanged and shrink oversized images to a JPEG."""
+    if len(frame) <= MAX_IMAGE_BYTES:
+        return frame
+
+    try:
+        with Image.open(BytesIO(frame)) as source:
+            if source.width * source.height > MAX_IMAGE_PIXELS:
+                raise ValueError(INVALID_IMAGE_MESSAGE)
+            # JPEG decoders can reduce resolution before allocating all pixels.
+            # Bound the working image before copying or converting its colors.
+            source.draft("RGB", (MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION))
+            source.thumbnail((MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION), Image.Resampling.LANCZOS)
+            with ImageOps.exif_transpose(source) as oriented, oriented.convert("RGB") as image:
+                for quality in (90, 80, 70):
+                    output = BytesIO()
+                    image.save(output, format="JPEG", quality=quality)
+                    prepared = output.getvalue()
+                    if len(prepared) <= MAX_IMAGE_BYTES:
+                        return prepared
+    except Exception:
+        raise ValueError(INVALID_IMAGE_MESSAGE) from None
+
+    raise ValueError(INVALID_IMAGE_MESSAGE)

+ 1 - 0
backend/tests/conftest.py

@@ -564,6 +564,7 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
         # 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.services.octoeverywhere_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

+ 388 - 0
backend/tests/integration/test_octoeverywhere_api.py

@@ -0,0 +1,388 @@
+"""Settings, permissions, and status routes for OctoEverywhere detection."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
+from backend.app.services.octoeverywhere_detection import octoeverywhere_detection_service as service
+
+
+@pytest.fixture(autouse=True)
+def clear_detection_state():
+    service._states.clear()
+    service._history.clear()
+    service._last_error = None
+    service._last_error_code = None
+    yield
+    service._states.clear()
+    service._history.clear()
+    service._last_error = None
+    service._last_error_code = None
+
+
+@pytest.mark.integration
+async def test_settings_round_trip_and_provider_switch(async_client):
+    settings = (await async_client.get("/api/v1/settings/")).json()
+    assert settings["octoeverywhere_enabled"] is False
+    assert settings["octoeverywhere_api_key"] == ""
+    assert settings["octoeverywhere_api_key_configured"] is False
+    assert settings["octoeverywhere_confidence"] == "medium"
+    assert settings["octoeverywhere_poll_interval"] == 20
+    assert settings["octoeverywhere_action"] == "notify"
+
+    await async_client.put("/api/v1/settings/", json={"obico_enabled": True})
+    response = await async_client.put(
+        "/api/v1/settings/",
+        json={
+            "octoeverywhere_enabled": True,
+            "octoeverywhere_api_key": "  test-key  ",
+            "octoeverywhere_confidence": "high",
+            "octoeverywhere_action": "pause",
+            "octoeverywhere_enabled_printers": "[3, 1]",
+            "octoeverywhere_poll_interval": 15,
+        },
+    )
+    assert response.status_code == 200
+    assert response.json()["octoeverywhere_api_key"] == ""
+    assert response.json()["octoeverywhere_api_key_configured"] is True
+    assert response.json()["octoeverywhere_poll_interval"] == 15
+    assert response.json()["obico_enabled"] is False
+    status = (await async_client.get("/api/v1/octoeverywhere/status")).json()
+    assert status["enabled"] is True
+    assert status["api_key_configured"] is True
+    assert status["confidence"] == "high"
+    assert status["action"] == "pause"
+    assert status["poll_interval"] == 15
+    assert "test-key" not in str(status)
+    cards = (await async_client.get("/api/v1/octoeverywhere/printer-status")).json()
+    assert cards["monitored_printers"] == [1, 3]
+    for key in ("api_key", "api_key_configured", "action", "history", "confidence"):
+        assert key not in cards
+
+    response = await async_client.put("/api/v1/settings/", json={"obico_enabled": True})
+    assert response.json()["octoeverywhere_enabled"] is False
+    assert response.json()["octoeverywhere_api_key"] == ""
+    assert response.json()["octoeverywhere_api_key_configured"] is True
+    assert response.json()["octoeverywhere_poll_interval"] == 15
+
+
+@pytest.mark.integration
+async def test_write_only_key_preserved_replaced_and_removed(async_client):
+    await async_client.put("/api/v1/settings/", json={"octoeverywhere_api_key": "first-key"})
+    for payload in ({"octoeverywhere_confidence": "low"}, {"octoeverywhere_api_key": None}):
+        response = await async_client.put("/api/v1/settings/", json=payload)
+        assert response.json()["octoeverywhere_api_key"] == ""
+        assert response.json()["octoeverywhere_api_key_configured"] is True
+        assert (await service._load_settings())["api_key"] == "first-key"
+    await async_client.put("/api/v1/settings/", json={"octoeverywhere_api_key": "replacement-key"})
+    settings = (await async_client.get("/api/v1/settings/")).json()
+    assert settings["octoeverywhere_api_key"] == ""
+    assert settings["octoeverywhere_api_key_configured"] is True
+    assert (await service._load_settings())["api_key"] == "replacement-key"
+    response = await async_client.put("/api/v1/settings/", json={"octoeverywhere_api_key": ""})
+    assert response.json()["octoeverywhere_api_key_configured"] is False
+    assert (await service._load_settings())["api_key"] == ""
+
+
+@pytest.mark.integration
+async def test_fixed_interval_round_trip(async_client):
+    for interval in (5, 30, 20):
+        response = await async_client.put("/api/v1/settings/", json={"octoeverywhere_poll_interval": interval})
+        assert response.status_code == 200
+        assert response.json()["octoeverywhere_poll_interval"] == interval
+        settings = (await async_client.get("/api/v1/settings/")).json()
+        assert settings["octoeverywhere_poll_interval"] == interval
+        assert (await service._load_settings())["poll_interval"] == interval
+
+
+@pytest.mark.integration
+@pytest.mark.parametrize("confidence", ["lowest", "low", "medium", "high", "highest"])
+async def test_confidence_round_trip_and_connection_validation(async_client, confidence):
+    response = await async_client.put(
+        "/api/v1/settings/", json={"octoeverywhere_api_key": "saved-key", "octoeverywhere_confidence": confidence}
+    )
+    assert response.status_code == 200
+    assert response.json()["octoeverywhere_confidence"] == confidence
+    assert (await async_client.get("/api/v1/settings/")).json()["octoeverywhere_confidence"] == confidence
+    assert (await async_client.get("/api/v1/octoeverywhere/status")).json()["confidence"] == confidence
+    assert (await service._load_settings())["confidence"] == confidence
+
+    with patch.object(service, "test_connection", new_callable=AsyncMock) as check:
+        check.return_value = {"ok": True, "status_code": 200, "error": None, "error_code": None}
+        for payload in ({}, {"confidence": confidence}):
+            response = await async_client.post("/api/v1/octoeverywhere/test-connection", json=payload)
+            assert response.status_code == 200
+            assert response.json()["ok"] is True
+            check.assert_awaited_once_with("saved-key", confidence)
+            check.reset_mock()
+
+
+@pytest.mark.integration
+@pytest.mark.parametrize("confidence", ["maximum", "Lowest", "", 5, True])
+async def test_invalid_confidence_rejected_by_settings_and_connection(async_client, confidence):
+    response = await async_client.put("/api/v1/settings/", json={"octoeverywhere_confidence": confidence})
+    assert response.status_code == 422
+    with patch.object(service, "test_connection", new_callable=AsyncMock) as check:
+        response = await async_client.post("/api/v1/octoeverywhere/test-connection", json={"confidence": confidence})
+    assert response.status_code == 422
+    check.assert_not_awaited()
+
+
+@pytest.mark.integration
+@pytest.mark.parametrize("sensitivity", ["lowest", "highest"])
+async def test_obico_rejects_octoeverywhere_only_levels(async_client, sensitivity):
+    response = await async_client.put("/api/v1/settings/", json={"obico_sensitivity": sensitivity})
+    assert response.status_code == 422
+
+
+@pytest.mark.integration
+@pytest.mark.parametrize("sensitivity", ["low", "medium", "high"])
+async def test_obico_existing_sensitivity_levels_remain_supported(async_client, sensitivity):
+    response = await async_client.put("/api/v1/settings/", json={"obico_sensitivity": sensitivity})
+    assert response.status_code == 200
+    assert response.json()["obico_sensitivity"] == sensitivity
+
+
+@pytest.mark.integration
+async def test_rejects_two_enabled_providers(async_client):
+    response = await async_client.put("/api/v1/settings/", json={"obico_enabled": True, "octoeverywhere_enabled": True})
+    assert response.status_code == 400
+
+
+@pytest.mark.integration
+@pytest.mark.parametrize(
+    "payload",
+    [
+        {"octoeverywhere_confidence": "maximum"},
+        {"octoeverywhere_action": "stop"},
+        {"octoeverywhere_enabled_printers": "{}"},
+        {"octoeverywhere_enabled_printers": "[true]"},
+        {"octoeverywhere_enabled_printers": "[-1]"},
+        {"octoeverywhere_enabled_printers": '["1"]'},
+        {"octoeverywhere_api_key": "key\r\nheader"},
+        {"octoeverywhere_poll_interval": 0},
+        {"octoeverywhere_poll_interval": 4},
+        {"octoeverywhere_poll_interval": 31},
+        {"octoeverywhere_poll_interval": 5.5},
+        {"octoeverywhere_poll_interval": True},
+        {"octoeverywhere_poll_interval": None},
+        {"octoeverywhere_poll_interval": "20"},
+    ],
+)
+async def test_invalid_settings_rejected(async_client, payload):
+    response = await async_client.put("/api/v1/settings/", json=payload)
+    assert response.status_code == 422
+
+
+@pytest.mark.integration
+async def test_connection_uses_saved_or_explicit_key(async_client):
+    await async_client.put(
+        "/api/v1/settings/", json={"octoeverywhere_api_key": "saved-key", "octoeverywhere_confidence": "low"}
+    )
+    with patch.object(service, "test_connection", new_callable=AsyncMock) as check:
+        check.return_value = {"ok": True, "status_code": 200, "error": None, "error_code": None}
+        response = await async_client.post("/api/v1/octoeverywhere/test-connection", json={})
+        assert response.json()["ok"] is True
+        check.assert_awaited_once_with("saved-key", "low")
+        check.reset_mock()
+        await async_client.post("/api/v1/octoeverywhere/test-connection", json={"api_key": ""})
+        check.assert_awaited_once_with("", "low")
+
+
+@pytest.mark.parametrize("can_see_error", [False, True])
+async def test_printer_status_respects_error_permissions(can_see_error):
+    from backend.app.api.routes.octoeverywhere import get_printer_status
+
+    user = MagicMock()
+    user.has_permission.return_value = can_see_error
+    service._last_error = "private diagnostic"
+    service._last_error_code = "OE_FREE_USAGE_LIMIT_REACHED"
+    entry = {
+        "class": "error",
+        "print_quality": None,
+        "frame_count": 0,
+        "error": "private diagnostic",
+        "error_code": "OE_FREE_USAGE_LIMIT_REACHED",
+    }
+    with (
+        patch.object(service, "_load_settings", return_value={"enabled": True, "enabled_printers": None}),
+        patch.object(service, "get_per_printer", return_value={1: entry}),
+    ):
+        result = await get_printer_status(user=user, printer_scope=ALL_PRINTERS, actor=user)
+    assert result["per_printer"][1]["class"] == "error"
+    assert result["per_printer"][1]["error"] == ("private diagnostic" if can_see_error else None)
+    assert result["per_printer"][1]["error_code"] == ("OE_FREE_USAGE_LIMIT_REACHED" if can_see_error else None)
+    assert result["last_error"] == ("private diagnostic" if can_see_error else None)
+    assert result["last_error_code"] == ("OE_FREE_USAGE_LIMIT_REACHED" if can_see_error else None)
+
+
+@pytest.mark.integration
+async def test_printer_status_hides_diagnostics_from_api_keys(async_client):
+    from backend.app.core.auth import ApiKeyActor, get_request_actor
+    from backend.app.main import app
+    from backend.app.models.api_key import APIKey
+
+    # Permission dependencies return user=None for API keys too. Their actor
+    # must still enforce settings:read when deciding whether to expose errors.
+    actor = ApiKeyActor(APIKey(can_read_status=True), owner=None)
+    service._last_error = "private diagnostic"
+    service._last_error_code = "OE_FREE_USAGE_LIMIT_REACHED"
+    entry = {
+        "class": "error",
+        "print_quality": None,
+        "frame_count": 0,
+        "error": "private diagnostic",
+        "error_code": "OE_FREE_USAGE_LIMIT_REACHED",
+    }
+    app.dependency_overrides[get_request_actor] = lambda: actor
+    try:
+        with (
+            patch.object(service, "_load_settings", return_value={"enabled": True, "enabled_printers": None}),
+            patch.object(service, "get_per_printer", return_value={1: entry}),
+        ):
+            response = await async_client.get("/api/v1/octoeverywhere/printer-status")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["per_printer"]["1"]["class"] == "error"
+        assert result["per_printer"]["1"]["error"] is None
+        assert result["per_printer"]["1"]["error_code"] is None
+        assert result["last_error"] is None
+        assert result["last_error_code"] is None
+    finally:
+        app.dependency_overrides.pop(get_request_actor, None)
+
+
+@pytest.mark.integration
+@pytest.mark.parametrize("allowed_ids", [frozenset({1}), frozenset()])
+@pytest.mark.parametrize("enabled_printers", [None, {1, 2}])
+async def test_printer_status_hides_printers_outside_caller_scope(async_client, allowed_ids, enabled_printers):
+    from backend.app.core.auth import get_printer_scope_if_auth_enabled
+    from backend.app.main import app
+
+    entry = {"class": "safe", "print_quality": 95, "frame_count": 1, "error": None, "error_code": None}
+    app.dependency_overrides[get_printer_scope_if_auth_enabled] = lambda: PrinterScope(allowed_ids)
+    try:
+        with (
+            patch.object(
+                service, "_load_settings", return_value={"enabled": True, "enabled_printers": enabled_printers}
+            ),
+            patch.object(service, "get_per_printer", return_value={1: entry, 2: entry}),
+        ):
+            response = await async_client.get("/api/v1/octoeverywhere/printer-status")
+        assert response.status_code == 200
+        result = response.json()
+        assert set(result["per_printer"]) == {str(pid) for pid in allowed_ids}
+        assert result["monitored_printers"] == (None if enabled_printers is None else sorted(allowed_ids))
+    finally:
+        app.dependency_overrides.pop(get_printer_scope_if_auth_enabled, None)
+
+
+@pytest.mark.integration
+async def test_usage_limit_code_reaches_settings_and_printer_status(async_client):
+    from backend.app.services.octoeverywhere_detection import _PrintState
+
+    code = "OE_FREE_USAGE_LIMIT_REACHED"
+    message = "Usage limit reached. Set up billing to continue."
+    service._states[1] = _PrintState(print_id="job", error=message, error_code=code)
+    service._last_error = message
+    service._last_error_code = code
+
+    for endpoint in ("status", "printer-status"):
+        response = await async_client.get(f"/api/v1/octoeverywhere/{endpoint}")
+        assert response.status_code == 200
+        assert response.json()["last_error_code"] == code
+        assert response.json()["per_printer"]["1"]["error_code"] == code
+        assert response.json()["per_printer"]["1"]["class"] == "error"
+
+
+@pytest.mark.integration
+async def test_connection_preserves_usage_limit_code(async_client):
+    result = {
+        "ok": False,
+        "status_code": 429,
+        "error": "Usage limit reached. Set up billing to continue.",
+        "error_code": "OE_FREE_USAGE_LIMIT_REACHED",
+    }
+    with patch.object(service, "test_connection", return_value=result):
+        response = await async_client.post("/api/v1/octoeverywhere/test-connection", json={})
+    assert response.status_code == 200
+    assert response.json() == result
+
+
+@pytest.mark.parametrize("is_api_key", [True, False])
+async def test_settings_response_always_redacts_oe_key(db_session, is_api_key):
+    from backend.app.api.routes.settings import _build_settings_response, set_setting
+
+    await set_setting(db_session, "octoeverywhere_api_key", "secret-key")
+    await db_session.commit()
+    settings = await _build_settings_response(db_session, is_api_key=is_api_key)
+    assert settings.octoeverywhere_api_key == ""
+    assert settings.octoeverywhere_api_key_configured is True
+
+
+@pytest.mark.integration
+@pytest.mark.parametrize(
+    ("provider_settings", "scope", "configured", "uncovered"),
+    [
+        ([], "", False, [1, 2]),
+        ([{"enabled": False}], "", False, [1, 2]),
+        ([{"on_ai_failure_detection": False}], "", False, [1, 2]),
+        ([{}], "", True, []),
+        ([{"printer_id": 1}], "", True, [2]),
+        ([{"printer_id": 1}], "[1]", True, []),
+        ([{"printer_id": 1}], "[2]", True, [2]),
+        ([{"printer_id": 1}, {"printer_id": 2}], "", True, []),
+        ([], "[]", False, []),
+    ],
+)
+async def test_notification_readiness_matches_subscriptions(
+    async_client, db_session, provider_settings, scope, configured, uncovered
+):
+    from backend.app.models.notification import NotificationProvider
+    from backend.app.models.printer import Printer
+
+    for pid in (1, 2, 3):
+        db_session.add(
+            Printer(
+                id=pid,
+                name=f"Printer {pid}",
+                serial_number=f"TEST{pid}",
+                ip_address="192.0.2.1",
+                access_code="12345678",
+                is_active=pid != 3,
+            )
+        )
+    await db_session.flush()
+    for overrides in provider_settings:
+        values = {"enabled": True, "on_ai_failure_detection": True, **overrides}
+        db_session.add(NotificationProvider(name="Alerts", provider_type="ntfy", config='{"token":"secret"}', **values))
+    await db_session.commit()
+    await async_client.put("/api/v1/settings/", json={"octoeverywhere_enabled_printers": scope})
+
+    response = await async_client.get("/api/v1/octoeverywhere/status")
+    assert response.status_code == 200
+    assert response.json()["notifications"] == {"configured": configured, "uncovered_printers": uncovered}
+    assert "secret" not in response.text
+
+
+async def test_routes_require_existing_permissions():
+    from fastapi import HTTPException
+
+    from backend.app.api.routes.octoeverywhere import get_printer_status, get_status, test_connection
+    from backend.app.core.permissions import Permission
+
+    for route, permission in (
+        (get_status, Permission.SETTINGS_READ),
+        (get_printer_status, Permission.PRINTERS_READ),
+        (test_connection, Permission.SETTINGS_UPDATE),
+    ):
+        with (
+            patch("backend.app.core.auth.is_auth_enabled", return_value=True),
+            patch("backend.app.core.auth._validate_api_key", return_value=MagicMock()),
+            patch("backend.app.core.auth.authorize_api_key", side_effect=HTTPException(403)) as authorize,
+            pytest.raises(HTTPException) as exc,
+        ):
+            await route.__defaults__[0].dependency(x_api_key="bb_test")
+        assert exc.value.status_code == 403
+        assert authorize.await_args.args[2] == [permission.value]

+ 5 - 1
backend/tests/unit/services/test_obico_actions.py

@@ -37,7 +37,8 @@ def _patch_session(fake_printer):
         yield
 
 
-async def test_notify_routes_to_on_ai_failure_detection(fake_printer):
+@pytest.mark.parametrize("frame", [None, b"detected-frame"])
+async def test_notify_routes_to_on_ai_failure_detection(fake_printer, frame):
     """Regression guard for #1794: action='notify' must call
     on_ai_failure_detection, not on_printer_error. If anyone reverts the
     handoff, the reporter's symptom (Discord silent when "Printer Error"
@@ -57,6 +58,7 @@ async def test_notify_routes_to_on_ai_failure_detection(fake_printer):
             action="notify",
             task_name="benchy.3mf",
             score=0.91,
+            frame=frame,
         )
 
         mock_ai.assert_awaited_once()
@@ -68,6 +70,8 @@ async def test_notify_routes_to_on_ai_failure_detection(fake_printer):
         assert call_kwargs["task_name"] == "benchy.3mf"
         assert call_kwargs["confidence"] == 0.91
         assert call_kwargs["action"] == "notify"
+        assert call_kwargs["image_data"] == frame
+        assert call_kwargs["print_quality"] is None
 
 
 async def test_pause_action_still_pauses_and_notifies(fake_printer):

+ 109 - 0
backend/tests/unit/services/test_octoeverywhere_actions.py

@@ -0,0 +1,109 @@
+"""OctoEverywhere uses the existing printer controls and AI event subscription."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
+from backend.app.services.octoeverywhere_actions import execute_action
+
+
+@pytest.mark.parametrize("action", ["notify", "pause", "pause_and_off"])
+async def test_dispatches_action_with_quality_without_fake_confidence(action):
+    with patch("backend.app.services.octoeverywhere_actions.execute_detection_action", new_callable=AsyncMock) as run:
+        await execute_action(1, action, "benchy", 2)
+    run.assert_awaited_once_with(1, action, "benchy", None, print_quality=2)
+
+
+async def test_pause_and_power_off_also_notify():
+    from backend.app.services.obico_actions import execute_action as run
+
+    with (
+        patch("backend.app.services.obico_actions._get_printer_name", return_value="Printer"),
+        patch("backend.app.services.obico_actions._pause_print") as pause,
+        patch("backend.app.services.obico_actions._turn_off_linked_plugs", new_callable=AsyncMock) as off,
+        patch("backend.app.services.obico_actions._notify", new_callable=AsyncMock) as notify,
+    ):
+        await run(1, "pause_and_off", "benchy", None, print_quality=1)
+    pause.assert_called_once_with(1)
+    off.assert_awaited_once_with(1)
+    notify.assert_awaited_once_with(1, "Printer", "benchy", None, "pause_and_off", None, print_quality=1)
+
+
+async def test_notification_shows_quality_and_preserves_event_subscription():
+    from backend.app.services.notification_service import NotificationService
+
+    service = NotificationService()
+    db = MagicMock()
+    template = NotificationTemplate(**next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "ai_failure_detection"))
+    original_body = template.body_template
+    with (
+        patch.object(service, "_get_providers_for_event", return_value=["provider"]) as providers,
+        patch.object(service, "_get_template", return_value=template),
+        patch.object(service, "_send_to_providers", new_callable=AsyncMock) as send,
+    ):
+        await service.on_ai_failure_detection(1, "Printer", "benchy", None, "notify", db, print_quality=2)
+    providers.assert_awaited_once_with(db, "on_ai_failure_detection", 1)
+    assert send.await_args.args[1] == "Possible Print Failure Detected"
+    assert send.await_args.args[2] == "Printer: benchy\nOctoEverywhere print quality: 2/10\nAction taken: notify"
+    assert send.await_args.kwargs["variables"]["confidence"] == "N/A"
+    assert template.body_template == original_body
+
+
+@pytest.mark.parametrize(
+    ("body", "expected"),
+    [
+        ("Please check {printer}.", "Please check Printer."),
+        ("{provider}: {task_name}, quality {print_quality}", "OctoEverywhere: benchy, quality 2/10"),
+    ],
+)
+async def test_notification_preserves_custom_title_and_body(body, expected):
+    from backend.app.services.notification_service import NotificationService
+
+    service = NotificationService()
+    template = NotificationTemplate(
+        event_type="ai_failure_detection",
+        name="AI Failure Detection",
+        title_template="Check {printer}",
+        body_template=body,
+    )
+    with (
+        patch.object(service, "_get_providers_for_event", return_value=["provider"]),
+        patch.object(service, "_get_template", return_value=template),
+        patch.object(service, "_send_to_providers", new_callable=AsyncMock) as send,
+    ):
+        await service.on_ai_failure_detection(1, "Printer", "benchy", None, "notify", MagicMock(), print_quality=2)
+    assert send.await_args.args[1:3] == ("Check Printer", expected)
+    assert template.body_template == body
+
+
+async def test_obico_default_notification_keeps_confidence():
+    from backend.app.services.notification_service import NotificationService
+
+    service = NotificationService()
+    template = NotificationTemplate(**next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "ai_failure_detection"))
+    with (
+        patch.object(service, "_get_providers_for_event", return_value=["provider"]),
+        patch.object(service, "_get_template", return_value=template),
+        patch.object(service, "_send_to_providers", new_callable=AsyncMock) as send,
+    ):
+        await service.on_ai_failure_detection(1, "Printer", "benchy", 0.85, "pause", MagicMock())
+    assert send.await_args.args[2] == "Printer: benchy\nConfidence: 0.85\nAction taken: pause"
+
+
+async def test_ai_notification_template_preview_includes_quality_variables():
+    from backend.app.api.routes.notification_templates import preview_template
+    from backend.app.schemas.notification_template import EVENT_VARIABLES, EventType, TemplatePreviewRequest
+
+    assert {"provider", "print_quality", "task_name", "confidence", "action"} <= set(
+        EVENT_VARIABLES[EventType.AI_FAILURE_DETECTION]
+    )
+    result = await preview_template(
+        TemplatePreviewRequest(
+            event_type=EventType.AI_FAILURE_DETECTION,
+            title_template="{provider}: {printer}",
+            body_template="{task_name}: {print_quality}, {confidence}, {action}",
+        )
+    )
+    assert result.title == "OctoEverywhere: Bambu X1C"
+    assert result.body == "Benchy.3mf: 2/10, N/A, pause"

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

@@ -480,6 +480,7 @@ class TestPollOneStateLifecycle:
         ):
             await svc._check_printer(1, status, settings)
             assert mock_action.call_count == 1
+            assert mock_action.await_args.args[-1] == FAKE_JPEG
             await svc._check_printer(1, status, settings)
             # Second call must not dispatch again
             assert mock_action.call_count == 1
@@ -653,6 +654,28 @@ class TestFrameCache:
         assert await pop_frame("aging-nonce") is None
 
 
+class TestProviderSwitch:
+    async def test_refresh_cancels_inflight_poll_before_it_can_act(self):
+        import asyncio
+
+        svc = ObicoDetectionService()
+        started = asyncio.Event()
+        action = AsyncMock()
+
+        async def pending_poll():
+            started.set()
+            await asyncio.Future()
+            await action()
+
+        svc._task = asyncio.create_task(pending_poll())
+        await started.wait()
+        with patch.object(svc, "start", new_callable=AsyncMock) as restart:
+            await svc.refresh_settings()
+        action.assert_not_awaited()
+        restart.assert_awaited_once()
+        assert svc._task is None
+
+
 class TestCheckPrinterUsesCachedFrameUrl:
     """The URL sent to Obico must point at our nonce endpoint, not /camera/snapshot."""
 

+ 2513 - 0
backend/tests/unit/test_octoeverywhere_detection.py

@@ -0,0 +1,2513 @@
+"""Unit tests for OctoEverywhere's per-print AI detection integration."""
+
+import asyncio
+import logging
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+
+from backend.app.services.octoeverywhere_detection import (
+    API_ERRORS,
+    CREATE_CONTEXT_URL,
+    ERROR_RETRY_INTERVAL,
+    IP_RESTRICTED_ERROR,
+    MAX_IMAGE_BYTES,
+    OctoEverywhereDetectionService,
+)
+
+MODULE = "backend.app.services.octoeverywhere_detection"
+FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\xff\xd9"
+PRIMARY_URL = "https://gadget-region.octoeverywhere.com/api/gadget/v1/process/context-one"
+FALLBACK_URL = "https://gadget-pv1-oeapi.octoeverywhere.com/api/gadget/v1/process/context-one"
+USAGE_LIMIT_ERROR = "OE_FREE_USAGE_LIMIT_REACHED"
+
+
+def quota_response(retry_after=30):
+    return httpx.Response(
+        429,
+        headers={"Retry-After": str(retry_after)},
+        json={"ErrorType": USAGE_LIMIT_ERROR, "ErrorDetails": "secret-api-key"},
+    )
+
+
+def ip_restricted_response():
+    return httpx.Response(403, json={"ErrorType": IP_RESTRICTED_ERROR, "ErrorDetails": "secret-api-key"})
+
+
+def context_payload(**overrides):
+    payload = {
+        "ContextId": "context-one",
+        "ProcessRequestUrl": PRIMARY_URL,
+        "FallbackProcessRequestUrl": FALLBACK_URL,
+    }
+    payload.update(overrides)
+    return payload
+
+
+def process_payload(**overrides):
+    payload = {
+        "NextProcessIntervalSec": {"Minimum": 40, "Recommended": 40},
+        "PrintQuality": 9,
+        "WarningSuggested": False,
+        "PauseSuggested": False,
+        "Score": 0,
+    }
+    payload.update(overrides)
+    return payload
+
+
+def settings(**overrides):
+    values = {
+        "enabled": True,
+        "api_key": "secret-api-key",
+        "confidence": "medium",
+        "action": "notify",
+        "enabled_printers": None,
+        "poll_interval": 20,
+    }
+    values.update(overrides)
+    return values
+
+
+def printer_status(**overrides):
+    values = {
+        "state": "RUNNING",
+        "subtask_name": "test-print",
+        "subtask_id": "print-1",
+        "gcode_file": "plate_1.gcode",
+    }
+    values.update(overrides)
+    return SimpleNamespace(**values)
+
+
+async def poll_once(detector, configuration):
+    """Wait for checks when a test needs the results of a complete polling cycle."""
+    await detector._poll_once(configuration)
+    await asyncio.gather(*detector._checks.values(), return_exceptions=True)
+
+
+@pytest.fixture
+def detector():
+    service = OctoEverywhereDetectionService()
+    service._capture_frame = AsyncMock(return_value=FAKE_JPEG)
+    service._dispatch_action = AsyncMock()
+    return service
+
+
+@pytest.fixture
+def clock():
+    # Patch this module's time reference rather than the process-wide clock
+    # used by asyncio's own scheduler.
+    with patch(f"{MODULE}.time") as mocked:
+        mocked.monotonic.return_value = 100.0
+        yield mocked.monotonic
+
+
+@pytest.fixture
+def http_client():
+    with patch(f"{MODULE}.httpx.AsyncClient") as factory:
+        client = MagicMock()
+        client.post = AsyncMock()
+        client.__aenter__ = AsyncMock(return_value=client)
+        client.__aexit__ = AsyncMock(return_value=False)
+        factory.return_value = client
+        yield client
+
+
+@pytest.fixture
+def manager():
+    with patch("backend.app.services.printer_manager.printer_manager") as mocked:
+        mocked.is_connected.return_value = True
+        mocked.get_all_statuses.return_value = {1: printer_status()}
+        yield mocked
+
+
+class TestConnection:
+    @pytest.mark.parametrize(
+        "confidence, level", [("lowest", 1), ("low", 2), ("medium", 3), ("high", 4), ("highest", 5)]
+    )
+    async def test_creates_context_without_uploading_photo(self, detector, http_client, confidence, level):
+        http_client.post.return_value = httpx.Response(201, json=context_payload())
+
+        result = await detector.test_connection("  secret-api-key  ", confidence)
+
+        assert result == {"ok": True, "status_code": 201, "error": None, "error_code": None}
+        http_client.post.assert_awaited_once_with(
+            CREATE_CONTEXT_URL,
+            headers={"X-API-Key": "secret-api-key"},
+            json={"WarningConfidenceLevel": level, "PauseConfidenceLevel": level},
+        )
+        detector._capture_frame.assert_not_awaited()
+        assert detector.get_per_printer() == {}
+
+    async def test_empty_key_never_makes_a_request(self, detector, http_client):
+        result = await detector.test_connection("  ")
+        assert result["ok"] is False
+        assert "required" in result["error"]
+        assert result["error_code"] is None
+        http_client.post.assert_not_awaited()
+
+    @pytest.mark.parametrize("field", ["ProcessRequestUrl", "FallbackProcessRequestUrl"])
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "http://gadget.octoeverywhere.com/process",
+            "https://octoeverywhere.com.attacker.example/process",
+            "https://attacker.example/process",
+            "https://gadget.octoeverywhere.com@attacker.example/process",
+            "https://user:password@gadget.octoeverywhere.com/process",
+            "https://gadget.octoeverywhere.com:444/process",
+            "https://127.0.0.1/process",
+            "https://gadget.octoeverywhere.com/process\n",
+            None,
+        ],
+    )
+    async def test_rejects_untrusted_processing_urls(self, detector, http_client, field, url):
+        http_client.post.return_value = httpx.Response(200, json=context_payload(**{field: url}))
+        result = await detector.test_connection("secret-api-key")
+        assert result["ok"] is False
+        assert "invalid processing URL" in result["error"]
+        assert http_client.post.await_count == 1
+
+    @pytest.mark.parametrize("payload", [None, [], {"ContextId": ""}, {"ErrorType": "OE_INVALID_API_KEY"}])
+    async def test_malformed_context_is_not_success(self, detector, http_client, payload):
+        http_client.post.return_value = httpx.Response(200, json=payload)
+        assert (await detector.test_connection("secret-api-key"))["ok"] is False
+
+    @pytest.mark.parametrize(
+        "error_type, expected",
+        [
+            ("OE_INVALID_API_KEY", "rejected the Gadget API key"),
+            ("OE_API_KEY_DISABLED", "disabled"),
+            ("OE_API_KEY_BLOCKED_PAYMENT_FAILED", "Check the account status"),
+            (IP_RESTRICTED_ERROR, "already in use by another"),
+        ],
+    )
+    async def test_actionable_api_errors_do_not_echo_secrets(self, detector, http_client, error_type, expected):
+        http_client.post.return_value = httpx.Response(
+            403, json={"ErrorType": error_type, "ErrorDetails": "secret-api-key"}
+        )
+        result = await detector.test_connection("secret-api-key")
+        assert result["ok"] is False
+        assert result["status_code"] == 403
+        assert expected in result["error"]
+        assert "secret-api-key" not in result["error"]
+        assert result["error_code"] == error_type
+
+    async def test_transport_errors_do_not_echo_secrets(self, detector, http_client):
+        http_client.post.side_effect = httpx.ConnectError("secret-api-key")
+        result = await detector.test_connection("secret-api-key")
+        assert result["ok"] is False
+        assert "secret-api-key" not in result["error"]
+        assert result["error_code"] is None
+
+    async def test_redirects_are_not_followed(self, detector, http_client):
+        http_client.post.return_value = httpx.Response(302, headers={"Location": "https://attacker.example"})
+        result = await detector.test_connection("secret-api-key")
+        assert result["ok"] is False
+        assert http_client.post.await_count == 1
+        from backend.app.services.octoeverywhere_detection import httpx as service_httpx
+
+        assert service_httpx.AsyncClient.call_args.kwargs["follow_redirects"] is False
+
+
+class TestProcessing:
+    @pytest.mark.parametrize(
+        "confidence, level", [("lowest", 1), ("low", 2), ("medium", 3), ("high", 4), ("highest", 5)]
+    )
+    async def test_inspection_context_uses_configured_confidence(self, detector, http_client, clock, confidence, level):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await detector._check_printer(1, printer_status(), settings(confidence=confidence))
+        creation = http_client.post.await_args_list[0]
+        assert creation.args == (CREATE_CONTEXT_URL,)
+        assert creation.kwargs["json"] == {
+            "WarningConfidenceLevel": level,
+            "PauseConfidenceLevel": level,
+        }
+        assert detector.get_per_printer()[1]["frame_count"] == 1
+
+    async def test_wire_format_and_server_controlled_interval(self, detector, http_client, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 75, "Recommended": 20})),
+            httpx.Response(200, json=process_payload()),
+        ]
+        status = printer_status()
+        await detector._check_printer(1, status, settings())
+
+        assert http_client.post.await_args.args == (PRIMARY_URL,)
+        assert http_client.post.await_args.kwargs == {
+            "headers": {"X-API-Key": "secret-api-key"},
+            "files": {"image": ("snapshot.jpg", FAKE_JPEG, "image/jpeg")},
+        }
+        assert detector.get_per_printer()[1] == {
+            "class": "safe",
+            "print_quality": 9,
+            "frame_count": 1,
+            "error": None,
+            "error_code": None,
+        }
+        clock.return_value = 174
+        await detector._check_printer(1, status, settings())
+        assert http_client.post.await_count == 2
+        clock.return_value = 175
+        await detector._check_printer(1, status, settings())
+        assert http_client.post.await_count == 3
+        assert detector._capture_frame.await_count == 2
+        assert detector.get_per_printer()[1]["frame_count"] == 2
+
+    async def test_interval_is_measured_after_response(self, detector, http_client, clock):
+        async def respond(url, **kwargs):
+            if url == CREATE_CONTEXT_URL:
+                return httpx.Response(200, json=context_payload())
+            clock.return_value = 125
+            return httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 60, "Recommended": 20}))
+
+        http_client.post.side_effect = respond
+        await detector._check_printer(1, printer_status(), settings())
+        assert detector._states[1].next_check_at == 185
+
+    async def test_default_interval_is_twenty_seconds_when_server_allows_faster(self, detector, http_client, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 5, "Recommended": 20})),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        assert detector._states[1].next_check_at == 120
+
+    @pytest.mark.parametrize(
+        "fields",
+        [
+            {},
+            {"Recommended": 1},
+            {"Recommended": 5},
+            {"Recommended": 20},
+            {"Recommended": 300},
+            {"Recommended": None},
+            {"Recommended": True},
+            {"Recommended": "20"},
+            {"Recommended": {}},
+            {"Recommended": []},
+        ],
+    )
+    async def test_recommended_interval_ignored_without_faster_inspection_hint(
+        self, detector, http_client, clock, fields
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 5, **fields})),
+        ]
+        await detector._check_printer(1, printer_status(), settings(poll_interval=12))
+        assert detector._states[1].interval == 5
+        assert detector._states[1].next_check_at == 112
+
+    async def test_latest_server_minimum_can_increase_and_decrease(self, detector, http_client, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 5, "Recommended": 20})),
+            httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 75, "Recommended": 90})),
+            httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 10, "Recommended": 20})),
+            httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 5, "Recommended": 20})),
+        ]
+        configured = settings(poll_interval=20)
+        for now, minimum, next_check in [(100, 5, 120), (120, 75, 195), (195, 10, 215), (215, 5, 235)]:
+            clock.return_value = now
+            await detector._check_printer(1, printer_status(), configured)
+            assert detector._states[1].interval == minimum
+            assert detector._states[1].next_check_at == next_check
+            clock.return_value = next_check - 0.1
+            call_count = http_client.post.await_count
+            await detector._check_printer(1, printer_status(), configured)
+            assert http_client.post.await_count == call_count
+        assert http_client.post.await_count == 5
+
+    @pytest.mark.parametrize("poll_interval", [5, 20, 30])
+    async def test_first_inspection_is_immediate_with_configured_baseline(
+        self, detector, http_client, clock, poll_interval
+    ):
+        async def capture(printer_id):
+            assert detector._states[printer_id].interval is None
+            assert detector._states[printer_id].next_check_at == 100 + poll_interval
+            return FAKE_JPEG
+
+        detector._capture_frame.side_effect = capture
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 5, "Recommended": 20})),
+        ]
+        await detector._check_printer(1, printer_status(), settings(poll_interval=poll_interval))
+        assert http_client.post.await_count == 2
+        assert detector._states[1].next_check_at == 100 + poll_interval
+
+    @pytest.mark.parametrize(
+        "server_interval, poll_interval, expected",
+        [(5, 20, 20), (10, 5, 10), (40, 30, 40), (40, 20, 40), (5, 5, 5), (5, 21, 21), (180, 30, 180)],
+    )
+    async def test_inspection_interval_is_longer_of_setting_and_server_minimum(
+        self, detector, http_client, clock, server_interval, poll_interval, expected
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(
+                200, json=process_payload(NextProcessIntervalSec={"Minimum": server_interval, "Recommended": 20})
+            ),
+            httpx.Response(
+                200, json=process_payload(NextProcessIntervalSec={"Minimum": server_interval, "Recommended": 20})
+            ),
+        ]
+        configured = settings(poll_interval=poll_interval)
+        await detector._check_printer(1, printer_status(), configured)
+        assert detector._states[1].interval == server_interval
+        assert detector._states[1].next_check_at == 100 + expected
+        clock.return_value = 100 + expected - 0.1
+        await detector._check_printer(1, printer_status(), configured)
+        assert http_client.post.await_count == 2
+        clock.return_value = 100 + expected
+        await detector._check_printer(1, printer_status(), configured)
+        assert http_client.post.await_count == 3
+
+    async def test_interval_edits_preserve_context_and_completed_actions(self, detector, http_client, manager, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(
+                200,
+                json=process_payload(PauseSuggested=True, NextProcessIntervalSec={"Minimum": 10, "Recommended": 20}),
+            ),
+            httpx.Response(
+                200,
+                json=process_payload(PauseSuggested=True, NextProcessIntervalSec={"Minimum": 10, "Recommended": 20}),
+            ),
+            httpx.Response(
+                200,
+                json=process_payload(PauseSuggested=True, NextProcessIntervalSec={"Minimum": 10, "Recommended": 20}),
+            ),
+        ]
+        await poll_once(detector, settings(poll_interval=30))
+        context = detector._states[1].context
+        clock.return_value = 105
+        await poll_once(detector, settings(poll_interval=5))
+        assert http_client.post.await_count == 2
+        assert detector._states[1].next_check_at == 110
+        clock.return_value = 110
+        await poll_once(detector, settings(poll_interval=5))
+        assert http_client.post.await_count == 3
+        assert detector._states[1].next_check_at == 120
+        clock.return_value = 115
+        await poll_once(detector, settings(poll_interval=21))
+        assert http_client.post.await_count == 3
+        assert detector._states[1].next_check_at == 131
+        clock.return_value = 131
+        await poll_once(detector, settings(poll_interval=21))
+        assert http_client.post.await_count == 4
+        assert detector._states[1].context is context
+        detector._dispatch_action.assert_awaited_once()
+
+    @pytest.mark.parametrize("retry_after, expected", [(None, 460), ("300", 580)])
+    async def test_interval_edits_never_shorten_rate_limit_backoff(
+        self, detector, http_client, clock, retry_after, expected
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 180, "Recommended": 20})),
+            httpx.Response(
+                429,
+                headers={"Retry-After": retry_after} if retry_after else {},
+                json={"ErrorType": "OE_CONTEXT_RATE_LIMITED"},
+            ),
+        ]
+        await detector._check_printer(1, printer_status(), settings(poll_interval=30))
+        clock.return_value = 280
+        await detector._check_printer(1, printer_status(), settings(poll_interval=30))
+        assert detector._states[1].next_check_at == expected
+        clock.return_value = expected - 1
+        await detector._check_printer(1, printer_status(), settings(poll_interval=5))
+        assert http_client.post.await_count == 3
+        assert detector.get_per_printer()[1]["class"] == "error"
+
+    async def test_raw_score_and_quality_never_trigger_actions(self, detector, http_client, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(Score=100, PrintQuality=1)),
+        ]
+        await detector._check_printer(1, printer_status(), settings(action="pause_and_off"))
+        assert detector.get_per_printer()[1]["class"] == "safe"
+        detector._dispatch_action.assert_not_awaited()
+
+    @pytest.mark.parametrize("action", ["notify", "pause", "pause_and_off"])
+    async def test_warning_then_pause_dispatches_each_action_once(self, detector, http_client, clock, action):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(WarningSuggested=True, PrintQuality=4)),
+            httpx.Response(200, json=process_payload(WarningSuggested=True, PrintQuality=3)),
+            httpx.Response(200, json=process_payload(WarningSuggested=True, PauseSuggested=True, PrintQuality=1)),
+            httpx.Response(200, json=process_payload(PauseSuggested=True, PrintQuality=1)),
+        ]
+        for now in (100, 140, 180, 220):
+            clock.return_value = now
+            await detector._check_printer(1, printer_status(), settings(action=action))
+
+        calls = detector._dispatch_action.await_args_list
+        assert calls[0].args == (1, "notify", "test-print", 4)
+        if action == "notify":
+            assert len(calls) == 1
+        else:
+            assert len(calls) == 2
+            assert calls[1].args == (1, action, "test-print", 1)
+        status = detector.get_status()
+        assert status["per_printer"][1]["class"] == "failure"
+        assert len(status["history"]) == 4
+        assert status["history"][0]["print_quality"] == 1
+        assert status["history"][0]["timestamp"].endswith("+00:00")
+
+    async def test_pause_without_warning_notifies_once(self, detector, http_client, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(PauseSuggested=True, PrintQuality=2)),
+        ]
+        await detector._check_printer(1, printer_status(), settings(action="pause"))
+        detector._dispatch_action.assert_awaited_once_with(1, "pause", "test-print", 2)
+
+    @pytest.mark.parametrize("action", ["pause", "pause_and_off"])
+    async def test_pause_after_warning_sends_its_own_notification(self, detector, http_client, clock, action):
+        # Run the real dispatcher: the user must hear that the printer stopped,
+        # not only about the warning that came before it.
+        detector._dispatch_action = OctoEverywhereDetectionService._dispatch_action.__get__(detector)
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(WarningSuggested=True, PrintQuality=4)),
+            httpx.Response(200, json=process_payload(WarningSuggested=True, PauseSuggested=True, PrintQuality=1)),
+        ]
+        with (
+            patch("backend.app.services.obico_actions._get_printer_name", return_value="Printer"),
+            patch("backend.app.services.obico_actions._pause_print") as pause,
+            patch("backend.app.services.obico_actions._turn_off_linked_plugs", new_callable=AsyncMock),
+            patch("backend.app.services.obico_actions._notify", new_callable=AsyncMock) as notify,
+        ):
+            for now in (100, 140):
+                clock.return_value = now
+                await detector._check_printer(1, printer_status(), settings(action=action))
+        pause.assert_called_once_with(1)
+        assert [call.args[4] for call in notify.await_args_list] == ["notify", action]
+
+    @pytest.mark.parametrize(
+        "fields",
+        [
+            {"PrintQuality": True},
+            {"PrintQuality": 0},
+            {"PrintQuality": 11},
+            {"PrintQuality": None},
+            {"PauseSuggested": "false"},
+            {"WarningSuggested": 1},
+        ],
+    )
+    async def test_invalid_assessment_is_an_error_and_never_acts(self, detector, http_client, clock, fields):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(**fields)),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        result = detector.get_per_printer()[1]
+        assert result["class"] == "error"
+        assert result["frame_count"] == 0
+        assert result["print_quality"] is None
+        detector._dispatch_action.assert_not_awaited()
+
+    @pytest.mark.parametrize(
+        "fields",
+        [
+            {},
+            {"NextProcessIntervalSec": None},
+            {"NextProcessIntervalSec": True},
+            {"NextProcessIntervalSec": 20},
+            {"NextProcessIntervalSec": "20"},
+            {"NextProcessIntervalSec": []},
+            {"NextProcessIntervalSec": {}},
+            {"NextProcessIntervalSec": {"Recommended": 20}},
+            {"NextProcessIntervalSec": {"Minimum": None, "Recommended": 20}},
+            {"NextProcessIntervalSec": {"Minimum": True, "Recommended": 20}},
+            {"NextProcessIntervalSec": {"Minimum": False, "Recommended": 20}},
+            {"NextProcessIntervalSec": {"Minimum": 0, "Recommended": 20}},
+            {"NextProcessIntervalSec": {"Minimum": -1, "Recommended": 20}},
+            {"NextProcessIntervalSec": {"Minimum": 5.5, "Recommended": 20}},
+            {"NextProcessIntervalSec": {"Minimum": "5", "Recommended": 20}},
+            {"NextProcessIntervalSec": {"Minimum": {}, "Recommended": 20}},
+            {"NextProcessIntervalSec": {"Minimum": [], "Recommended": 20}},
+        ],
+    )
+    async def test_invalid_interval_retries_without_actions(self, detector, http_client, clock, fields):
+        payload = process_payload(PauseSuggested=True)
+        payload.pop("NextProcessIntervalSec")
+        payload.update(fields)
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=payload),
+            httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 5, "Recommended": 20})),
+        ]
+        configured = settings(poll_interval=5, action="pause_and_off")
+        await detector._check_printer(1, printer_status(), configured)
+        state = detector._states[1]
+        context = state.context
+        assert detector.get_per_printer()[1]["class"] == "error"
+        assert "invalid processing interval" in state.error
+        assert state.frame_count == 0
+        assert state.interval is None
+        assert state.next_check_at == 100 + ERROR_RETRY_INTERVAL
+        detector._dispatch_action.assert_not_awaited()
+
+        clock.return_value = 100 + ERROR_RETRY_INTERVAL - 0.1
+        await detector._check_printer(1, printer_status(), configured)
+        assert http_client.post.await_count == 2
+        clock.return_value = 100 + ERROR_RETRY_INTERVAL
+        await detector._check_printer(1, printer_status(), configured)
+        assert http_client.post.await_count == 3
+        assert state.context is context
+        assert state.error is None
+        assert state.frame_count == 1
+        assert state.next_check_at == 100 + ERROR_RETRY_INTERVAL + 5
+        detector._dispatch_action.assert_not_awaited()
+
+    async def test_failure_uses_fallback_after_backoff_and_keeps_it(self, detector, http_client, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.ConnectError("secret-api-key"),
+            httpx.Response(200, json=process_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        assert detector.get_per_printer()[1]["class"] == "error"
+        assert "secret-api-key" not in detector.get_status()["last_error"]
+        assert http_client.post.await_count == 2
+        clock.return_value = 100 + ERROR_RETRY_INTERVAL - 1
+        await detector._check_printer(1, printer_status(), settings())
+        assert http_client.post.await_count == 2
+        for now in (160, 200):
+            clock.return_value = now
+            await detector._check_printer(1, printer_status(), settings())
+        assert [call.args[0] for call in http_client.post.await_args_list] == [
+            CREATE_CONTEXT_URL,
+            PRIMARY_URL,
+            FALLBACK_URL,
+            FALLBACK_URL,
+        ]
+        assert detector.get_status()["last_error"] is None
+        assert detector.get_per_printer()[1]["class"] == "safe"
+
+    async def test_retry_after_and_previous_server_interval_are_honored(self, detector, http_client, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 180, "Recommended": 20})),
+            httpx.Response(429, headers={"Retry-After": "300"}, json={"ErrorType": "OE_CONTEXT_RATE_LIMITED"}),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        clock.return_value = 280
+        await detector._check_printer(1, printer_status(), settings())
+        assert detector._states[1].next_check_at == 580
+        assert detector._states[1].frame_count == 1
+
+    async def test_invalid_assessment_still_honors_valid_long_interval(self, detector, http_client, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(
+                200, json=process_payload(NextProcessIntervalSec={"Minimum": 600, "Recommended": 20}, PrintQuality=None)
+            ),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        assert detector._states[1].next_check_at == 700
+
+    async def test_failed_capture_does_not_upload_and_backs_off(self, detector, http_client, clock):
+        detector._capture_frame.return_value = None
+        await detector._check_printer(1, printer_status(), settings())
+        await detector._check_printer(1, printer_status(), settings())
+        http_client.post.assert_not_awaited()
+        detector._capture_frame.assert_awaited_once()
+        assert detector.get_per_printer()[1]["class"] == "error"
+        assert detector._states[1].next_check_at == 160
+
+    @pytest.mark.parametrize("oversized", [False, True])
+    async def test_only_oversized_snapshots_are_prepared_before_upload(self, detector, http_client, clock, oversized):
+        frame = b"x" * (MAX_IMAGE_BYTES + int(oversized))
+        detector._capture_frame.return_value = frame
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        with patch(f"{MODULE}.prepare_frame", return_value=FAKE_JPEG) as prepare:
+            await detector._check_printer(1, printer_status(), settings())
+        if oversized:
+            prepare.assert_called_once_with(frame)
+        else:
+            prepare.assert_not_called()
+        uploaded = http_client.post.await_args.kwargs["files"]["image"]
+        assert uploaded == ("snapshot.jpg", FAKE_JPEG if oversized else frame, "image/jpeg")
+        assert len(uploaded[1]) <= MAX_IMAGE_BYTES
+        assert detector.get_per_printer()[1]["class"] == "safe"
+
+    async def test_unprocessable_oversized_snapshot_is_not_uploaded(self, detector, http_client, clock):
+        frame = b"x" * (MAX_IMAGE_BYTES + 1)
+        detector._capture_frame.return_value = frame
+        with patch(f"{MODULE}.prepare_frame", side_effect=ValueError("private-image-details")) as prepare:
+            await detector._check_printer(1, printer_status(), settings(action="pause_and_off"))
+        prepare.assert_called_once_with(frame)
+        http_client.post.assert_not_awaited()
+        detector._dispatch_action.assert_not_awaited()
+        assert detector.get_per_printer()[1]["class"] == "error"
+        assert "6 MiB" in detector.get_status()["last_error"]
+        assert "private-image-details" not in str(detector.get_status())
+        assert detector._states[1].next_check_at == 160
+
+    async def test_reset_during_capture_does_not_create_or_upload_context(self, detector, http_client, clock):
+        async def capture(printer_id):
+            detector.reset_printer(printer_id)
+            return FAKE_JPEG
+
+        detector._capture_frame.side_effect = capture
+        await detector._check_printer(1, printer_status(), settings())
+        http_client.post.assert_not_awaited()
+        assert detector.get_per_printer() == {}
+
+    async def test_completion_during_processing_cannot_pause_next_print(self, detector, http_client, clock):
+        async def respond(url, **kwargs):
+            if url == CREATE_CONTEXT_URL:
+                return httpx.Response(200, json=context_payload())
+            detector.reset_printer(1)
+            return httpx.Response(200, json=process_payload(PauseSuggested=True))
+
+        http_client.post.side_effect = respond
+        await detector._check_printer(1, printer_status(), settings(action="pause"))
+        detector._dispatch_action.assert_not_awaited()
+        assert detector.get_per_printer() == {}
+
+    @pytest.mark.parametrize(
+        "changes",
+        [
+            {"enabled": False},
+            {"api_key": "replacement-key"},
+            {"enabled_printers": set()},
+            {"action": "notify"},
+        ],
+    )
+    async def test_saved_settings_invalidate_in_flight_actions(self, detector, http_client, manager, clock, changes):
+        detector._load_settings = AsyncMock(return_value=settings(**changes))
+
+        async def respond(url, **kwargs):
+            if url == CREATE_CONTEXT_URL:
+                return httpx.Response(200, json=context_payload())
+            await detector.refresh_settings()
+            return httpx.Response(
+                200,
+                json=process_payload(PauseSuggested=True, NextProcessIntervalSec={"Minimum": 120, "Recommended": 20}),
+            )
+
+        http_client.post.side_effect = respond
+        await poll_once(detector, settings(action="pause"))
+
+        detector._dispatch_action.assert_not_awaited()
+        if "action" in changes:
+            # Action changes keep temporal context and server pacing, while
+            # discarding the decision made under the previous configuration.
+            assert detector._states[1].context is not None
+            assert detector._states[1].next_check_at == 220
+        elif "api_key" in changes:
+            assert detector._states[1].context is None
+            assert detector._states[1].next_check_at == 220
+        else:
+            assert detector.get_per_printer() == {}
+
+    async def test_unknown_until_first_inference_returns(self, detector, http_client, clock):
+        async def capture(printer_id):
+            assert detector.get_per_printer()[printer_id] == {
+                "class": "unknown",
+                "print_quality": None,
+                "frame_count": 0,
+                "error": None,
+                "error_code": None,
+            }
+            return FAKE_JPEG
+
+        detector._capture_frame.side_effect = capture
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        assert detector.get_per_printer()[1]["class"] == "safe"
+
+    async def test_invalid_json_is_not_a_safe_frame(self, detector, http_client, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, text="not json: secret-api-key"),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        assert detector.get_per_printer()[1]["class"] == "error"
+        assert "secret-api-key" not in detector.get_status()["last_error"]
+
+    @pytest.mark.parametrize("flag", ["WarningSuggested", "PauseSuggested"])
+    async def test_expired_context_is_recreated_without_repeating_actions(self, detector, http_client, clock, flag):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(**{flag: True})),
+            httpx.Response(
+                500, json={"ErrorType": "OE_INTERNAL_ERROR", "ErrorDetails": "Failed to get context object."}
+            ),
+            httpx.Response(200, json=context_payload(ContextId="new-context")),
+            httpx.Response(200, json=process_payload(**{flag: True})),
+        ]
+        for now in (100, 140, 200):
+            clock.return_value = now
+            await detector._check_printer(1, printer_status(), settings())
+        assert detector._states[1].context.context_id == "new-context"
+        detector._dispatch_action.assert_awaited_once()
+
+    @pytest.mark.parametrize(
+        "status_code, payload",
+        [
+            (500, {"ErrorType": "OE_INTERNAL_ERROR", "ErrorDetails": "ML Model Exception"}),
+            (500, {"ErrorType": "OE_BACKEND_THROTTLED", "ErrorDetails": "Failed to get context object."}),
+            (404, {"ErrorType": "OE_INTERNAL_ERROR", "ErrorDetails": "Failed to get context object."}),
+            (500, {"ErrorType": "OE_INTERNAL_ERROR", "ErrorDetails": "Failed to get context object. secret-api-key"}),
+        ],
+    )
+    async def test_other_errors_retain_context_and_do_not_echo_details(
+        self, detector, http_client, clock, status_code, payload
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(status_code, json=payload),
+            httpx.Response(status_code, json=payload),
+        ]
+        for now in (100, 160):
+            clock.return_value = now
+            await detector._check_printer(1, printer_status(), settings())
+        assert detector._states[1].context.context_id == "context-one"
+        assert detector._states[1].context.process_url == (FALLBACK_URL if status_code >= 500 else PRIMARY_URL)
+        assert "secret-api-key" not in detector.get_status()["last_error"]
+        assert http_client.post.await_count == 3
+
+    async def test_one_printers_success_does_not_hide_another_printers_error(self, detector, http_client, clock):
+        http_client.post.side_effect = [
+            httpx.Response(503),
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        await detector._check_printer(2, printer_status(), settings())
+        assert detector.get_per_printer()[1]["class"] == "error"
+        assert detector.get_per_printer()[2]["class"] == "safe"
+        assert detector.get_status()["last_error"] is not None
+
+
+class TestFasterInspectionTiming:
+    @pytest.mark.parametrize(
+        "minimum, recommended, configured, delay",
+        [(5, 8, 20, 8), (5, 45, 5, 5), (5, 45, 20, 20), (40, 2, 30, 40)],
+    )
+    async def test_timing_hint_only_shortens_configured_interval_and_respects_minimum(
+        self, detector, http_client, clock, caplog, minimum, recommended, configured, delay
+    ):
+        caplog.set_level(logging.DEBUG, logger=MODULE)
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": minimum, "Recommended": recommended},
+                    FasterInspectionSuggested=True,
+                    PrintQuality=2,
+                ),
+            ),
+        ]
+        await detector._check_printer(1, printer_status(), settings(poll_interval=configured, action="pause_and_off"))
+        assert detector._states[1].recommended_interval == recommended
+        assert detector._states[1].next_check_at == 100 + delay
+        clock.return_value = 100 + delay - 0.1
+        await detector._check_printer(1, printer_status(), settings(poll_interval=configured, action="pause_and_off"))
+        assert http_client.post.await_count == 2
+        assert detector.get_per_printer()[1]["class"] == "safe"
+        detector._dispatch_action.assert_not_awaited()
+        results = [
+            record.message
+            for record in caplog.records
+            if record.name == MODULE and "inspection result" in record.message
+        ]
+        assert len(results) == 1
+        assert "faster_inspection_suggested=True" in results[0]
+        assert f"next_check_in={delay}s" in results[0]
+
+    async def test_latest_hint_changes_cadence_and_false_or_missing_restores_setting(
+        self, detector, http_client, clock
+    ):
+        responses = [(True, 8), (True, 6), (False, 5), (True, 5), (None, 5)]
+        http_client.post.side_effect = [httpx.Response(200, json=context_payload())] + [
+            httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": 5, "Recommended": recommended},
+                    **({"FasterInspectionSuggested": hint} if hint is not None else {}),
+                ),
+            )
+            for hint, recommended in responses
+        ]
+        context = None
+        for now, next_check, active_interval in [
+            (100, 108, 8),
+            (108, 114, 6),
+            (114, 134, None),
+            (134, 139, 5),
+            (139, 159, None),
+        ]:
+            clock.return_value = now
+            await detector._check_printer(1, printer_status(), settings())
+            state = detector._states[1]
+            if context is None:
+                context = state.context
+            assert state.context is context
+            assert state.recommended_interval == active_interval
+            assert state.next_check_at == next_check
+        assert [call.args[0] for call in http_client.post.await_args_list] == [CREATE_CONTEXT_URL] + [PRIMARY_URL] * 5
+        detector._dispatch_action.assert_not_awaited()
+
+    async def test_interval_edit_preserves_hint_then_uses_new_setting_when_hint_clears(
+        self, detector, http_client, manager, clock
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": 5, "Recommended": 5}, FasterInspectionSuggested=True
+                ),
+            ),
+            httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": 5, "Recommended": 5}, FasterInspectionSuggested=False
+                ),
+            ),
+        ]
+        await poll_once(detector, settings())
+        context = detector._states[1].context
+        clock.return_value = 101
+        await poll_once(detector, settings(poll_interval=30))
+        assert detector._states[1].next_check_at == 105
+        assert http_client.post.await_count == 2
+        clock.return_value = 105
+        await poll_once(detector, settings(poll_interval=30))
+        assert detector._states[1].context is context
+        assert detector._states[1].recommended_interval is None
+        assert detector._states[1].next_check_at == 135
+
+    @pytest.mark.parametrize(
+        "hint, interval_fields",
+        [
+            (None, {"Recommended": 5}),
+            (1, {"Recommended": 5}),
+            ("true", {"Recommended": 5}),
+            (True, {}),
+            (True, {"Recommended": None}),
+            (True, {"Recommended": True}),
+            (True, {"Recommended": 0}),
+            (True, {"Recommended": -1}),
+            (True, {"Recommended": 2.5}),
+            (True, {"Recommended": "private-recommendation"}),
+        ],
+    )
+    async def test_invalid_optional_hint_restores_setting_without_losing_warning(
+        self, detector, http_client, clock, caplog, hint, interval_fields
+    ):
+        caplog.set_level(logging.DEBUG, logger=MODULE)
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": 5, "Recommended": 5}, FasterInspectionSuggested=True
+                ),
+            ),
+            httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": 5, **interval_fields},
+                    FasterInspectionSuggested=hint,
+                    WarningSuggested=True,
+                    PrintQuality=4,
+                ),
+            ),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        clock.return_value = 105
+        await detector._check_printer(1, printer_status(), settings())
+        state = detector._states[1]
+        assert state.recommended_interval is None
+        assert state.next_check_at == 125
+        assert state.frame_count == 2
+        assert state.error is None
+        assert state.verdict == "warning"
+        detector._dispatch_action.assert_awaited_once_with(1, "notify", "test-print", 4)
+        assert "private-recommendation" not in "\n".join(
+            record.message for record in caplog.records if record.name == MODULE
+        )
+
+    async def test_timing_hint_is_scoped_to_printer_and_print(self, detector, http_client, manager, clock):
+        manager.get_all_statuses.return_value[2] = printer_status(subtask_id="other-print")
+        fast = process_payload(NextProcessIntervalSec={"Minimum": 5, "Recommended": 5}, FasterInspectionSuggested=True)
+        normal = process_payload(NextProcessIntervalSec={"Minimum": 5, "Recommended": 5})
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=fast),
+            httpx.Response(200, json=context_payload(ContextId="other-context")),
+            httpx.Response(200, json=normal),
+            httpx.Response(200, json=context_payload(ContextId="new-context")),
+            httpx.Response(200, json=normal),
+        ]
+        await poll_once(detector, settings())
+        assert detector._states[1].next_check_at == 105
+        assert detector._states[2].next_check_at == 120
+        old_state = detector._states[1]
+        manager.get_all_statuses.return_value[1].subtask_id = "new-print"
+        clock.return_value = 101
+
+        async def capture(printer_id):
+            assert printer_id == 1
+            assert detector._states[printer_id].recommended_interval is None
+            assert detector._states[printer_id].next_check_at == 121
+            return FAKE_JPEG
+
+        detector._capture_frame.side_effect = capture
+        await poll_once(detector, settings())
+        assert detector._states[1] is not old_state
+        assert detector._states[1].next_check_at == 121
+        assert detector._states[2].next_check_at == 120
+
+    @pytest.mark.parametrize("changes", [{"api_key": "new-key"}, {"confidence": "highest"}])
+    async def test_context_configuration_change_clears_timing_hint(
+        self, detector, http_client, manager, clock, changes
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": 5, "Recommended": 5}, FasterInspectionSuggested=True
+                ),
+            ),
+        ]
+        await poll_once(detector, settings())
+        clock.return_value = 101
+        await poll_once(detector, settings(**changes))
+        assert detector._states[1].recommended_interval is None
+        assert detector._states[1].context is None
+        assert detector._states[1].next_check_at == 120
+        assert http_client.post.await_count == 2
+
+    async def test_expired_context_clears_timing_hint_and_respects_backoff(self, detector, http_client, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": 5, "Recommended": 5}, FasterInspectionSuggested=True
+                ),
+            ),
+            httpx.Response(
+                500, json={"ErrorType": "OE_INTERNAL_ERROR", "ErrorDetails": "Failed to get context object."}
+            ),
+            httpx.Response(200, json=context_payload(ContextId="new-context")),
+            httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 5, "Recommended": 5})),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        clock.return_value = 105
+        await detector._check_printer(1, printer_status(), settings())
+        state = detector._states[1]
+        assert state.context is None
+        assert state.recommended_interval is None
+        assert state.next_check_at == 165
+        clock.return_value = 164
+        await detector._check_printer(1, printer_status(), settings())
+        assert http_client.post.await_count == 3
+        clock.return_value = 165
+        await detector._check_printer(1, printer_status(), settings())
+        assert state.context.context_id == "new-context"
+        assert state.next_check_at == 185
+
+    @pytest.mark.parametrize(
+        "response, delay, host",
+        [
+            (httpx.ConnectError("private-transport-details"), 60, FALLBACK_URL),
+            (
+                httpx.Response(429, headers={"Retry-After": "300"}, json={"ErrorType": "OE_CONTEXT_RATE_LIMITED"}),
+                300,
+                PRIMARY_URL,
+            ),
+        ],
+    )
+    async def test_active_timing_hint_never_shortens_error_backoff(
+        self, detector, http_client, clock, response, delay, host
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": 5, "Recommended": 5}, FasterInspectionSuggested=True
+                ),
+            ),
+            response,
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        clock.return_value = 105
+        await detector._check_printer(1, printer_status(), settings())
+        state = detector._states[1]
+        assert state.next_check_at == 105 + delay
+        assert state.context.process_url == host
+        clock.return_value = 105 + delay - 1
+        await detector._check_printer(1, printer_status(), settings(poll_interval=5))
+        assert state.next_check_at == 105 + delay
+        assert http_client.post.await_count == 3
+        detector._dispatch_action.assert_not_awaited()
+
+    async def test_invalid_assessment_keeps_latest_minimum_without_accepting_new_hint(
+        self, detector, http_client, clock
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": 5, "Recommended": 5}, FasterInspectionSuggested=True
+                ),
+            ),
+            httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": 90, "Recommended": 2},
+                    FasterInspectionSuggested=True,
+                    PrintQuality=None,
+                ),
+            ),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        clock.return_value = 105
+        await detector._check_printer(1, printer_status(), settings())
+        state = detector._states[1]
+        assert state.interval == 90
+        assert state.recommended_interval == 5
+        assert state.next_check_at == 195
+        assert state.frame_count == 1
+        detector._dispatch_action.assert_not_awaited()
+
+    async def test_canceled_fast_inspection_preserves_minimum_before_retry(self, detector, http_client, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": 10, "Recommended": 5}, FasterInspectionSuggested=True
+                ),
+            ),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+
+        async def cancel_response(url, **kwargs):
+            clock.return_value = 115
+            raise asyncio.CancelledError
+
+        http_client.post.side_effect = cancel_response
+        clock.return_value = 110
+        with pytest.raises(asyncio.CancelledError):
+            await detector._check_printer(1, printer_status(), settings())
+        assert detector._states[1].recommended_interval == 5
+        assert detector._states[1].next_check_at == 125
+        clock.return_value = 124
+        await detector._check_printer(1, printer_status(), settings())
+        assert http_client.post.await_count == 3
+
+    async def test_stale_response_does_not_apply_timing_hint(self, detector, http_client, clock):
+        status = printer_status()
+
+        async def respond(url, **kwargs):
+            if url == CREATE_CONTEXT_URL:
+                return httpx.Response(200, json=context_payload())
+            status.subtask_id = "new-print"
+            return httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": 5, "Recommended": 5},
+                    FasterInspectionSuggested=True,
+                    PauseSuggested=True,
+                ),
+            )
+
+        http_client.post.side_effect = respond
+        await detector._check_printer(1, status, settings(action="pause"))
+        assert detector._states[1].recommended_interval is None
+        assert detector._states[1].frame_count == 0
+        detector._dispatch_action.assert_not_awaited()
+
+
+class TestDebugLogging:
+    @pytest.mark.parametrize(
+        "warning, pause, quality, verdict, minimum, interval",
+        [(False, False, 9, "safe", 5, 20), (True, False, 4, "warning", 40, 40), (True, True, 1, "failure", 5, 20)],
+    )
+    async def test_each_due_inspection_logs_results_and_context_reuse(
+        self, detector, http_client, clock, caplog, warning, pause, quality, verdict, minimum, interval
+    ):
+        caplog.set_level(logging.DEBUG, logger=MODULE)
+        payload = process_payload(
+            WarningSuggested=warning,
+            PauseSuggested=pause,
+            PrintQuality=quality,
+            NextProcessIntervalSec={"Minimum": minimum, "Recommended": 20},
+            ExtraDetails="private-response-details",
+        )
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=payload),
+            httpx.Response(200, json=payload),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        first_messages = [record.message for record in caplog.records if record.name == MODULE]
+        clock.return_value = 100 + interval - 1
+        await detector._check_printer(1, printer_status(), settings())
+        assert [record.message for record in caplog.records if record.name == MODULE] == first_messages
+
+        clock.return_value = 100 + interval
+        await detector._check_printer(1, printer_status(), settings())
+        records = [record for record in caplog.records if record.name == MODULE]
+        messages = [record.message for record in records]
+        assert all(record.levelno == logging.DEBUG for record in records)
+        assert messages.count("OctoEverywhere context created for printer 1") == 1
+        for frame, reused in ((1, False), (2, True)):
+            assert (
+                f"OctoEverywhere inspection starting for printer 1 (frame={frame}, context_reused={reused})"
+            ) in messages
+            assert (
+                f"OctoEverywhere inspection result for printer 1: frame={frame}, verdict={verdict}, "
+                f"print_quality={quality}, warning_suggested={warning}, pause_suggested={pause}, "
+                "faster_inspection_suggested=False, "
+                f"minimum_interval={minimum}s, next_check_in={interval}s"
+            ) in messages
+        assert len(messages) == 5
+        log_text = "\n".join(messages)
+        for private_value in ("secret-api-key", "context-one", PRIMARY_URL, FALLBACK_URL, "private-response-details"):
+            assert private_value not in log_text
+        assert http_client.post.await_count == 3
+
+    async def test_repeated_errors_log_each_retry_with_safe_error_code(self, detector, http_client, clock, caplog):
+        caplog.set_level(logging.DEBUG, logger=MODULE)
+        response = httpx.Response(
+            429,
+            headers={"Retry-After": "30"},
+            json={"ErrorType": "OE_CONTEXT_RATE_LIMITED", "ErrorDetails": "private-response-details secret-api-key"},
+        )
+        http_client.post.side_effect = [httpx.Response(200, json=context_payload()), response, response]
+        for now in (100, 160):
+            clock.return_value = now
+            await detector._check_printer(1, printer_status(), settings())
+
+        records = [record for record in caplog.records if record.name == MODULE]
+        failures = [record for record in records if "inspection failed" in record.message]
+        assert len(failures) == 2
+        assert all(record.levelno == logging.DEBUG for record in failures)
+        for record, delay in zip(failures, (60, 120), strict=True):
+            assert f"error_code=OE_CONTEXT_RATE_LIMITED, retry_in={delay:.1f}s" in record.message
+            assert API_ERRORS["OE_CONTEXT_RATE_LIMITED"] in record.message
+        assert len([record for record in records if record.levelno == logging.WARNING]) == 1
+        assert not any("inspection result" in record.message for record in records)
+        log_text = "\n".join(record.message for record in records)
+        for private_value in ("secret-api-key", "context-one", PRIMARY_URL, FALLBACK_URL, "private-response-details"):
+            assert private_value not in log_text
+
+    @pytest.mark.parametrize(
+        "response, expected_error",
+        [
+            (
+                httpx.Response(
+                    200,
+                    json=process_payload(
+                        PrintQuality="private-invalid-quality",
+                        WarningSuggested="private-invalid-warning",
+                        PauseSuggested="private-invalid-pause",
+                    ),
+                ),
+                "invalid print assessment",
+            ),
+            (
+                httpx.Response(
+                    200,
+                    json=process_payload(NextProcessIntervalSec={"Minimum": "private-invalid-interval"}),
+                ),
+                "invalid processing interval",
+            ),
+            (
+                httpx.Response(
+                    500,
+                    json={"ErrorType": "private-unknown-code", "ErrorDetails": "private-error-details secret-api-key"},
+                ),
+                "API request failed (HTTP 500)",
+            ),
+            (httpx.ConnectError(f"private-transport-details {PRIMARY_URL} secret-api-key"), "Could not reach"),
+            (httpx.Response(200, text="private-raw-payload secret-api-key"), "invalid response"),
+        ],
+        ids=["invalid-assessment", "invalid-interval", "unknown-error", "transport-error", "invalid-json"],
+    )
+    async def test_failures_do_not_log_remote_payloads_or_credentials(
+        self, detector, http_client, clock, caplog, response, expected_error
+    ):
+        caplog.set_level(logging.DEBUG, logger=MODULE)
+        http_client.post.side_effect = [httpx.Response(200, json=context_payload()), response]
+        await detector._check_printer(1, printer_status(), settings())
+        records = [record for record in caplog.records if record.name == MODULE]
+        failures = [record for record in records if "inspection failed" in record.message]
+        assert len(failures) == 1
+        assert failures[0].levelno == logging.DEBUG
+        assert expected_error in failures[0].message
+        assert "error_code=None, retry_in=60.0s" in failures[0].message
+        assert not any("inspection result" in record.message for record in records)
+        log_text = "\n".join(record.message for record in records)
+        for private_value in ("secret-api-key", "context-one", PRIMARY_URL, FALLBACK_URL, "private-"):
+            assert private_value not in log_text
+
+    async def test_stale_result_logs_discard_without_logging_assessment(self, detector, http_client, clock, caplog):
+        caplog.set_level(logging.DEBUG, logger=MODULE)
+
+        async def respond(url, **kwargs):
+            if url == CREATE_CONTEXT_URL:
+                return httpx.Response(200, json=context_payload())
+            detector.reset_printer(1)
+            return httpx.Response(200, json=process_payload(PauseSuggested=True))
+
+        http_client.post.side_effect = respond
+        await detector._check_printer(1, printer_status(), settings(action="pause"))
+        records = [record for record in caplog.records if record.name == MODULE]
+        discarded = [record for record in records if "inspection discarded" in record.message]
+        assert len(discarded) == 1
+        assert discarded[0].levelno == logging.DEBUG
+        assert not any("inspection result" in record.message for record in records)
+        assert not any("inspection failed" in record.message for record in records)
+        detector._dispatch_action.assert_not_awaited()
+
+    async def test_canceled_inspection_logs_cancellation_without_exception_details(
+        self, detector, http_client, clock, caplog
+    ):
+        caplog.set_level(logging.DEBUG, logger=MODULE)
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            asyncio.CancelledError("private-cancellation-details secret-api-key"),
+        ]
+        with pytest.raises(asyncio.CancelledError):
+            await detector._check_printer(1, printer_status(), settings())
+        records = [record for record in caplog.records if record.name == MODULE]
+        canceled = [record for record in records if "inspection canceled" in record.message]
+        assert len(canceled) == 1
+        assert canceled[0].levelno == logging.DEBUG
+        assert not any("inspection result" in record.message for record in records)
+        assert not any("inspection failed" in record.message for record in records)
+        log_text = "\n".join(record.message for record in records)
+        assert "private-cancellation-details" not in log_text
+        assert "secret-api-key" not in log_text
+
+
+class TestErrorCodes:
+    async def test_connection_exposes_quota_code_and_safe_message(self, detector, http_client):
+        http_client.post.return_value = quota_response()
+        result = await detector.test_connection("secret-api-key")
+        assert result == {
+            "ok": False,
+            "status_code": 429,
+            "error": API_ERRORS[USAGE_LIMIT_ERROR],
+            "error_code": USAGE_LIMIT_ERROR,
+        }
+        assert result["error"] == "API usage limit reached. Set up billing to continue."
+        assert "secret-api-key" not in result["error"]
+        detector._capture_frame.assert_not_awaited()
+        detector._dispatch_action.assert_not_awaited()
+
+    @pytest.mark.parametrize(
+        "unknown_code", [None, "OE_FUTURE_ERROR_secret-api-key", ["secret-api-key"], {"secret": "secret-api-key"}]
+    )
+    async def test_unknown_remote_codes_are_not_exposed(self, detector, http_client, clock, unknown_code):
+        response = httpx.Response(429, json={"ErrorType": unknown_code, "ErrorDetails": "secret-api-key"})
+        http_client.post.return_value = response
+        result = await detector.test_connection("secret-api-key")
+        assert result["error_code"] is None
+        assert "secret-api-key" not in result["error"]
+        http_client.post.side_effect = [httpx.Response(200, json=context_payload()), response]
+        await detector._check_printer(1, printer_status(), settings())
+        status = detector.get_status()
+        assert status["last_error_code"] is None
+        assert status["per_printer"][1]["error_code"] is None
+        assert "secret-api-key" not in str(status)
+
+    @pytest.mark.parametrize(
+        "error_code",
+        [
+            "OE_INVALID_API_KEY",
+            "OE_API_KEY_DISABLED",
+            "OE_API_KEY_BLOCKED_PAYMENT_FAILED",
+            IP_RESTRICTED_ERROR,
+            USAGE_LIMIT_ERROR,
+        ],
+    )
+    @pytest.mark.parametrize("stage", ["context", "inspection"])
+    async def test_account_errors_stop_all_printers_without_automatic_retries(
+        self, detector, http_client, manager, clock, error_code, stage
+    ):
+        manager.get_all_statuses.return_value[2] = printer_status(subtask_id="print-2")
+        response = httpx.Response(
+            429 if error_code == USAGE_LIMIT_ERROR else 403,
+            headers={"Retry-After": "30"},
+            json={"ErrorType": error_code, "ErrorDetails": "secret-api-key"},
+        )
+        http_client.post.side_effect = (
+            [httpx.Response(200, json=context_payload()), response] if stage == "inspection" else [response]
+        )
+        await poll_once(detector, settings(action="pause_and_off"))
+        calls = http_client.post.await_count
+        detector._capture_frame.assert_awaited_once_with(1)
+        for now in (130, 3700, 100 + 31 * 24 * 3600):
+            clock.return_value = now
+            await poll_once(detector, settings(poll_interval=5, action="pause_and_off"))
+        assert http_client.post.await_count == calls
+        detector._capture_frame.assert_awaited_once_with(1)
+        detector._dispatch_action.assert_not_awaited()
+        status = detector.get_status()
+        assert status["last_error_code"] == error_code
+        assert status["last_error"] == API_ERRORS[error_code]
+        assert set(status["per_printer"]) == {1, 2}
+        assert all(entry["error_code"] == error_code for entry in status["per_printer"].values())
+        assert all(entry["class"] == "error" for entry in status["per_printer"].values())
+        assert status["history"] == []
+        assert "secret-api-key" not in str(status)
+        if stage == "inspection":
+            assert detector._states[1].context.process_url == PRIMARY_URL
+
+    @pytest.mark.parametrize("status_code", [401, 403])
+    async def test_unrecognized_auth_response_still_blocks_the_account(self, detector, http_client, clock, status_code):
+        http_client.post.return_value = httpx.Response(status_code, text="secret-api-key")
+        await detector._check_printer(1, printer_status(), settings())
+        clock.return_value = 1000
+        await detector._check_printer(2, printer_status(), settings())
+        assert detector.get_status()["last_error_code"] == "OE_INVALID_API_KEY"
+        http_client.post.assert_awaited_once()
+        detector._capture_frame.assert_awaited_once()
+
+    @pytest.mark.parametrize(
+        "change", ["confidence", "interval", "action", "selection", "disabled", "reset", "new_print", "disconnect"]
+    )
+    async def test_ordinary_settings_and_lifecycle_changes_do_not_clear_account_block(
+        self, detector, http_client, manager, clock, change
+    ):
+        http_client.post.side_effect = [httpx.Response(200, json=context_payload()), quota_response()]
+        await poll_once(detector, settings())
+        clock.return_value = 1000
+        configuration = settings()
+        if change == "reset":
+            detector.reset_printer(1)
+        elif change == "new_print":
+            manager.get_all_statuses.return_value[1] = printer_status(subtask_id="print-2")
+        elif change == "disconnect":
+            manager.is_connected.return_value = False
+        else:
+            configuration.update(
+                {
+                    "confidence": {"confidence": "high"},
+                    "interval": {"poll_interval": 5},
+                    "action": {"action": "pause_and_off"},
+                    "selection": {"enabled_printers": set()},
+                    "disabled": {"enabled": False},
+                }[change]
+            )
+        await poll_once(detector, configuration)
+        assert detector.get_status()["last_error_code"] == USAGE_LIMIT_ERROR
+        manager.is_connected.return_value = True
+        await poll_once(detector, settings())
+        status = detector.get_status()
+        assert status["last_error_code"] == USAGE_LIMIT_ERROR
+        assert status["last_error"] == API_ERRORS[USAGE_LIMIT_ERROR]
+        assert status["per_printer"][1]["error_code"] == USAGE_LIMIT_ERROR
+        assert http_client.post.await_count == 2
+        detector._capture_frame.assert_awaited_once()
+        detector._dispatch_action.assert_not_awaited()
+
+    @pytest.mark.parametrize("tested_key, recovered", [("secret-api-key", True), ("another-key", False)])
+    async def test_only_successful_test_of_configured_key_resumes_checks(
+        self, detector, http_client, manager, clock, tested_key, recovered
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            ip_restricted_response(),
+            httpx.Response(200, json=context_payload(ContextId="test-context")),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await poll_once(detector, settings())
+        context = detector._states[1].context
+        assert (await detector.test_connection(tested_key))["ok"] is True
+        clock.return_value = 120
+        await poll_once(detector, settings())
+        assert http_client.post.await_count == (4 if recovered else 3)
+        assert detector.get_per_printer()[1]["class"] == ("safe" if recovered else "error")
+        assert detector._states[1].context is context
+        assert detector._states[1].context.process_url == PRIMARY_URL
+
+    async def test_recovery_preserves_context_actions_and_server_deadline(self, detector, http_client, manager, clock):
+        assessment = process_payload(NextProcessIntervalSec={"Minimum": 300, "Recommended": 20}, PauseSuggested=True)
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=assessment),
+            quota_response(),
+            httpx.Response(200, json=context_payload(ContextId="test-context")),
+            httpx.Response(200, json=assessment),
+        ]
+        configuration = settings(action="pause_and_off")
+        await poll_once(detector, configuration)
+        state = detector._states[1]
+        context = state.context
+        clock.return_value = 400
+        await poll_once(detector, configuration)
+        assert detector.get_status()["last_error_code"] == USAGE_LIMIT_ERROR
+        assert (await detector.test_connection("secret-api-key"))["ok"] is True
+        assert detector._states[1] is state
+        assert state.context is context
+        assert state.warning_fired and state.action_fired
+        assert state.interval == 300
+        assert state.next_check_at == 700
+        clock.return_value = 699
+        await poll_once(detector, configuration)
+        assert http_client.post.await_count == 4
+        clock.return_value = 700
+        await poll_once(detector, configuration)
+        assert http_client.post.await_count == 5
+        assert http_client.post.await_args.args == (PRIMARY_URL,)
+        assert detector.get_status()["last_error_code"] is None
+        assert detector.get_status()["last_error"] is None
+        assert state.context is context
+        detector._dispatch_action.assert_awaited_once()
+
+    async def test_saving_replacement_key_recovers_without_repeating_actions(
+        self, detector, http_client, manager, clock
+    ):
+        assessment = process_payload(NextProcessIntervalSec={"Minimum": 300, "Recommended": 20}, PauseSuggested=True)
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=assessment),
+            quota_response(),
+            httpx.Response(200, json=context_payload(ContextId="new-context")),
+            httpx.Response(200, json=assessment),
+        ]
+        await poll_once(detector, settings(action="pause"))
+        state = detector._states[1]
+        clock.return_value = 400
+        await poll_once(detector, settings(action="pause"))
+        configuration = settings(api_key="replacement-key", action="pause")
+        clock.return_value = 401
+        await poll_once(detector, configuration)
+        assert detector.get_status()["last_error_code"] is None
+        assert state.context is None
+        assert state.interval == 300
+        assert state.next_check_at == 700
+        assert state.warning_fired and state.action_fired
+        assert http_client.post.await_count == 3
+        clock.return_value = 700
+        await poll_once(detector, configuration)
+        assert state.context.context_id == "new-context"
+        creation = http_client.post.await_args_list[3]
+        assert creation.args == (CREATE_CONTEXT_URL,)
+        assert creation.kwargs["headers"]["X-API-Key"] == "replacement-key"
+        assert detector.get_per_printer()[1]["class"] == "failure"
+        detector._dispatch_action.assert_awaited_once()
+
+    async def test_successful_test_cannot_confirm_remaining_inspection_allowance(
+        self, detector, http_client, manager, clock
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            quota_response(),
+            httpx.Response(200, json=context_payload(ContextId="test-context")),
+            quota_response(),
+        ]
+        await poll_once(detector, settings())
+        assert (await detector.test_connection("secret-api-key"))["ok"] is True
+        clock.return_value = 120
+        await poll_once(detector, settings())
+        assert detector.get_status()["last_error_code"] == USAGE_LIMIT_ERROR
+        clock.return_value = 1000
+        await poll_once(detector, settings())
+        assert http_client.post.await_count == 4
+        assert detector._capture_frame.await_count == 2
+
+    @pytest.mark.parametrize("response", [httpx.Response(503), httpx.ReadTimeout("private-details")])
+    async def test_failed_test_does_not_clear_account_block(self, detector, http_client, manager, clock, response):
+        http_client.post.side_effect = [httpx.Response(200, json=context_payload()), quota_response(), response]
+        await poll_once(detector, settings())
+        assert (await detector.test_connection("secret-api-key"))["ok"] is False
+        clock.return_value = 1000
+        await poll_once(detector, settings())
+        assert detector.get_status()["last_error_code"] == USAGE_LIMIT_ERROR
+        assert http_client.post.await_count == 3
+        detector._capture_frame.assert_awaited_once()
+
+    @pytest.mark.parametrize("tested_key, blocks", [("secret-api-key", True), ("another-key", False)])
+    async def test_account_error_testing_configured_key_stops_monitoring(
+        self, detector, http_client, manager, clock, tested_key, blocks
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+            ip_restricted_response(),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await poll_once(detector, settings())
+        assert (await detector.test_connection(tested_key))["ok"] is False
+        clock.return_value = 140
+        await poll_once(detector, settings())
+        assert http_client.post.await_count == (3 if blocks else 4)
+        assert detector.get_status()["last_error_code"] == (IP_RESTRICTED_ERROR if blocks else None)
+
+    @pytest.mark.parametrize("replace_key", [False, True])
+    async def test_stale_successful_test_cannot_clear_a_newer_account_block(
+        self, detector, http_client, manager, clock, replace_key
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await poll_once(detector, settings())
+        started = asyncio.Event()
+        release = asyncio.Event()
+
+        async def respond(url, **kwargs):
+            if url == CREATE_CONTEXT_URL:
+                if kwargs["headers"]["X-API-Key"] == "secret-api-key":
+                    started.set()
+                    await release.wait()
+                return httpx.Response(200, json=context_payload(ContextId="test-context"))
+            return quota_response()
+
+        http_client.post.side_effect = respond
+        test = asyncio.create_task(detector.test_connection("secret-api-key"))
+        try:
+            await asyncio.wait_for(started.wait(), timeout=1)
+            configuration = settings(api_key="replacement-key") if replace_key else settings()
+            clock.return_value = 140
+            await poll_once(detector, configuration)
+            assert detector.get_status()["last_error_code"] == USAGE_LIMIT_ERROR
+            release.set()
+            assert (await asyncio.wait_for(test, timeout=1))["ok"] is True
+            clock.return_value = 1000
+            captures = detector._capture_frame.await_count
+            await poll_once(detector, configuration)
+            assert detector._capture_frame.await_count == captures
+            assert detector.get_status()["last_error_code"] == USAGE_LIMIT_ERROR
+        finally:
+            release.set()
+            await asyncio.gather(test, return_exceptions=True)
+
+    async def test_stale_failed_test_cannot_block_a_replacement_key(self, detector, http_client, manager, clock):
+        detector._apply_settings(settings())
+        started = asyncio.Event()
+        release = asyncio.Event()
+
+        async def respond(url, **kwargs):
+            started.set()
+            await release.wait()
+            return ip_restricted_response()
+
+        http_client.post.side_effect = respond
+        test = asyncio.create_task(detector.test_connection("secret-api-key"))
+        try:
+            await asyncio.wait_for(started.wait(), timeout=1)
+            detector._apply_settings(settings(api_key="replacement-key"))
+            release.set()
+            assert (await asyncio.wait_for(test, timeout=1))["ok"] is False
+            assert detector.get_status()["last_error_code"] is None
+        finally:
+            release.set()
+            await asyncio.gather(test, return_exceptions=True)
+
+    @pytest.mark.parametrize("change", ["paused", "finished", "reset"])
+    async def test_account_error_from_ending_print_still_blocks_other_printers(
+        self, detector, http_client, clock, change
+    ):
+        status = printer_status()
+        detector._apply_settings(settings())
+
+        async def respond(url, **kwargs):
+            if url == CREATE_CONTEXT_URL:
+                return httpx.Response(200, json=context_payload())
+            if change == "reset":
+                detector.reset_printer(1)
+            else:
+                status.state = "PAUSE" if change == "paused" else "FINISH"
+            return quota_response()
+
+        http_client.post.side_effect = respond
+        await detector._check_printer(1, status, settings())
+        await detector._check_printer(2, printer_status(subtask_id="print-2"), settings())
+        assert detector.get_status()["last_error_code"] == USAGE_LIMIT_ERROR
+        assert detector.get_per_printer()[2]["error_code"] == USAGE_LIMIT_ERROR
+        assert http_client.post.await_count == 2
+        detector._capture_frame.assert_awaited_once_with(1)
+        detector._dispatch_action.assert_not_awaited()
+
+    @pytest.mark.parametrize("changes", [{"api_key": "replacement-key"}, {"confidence": "high"}])
+    async def test_account_error_from_old_configuration_cannot_block_new_configuration(
+        self, detector, http_client, clock, changes
+    ):
+        detector._apply_settings(settings())
+
+        async def respond(url, **kwargs):
+            if url == CREATE_CONTEXT_URL:
+                return httpx.Response(200, json=context_payload())
+            detector._apply_settings(settings(**changes))
+            return quota_response()
+
+        http_client.post.side_effect = respond
+        await detector._check_printer(1, printer_status(), settings())
+        assert detector.get_status()["last_error_code"] is None
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        clock.return_value = 120
+        await detector._check_printer(1, printer_status(), settings(**changes))
+        assert detector.get_per_printer()[1]["class"] == "safe"
+        assert http_client.post.await_count == 4
+
+    @pytest.mark.parametrize("stage", ["camera", "inspection"])
+    @pytest.mark.parametrize("ignore_cancellation", [False, True])
+    async def test_account_block_cancels_sibling_checks_and_discards_late_decisions(
+        self, detector, http_client, manager, clock, stage, ignore_cancellation
+    ):
+        manager.get_all_statuses.return_value = {2: printer_status(subtask_id="print-2"), 1: printer_status()}
+        started = asyncio.Event()
+        canceled = asyncio.Event()
+
+        async def wait_for_block():
+            started.set()
+            try:
+                await asyncio.Event().wait()
+            except asyncio.CancelledError:
+                canceled.set()
+                if not ignore_cancellation:
+                    raise
+
+        async def capture(printer_id):
+            if printer_id == 2 and stage == "camera":
+                await wait_for_block()
+            if printer_id == 1:
+                await started.wait()
+            return FAKE_JPEG + bytes([printer_id])
+
+        async def respond(url, **kwargs):
+            if url == CREATE_CONTEXT_URL:
+                return httpx.Response(200, json=context_payload())
+            if kwargs["files"]["image"][1][-1] == 1:
+                return quota_response()
+            await wait_for_block()
+            return httpx.Response(200, json=process_payload(PauseSuggested=True))
+
+        detector._capture_frame.side_effect = capture
+        http_client.post.side_effect = respond
+        await detector._poll_once(settings(action="pause_and_off"))
+        checks = list(detector._checks.values())
+        try:
+            await asyncio.wait_for(asyncio.gather(*checks, return_exceptions=True), timeout=1)
+            assert canceled.is_set()
+            assert detector.get_status()["last_error_code"] == USAGE_LIMIT_ERROR
+            assert detector._states[2].frame_count == 0
+            assert not detector._states[2].action_fired
+            assert detector.get_status()["history"] == []
+            detector._dispatch_action.assert_not_awaited()
+            captures = detector._capture_frame.await_count
+            calls = http_client.post.await_count
+            clock.return_value = 1000
+            await poll_once(detector, settings(action="pause_and_off"))
+            assert detector._capture_frame.await_count == captures
+            assert http_client.post.await_count == calls
+        finally:
+            detector.stop()
+            await asyncio.gather(*checks, return_exceptions=True)
+
+    @pytest.mark.parametrize(
+        "response, expected_url",
+        [
+            (httpx.Response(400, json={"ErrorType": "OE_BAD_ARGS"}), PRIMARY_URL),
+            (httpx.Response(400, json={"ErrorType": "OE_ARGS_PARSE_FAILED"}), PRIMARY_URL),
+            (httpx.Response(422, json={"ErrorType": "OE_IMAGE_DECODE_FAILED"}), PRIMARY_URL),
+            (httpx.Response(429, json={"ErrorType": "OE_CONTEXT_RATE_LIMITED"}), PRIMARY_URL),
+            (httpx.Response(500, json={"ErrorType": "OE_BAD_ARGS"}), PRIMARY_URL),
+            (httpx.Response(200, text="not-json"), PRIMARY_URL),
+            (httpx.Response(503, json={"ErrorType": "OE_BACKEND_THROTTLED"}), FALLBACK_URL),
+            (httpx.Response(500, json={"ErrorType": "OE_INTERNAL_ERROR"}), FALLBACK_URL),
+            (httpx.Response(502, text="not-json"), FALLBACK_URL),
+            (httpx.ConnectError("private-details"), FALLBACK_URL),
+            (httpx.ReadTimeout("private-details"), FALLBACK_URL),
+        ],
+    )
+    async def test_fallback_is_only_used_for_transport_or_server_failures(
+        self, detector, http_client, clock, response, expected_url
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            response,
+            httpx.Response(200, json=process_payload()),
+        ]
+        await detector._check_printer(1, printer_status(), settings())
+        context = detector._states[1].context
+        assert context.process_url == expected_url
+        clock.return_value = 160
+        await detector._check_printer(1, printer_status(), settings())
+        assert detector._states[1].context is context
+        assert http_client.post.await_args.args == (expected_url,)
+        assert detector.get_per_printer()[1]["class"] == "safe"
+
+    async def test_loop_error_does_not_hide_account_block(self, detector, http_client, clock):
+        http_client.post.side_effect = [httpx.Response(200, json=context_payload()), quota_response()]
+        await detector._check_printer(1, printer_status(), settings())
+        detector._load_settings = AsyncMock(side_effect=RuntimeError("secret-api-key"))
+        with (
+            patch(f"{MODULE}.asyncio.sleep", new=AsyncMock(side_effect=asyncio.CancelledError)),
+            pytest.raises(asyncio.CancelledError),
+        ):
+            await detector._loop()
+        assert detector.get_status()["last_error_code"] == USAGE_LIMIT_ERROR
+        assert detector.get_status()["last_error"] == API_ERRORS[USAGE_LIMIT_ERROR]
+
+    async def test_action_error_does_not_hide_account_block(self, detector, http_client, clock):
+        http_client.post.side_effect = [httpx.Response(200, json=context_payload()), quota_response()]
+        await detector._check_printer(1, printer_status(), settings())
+        detector._dispatch_action = OctoEverywhereDetectionService._dispatch_action.__get__(detector)
+        with patch(
+            "backend.app.services.octoeverywhere_actions.execute_action", side_effect=RuntimeError("secret-api-key")
+        ):
+            await detector._dispatch_action(1, "notify", "test-print", 1)
+        assert detector.get_status()["last_error_code"] == USAGE_LIMIT_ERROR
+        assert detector.get_status()["last_error"] == API_ERRORS[USAGE_LIMIT_ERROR]
+
+
+class TestScheduling:
+    @pytest.mark.parametrize("slow_stage", ["camera", "api"])
+    @pytest.mark.parametrize("faster, interval", [(False, 20), (True, 5)])
+    async def test_slow_printer_does_not_delay_other_inspections(
+        self, detector, http_client, manager, clock, slow_stage, faster, interval
+    ):
+        blocked = asyncio.Event()
+        manager.get_all_statuses.return_value[2] = printer_status(subtask_id="print-2")
+
+        async def capture(printer_id):
+            if printer_id == 1 and slow_stage == "camera":
+                blocked.set()
+                await asyncio.Event().wait()
+            return FAKE_JPEG + bytes([printer_id])
+
+        async def respond(url, **kwargs):
+            if url == CREATE_CONTEXT_URL:
+                return httpx.Response(200, json=context_payload())
+            if kwargs["files"]["image"][1][-1] == 1 and slow_stage == "api":
+                blocked.set()
+                await asyncio.Event().wait()
+            return httpx.Response(
+                200,
+                json=process_payload(
+                    NextProcessIntervalSec={"Minimum": 5, "Recommended": 5},
+                    FasterInspectionSuggested=faster,
+                ),
+            )
+
+        detector._capture_frame.side_effect = capture
+        http_client.post.side_effect = respond
+        poll = asyncio.create_task(detector._poll_once(settings()))
+        try:
+            await asyncio.wait_for(blocked.wait(), timeout=1)
+            await asyncio.wait_for(asyncio.shield(poll), timeout=1)
+            slow_check = detector._checks[1]
+            context = detector._states[2].context
+            assert detector._states[2].frame_count == 1
+
+            clock.return_value = 100 + interval - 1
+            await asyncio.wait_for(detector._poll_once(settings()), timeout=1)
+            await asyncio.sleep(0)
+            assert detector._states[2].frame_count == 1
+
+            for frame_count in (2, 3):
+                clock.return_value = 100 + interval * (frame_count - 1)
+                await asyncio.wait_for(detector._poll_once(settings()), timeout=1)
+                await asyncio.sleep(0)
+                assert detector._states[2].frame_count == frame_count
+                assert detector._states[2].context is context
+                assert detector._checks[1] is slow_check
+                assert not slow_check.done()
+
+            captures = [call.args[0] for call in detector._capture_frame.await_args_list]
+            assert captures.count(1) == 1
+            assert captures.count(2) == 3
+        finally:
+            checks = list(detector._checks.values())
+            detector.stop()
+            poll.cancel()
+            await asyncio.gather(poll, *checks, return_exceptions=True)
+
+    @pytest.mark.parametrize("change", ["paused", "unknown", "disconnected", "finished", "removed", "deselected"])
+    async def test_poll_cancels_stale_inspection_without_waiting_for_response(
+        self, detector, http_client, manager, clock, change
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await poll_once(detector, settings(action="pause"))
+        state = detector._states[1]
+        context = state.context
+        processing = asyncio.Event()
+
+        async def pending_response(url, **kwargs):
+            processing.set()
+            await asyncio.Event().wait()
+
+        http_client.post.side_effect = pending_response
+        clock.return_value = 140
+        await detector._poll_once(settings(action="pause"))
+        check = detector._checks[1]
+        try:
+            await asyncio.wait_for(processing.wait(), timeout=1)
+            configuration = settings(action="pause")
+            if change in ("paused", "unknown", "finished"):
+                manager.get_all_statuses.return_value[1] = printer_status(
+                    state={"paused": "PAUSE", "unknown": "unknown", "finished": "FINISH"}[change]
+                )
+            elif change == "disconnected":
+                manager.is_connected.return_value = False
+            elif change == "removed":
+                manager.get_all_statuses.return_value = {}
+            else:
+                configuration["enabled_printers"] = set()
+            await detector._poll_once(configuration)
+            await asyncio.wait_for(asyncio.gather(check, return_exceptions=True), timeout=1)
+
+            assert check.cancelled()
+            assert detector._checks == {}
+            detector._dispatch_action.assert_not_awaited()
+            if change in ("paused", "unknown", "disconnected"):
+                assert detector._states[1] is state
+                assert state.context is context
+                assert state.frame_count == 1
+            else:
+                assert detector.get_per_printer() == {}
+        finally:
+            detector.stop()
+            await asyncio.gather(check, return_exceptions=True)
+
+    async def test_completed_old_check_cannot_remove_replacement(self, detector, manager, clock):
+        started = asyncio.Event()
+
+        async def capture(printer_id):
+            started.set()
+            await asyncio.Event().wait()
+
+        detector._capture_frame.side_effect = capture
+        await detector._poll_once(settings())
+        old_check = detector._checks[1]
+        await asyncio.wait_for(started.wait(), timeout=1)
+        manager.get_all_statuses.return_value[1] = printer_status(subtask_id="print-2")
+        await detector._poll_once(settings())
+        new_check = detector._checks[1]
+        try:
+            await asyncio.wait_for(asyncio.gather(old_check, return_exceptions=True), timeout=1)
+            assert old_check.cancelled()
+            assert new_check is not old_check
+            assert detector._checks[1] is new_check
+            assert not new_check.done()
+            assert detector._states[1].print_id == "print-2"
+        finally:
+            detector.stop()
+            await asyncio.gather(old_check, new_check, return_exceptions=True)
+
+    async def test_unexpected_check_error_is_logged_safely_and_does_not_block_retry(self, detector, manager, caplog):
+        detector._check_printer = AsyncMock(side_effect=[RuntimeError("secret-api-key"), None])
+        with caplog.at_level(logging.ERROR, logger=MODULE):
+            await poll_once(detector, settings())
+            assert detector._checks == {}
+            await poll_once(detector, settings())
+
+        assert detector._check_printer.await_count == 2
+        assert "OctoEverywhere printer check failed: RuntimeError" in caplog.text
+        assert "secret-api-key" not in caplog.text
+
+
+class TestLifecycle:
+    @pytest.mark.parametrize("initial_id", [None, "", "0", 0])
+    async def test_late_job_id_reuses_context_and_later_new_job_replaces_it(
+        self, detector, http_client, manager, clock, initial_id
+    ):
+        new_url = PRIMARY_URL.replace("context-one", "context-two")
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+            httpx.Response(200, json=process_payload()),
+            httpx.Response(200, json=context_payload(ContextId="context-two", ProcessRequestUrl=new_url)),
+            httpx.Response(200, json=process_payload()),
+        ]
+        status = manager.get_all_statuses.return_value[1]
+        status.subtask_id = initial_id
+        await poll_once(detector, settings())
+        state = detector._states[1]
+        context = state.context
+
+        status.subtask_id = "print-1"
+        clock.return_value = 140
+        await poll_once(detector, settings())
+        assert detector._states[1] is state
+        assert state.context is context
+        assert state.frame_count == 2
+
+        status.subtask_id = "print-2"
+        clock.return_value = 180
+        await poll_once(detector, settings())
+        assert detector._states[1] is not state
+        assert detector._states[1].context.context_id == "context-two"
+        assert detector._states[1].frame_count == 1
+        assert [call.args[0] for call in http_client.post.await_args_list] == [
+            CREATE_CONTEXT_URL,
+            PRIMARY_URL,
+            PRIMARY_URL,
+            CREATE_CONTEXT_URL,
+            new_url,
+        ]
+
+    @pytest.mark.parametrize("missing_id", [None, "", "0", 0])
+    async def test_missing_job_id_preserves_context_and_last_known_job(
+        self, detector, http_client, manager, clock, missing_id
+    ):
+        new_url = PRIMARY_URL.replace("context-one", "context-two")
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+            httpx.Response(200, json=process_payload()),
+            httpx.Response(200, json=process_payload()),
+            httpx.Response(200, json=process_payload()),
+            httpx.Response(200, json=context_payload(ContextId="context-two", ProcessRequestUrl=new_url)),
+            httpx.Response(200, json=process_payload()),
+        ]
+        status = manager.get_all_statuses.return_value[1]
+        await poll_once(detector, settings())
+        state = detector._states[1]
+        context = state.context
+
+        for now, job_id in ((140, missing_id), (180, "print-1"), (220, missing_id)):
+            status.subtask_id = job_id
+            clock.return_value = now
+            await poll_once(detector, settings())
+            assert detector._states[1] is state
+            assert state.context is context
+        assert state.frame_count == 4
+
+        status.subtask_id = "print-2"
+        clock.return_value = 260
+        await poll_once(detector, settings())
+        assert detector._states[1] is not state
+        assert detector._states[1].context.context_id == "context-two"
+        assert [call.args[0] for call in http_client.post.await_args_list] == [
+            CREATE_CONTEXT_URL,
+            PRIMARY_URL,
+            PRIMARY_URL,
+            PRIMARY_URL,
+            PRIMARY_URL,
+            CREATE_CONTEXT_URL,
+            new_url,
+        ]
+
+    async def test_filename_and_name_changes_without_job_id_reuse_printer_context(
+        self, detector, http_client, manager, clock
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+            httpx.Response(200, json=process_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        status = manager.get_all_statuses.return_value[1]
+        status.subtask_id = None
+        await poll_once(detector, settings())
+        state = detector._states[1]
+        context = state.context
+
+        for now, name, filename in ((140, "renamed-print", "plate_2.gcode"), (180, None, None)):
+            status.subtask_name = name
+            status.gcode_file = filename
+            clock.return_value = now
+            await poll_once(detector, settings())
+            assert detector._states[1] is state
+            assert state.context is context
+        assert state.frame_count == 3
+        assert [call.args[0] for call in http_client.post.await_args_list] == [
+            CREATE_CONTEXT_URL,
+            PRIMARY_URL,
+            PRIMARY_URL,
+            PRIMARY_URL,
+        ]
+
+    @pytest.mark.parametrize(
+        "transient_status",
+        [printer_status(state="unknown"), printer_status(state="PREPARE"), printer_status(state=None), None],
+    )
+    async def test_transient_printer_status_preserves_context_without_uploading(
+        self, detector, http_client, manager, clock, transient_status
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        status = manager.get_all_statuses.return_value[1]
+        await poll_once(detector, settings())
+        state = detector._states[1]
+        context = state.context
+
+        manager.get_all_statuses.return_value[1] = transient_status
+        clock.return_value = 300
+        await poll_once(detector, settings())
+        assert detector._states[1] is state
+        assert state.context is context
+        assert http_client.post.await_count == 2
+        detector._capture_frame.assert_awaited_once()
+
+        manager.get_all_statuses.return_value[1] = status
+        await poll_once(detector, settings())
+        assert detector._states[1] is state
+        assert state.context is context
+        assert state.frame_count == 2
+        assert [call.args[0] for call in http_client.post.await_args_list] == [
+            CREATE_CONTEXT_URL,
+            PRIMARY_URL,
+            PRIMARY_URL,
+        ]
+
+    @pytest.mark.parametrize("phase", ["capture", "context", "process"])
+    async def test_late_metadata_during_inspection_preserves_context_and_result(
+        self, detector, http_client, clock, phase
+    ):
+        status = printer_status(subtask_id=None)
+
+        def update_metadata():
+            status.subtask_id = "print-1"
+            status.subtask_name = "updated-print-name"
+            status.gcode_file = "updated-plate.gcode"
+
+        async def capture(printer_id):
+            if phase == "capture":
+                update_metadata()
+            return FAKE_JPEG
+
+        async def respond(url, **kwargs):
+            if url == CREATE_CONTEXT_URL:
+                if phase == "context":
+                    update_metadata()
+                return httpx.Response(200, json=context_payload())
+            if phase == "process":
+                update_metadata()
+            return httpx.Response(200, json=process_payload())
+
+        detector._capture_frame.side_effect = capture
+        http_client.post.side_effect = respond
+        await detector._check_printer(1, status, settings())
+        state = detector._states[1]
+        context = state.context
+        assert context is not None
+        assert state.frame_count == 1
+        assert detector.get_per_printer()[1]["class"] == "safe"
+
+        clock.return_value = 140
+        await detector._check_printer(1, status, settings())
+        assert detector._states[1] is state
+        assert state.context is context
+        assert state.frame_count == 2
+        assert [call.args[0] for call in http_client.post.await_args_list] == [
+            CREATE_CONTEXT_URL,
+            PRIMARY_URL,
+            PRIMARY_URL,
+        ]
+
+    async def test_known_job_change_during_processing_discards_stale_result(self, detector, http_client, clock):
+        status = printer_status()
+
+        async def respond(url, **kwargs):
+            if url == CREATE_CONTEXT_URL:
+                return httpx.Response(200, json=context_payload())
+            status.subtask_id = "print-2"
+            return httpx.Response(200, json=process_payload(PauseSuggested=True))
+
+        http_client.post.side_effect = respond
+        await detector._check_printer(1, status, settings(action="pause"))
+        state = detector._states[1]
+        assert state.frame_count == 0
+        assert detector.get_status()["history"] == []
+        detector._dispatch_action.assert_not_awaited()
+
+        new_url = PRIMARY_URL.replace("context-one", "context-two")
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload(ContextId="context-two", ProcessRequestUrl=new_url)),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await detector._check_printer(1, status, settings(action="pause"))
+        assert detector._states[1] is not state
+        assert detector._states[1].context.context_id == "context-two"
+        assert detector._states[1].frame_count == 1
+        assert [call.args[0] for call in http_client.post.await_args_list] == [
+            CREATE_CONTEXT_URL,
+            PRIMARY_URL,
+            CREATE_CONTEXT_URL,
+            new_url,
+        ]
+        detector._dispatch_action.assert_not_awaited()
+
+    async def test_pause_preserves_context_and_does_not_upload(self, detector, http_client, manager, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await poll_once(detector, settings())
+        context = detector._states[1].context
+        manager.get_all_statuses.return_value[1].state = "PAUSE"
+        clock.return_value = 300
+        await poll_once(detector, settings())
+        assert detector._states[1].context is context
+        assert http_client.post.await_count == 2
+        manager.get_all_statuses.return_value[1].state = "RUNNING"
+        await poll_once(detector, settings())
+        assert detector._states[1].context is context
+        assert http_client.post.await_count == 3
+
+    @pytest.mark.parametrize("terminal_state", ["FINISH", "FAILED", "IDLE"])
+    async def test_end_clears_state_and_identical_job_gets_new_context(
+        self, detector, http_client, manager, clock, terminal_state
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+            httpx.Response(200, json=context_payload(ContextId="new-context")),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await poll_once(detector, settings())
+        manager.get_all_statuses.return_value[1].state = terminal_state
+        await poll_once(detector, settings())
+        assert detector.get_per_printer() == {}
+        manager.get_all_statuses.return_value[1].state = "RUNNING"
+        await poll_once(detector, settings())
+        assert detector._states[1].context.context_id == "new-context"
+        assert detector._states[1].frame_count == 1
+
+    async def test_same_filename_new_job_id_gets_fresh_context(self, detector, http_client, manager, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(PauseSuggested=True)),
+            httpx.Response(200, json=context_payload(ContextId="new-context")),
+            httpx.Response(200, json=process_payload(PauseSuggested=True)),
+        ]
+        await poll_once(detector, settings())
+        manager.get_all_statuses.return_value[1].subtask_id = "print-2"
+        await poll_once(detector, settings())
+        assert detector._states[1].context.context_id == "new-context"
+        assert detector._dispatch_action.await_count == 2
+
+    @pytest.mark.parametrize("changes", [{"api_key": "replacement-key"}, {"confidence": "high"}])
+    async def test_credentials_or_confidence_change_recreates_context(
+        self, detector, http_client, manager, clock, changes
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(PauseSuggested=True)),
+            httpx.Response(200, json=context_payload(ContextId="new-context")),
+            httpx.Response(200, json=process_payload(PauseSuggested=True)),
+        ]
+        await poll_once(detector, settings())
+        await poll_once(detector, settings(**changes))
+        assert detector._states[1].context is None
+        assert detector._states[1].next_check_at == 140
+        assert http_client.post.await_count == 2
+        clock.return_value = 140
+        await poll_once(detector, settings(**changes))
+        assert detector._states[1].context.context_id == "new-context"
+        detector._dispatch_action.assert_awaited_once()
+        creation = http_client.post.await_args_list[2]
+        assert creation.kwargs["headers"]["X-API-Key"] == changes.get("api_key", "secret-api-key")
+        assert creation.kwargs["json"]["PauseConfidenceLevel"] == (4 if "confidence" in changes else 3)
+
+    @pytest.mark.parametrize(
+        "changes", [{"enabled": False}, {"api_key": ""}, {"enabled_printers": set()}, {"enabled_printers": {2}}]
+    )
+    async def test_disabled_printers_stop_uploading_and_clear_status(
+        self, detector, http_client, manager, clock, changes
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await poll_once(detector, settings())
+        await poll_once(detector, settings(**changes))
+        assert detector.get_per_printer() == {}
+        assert http_client.post.await_count == 2
+
+    async def test_deleted_printer_clears_state(self, detector, http_client, manager, clock):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await poll_once(detector, settings())
+        manager.get_all_statuses.return_value = {}
+        await poll_once(detector, settings())
+        assert detector.get_per_printer() == {}
+
+    async def test_disconnected_printer_retains_context_without_stale_safe_status(
+        self, detector, http_client, manager, clock
+    ):
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload()),
+            httpx.Response(200, json=process_payload()),
+        ]
+        await poll_once(detector, settings())
+        context = detector._states[1].context
+        manager.is_connected.return_value = False
+        clock.return_value = 300
+        await poll_once(detector, settings())
+        assert detector.get_per_printer()[1]["class"] == "error"
+        assert detector._states[1].context is context
+        assert http_client.post.await_count == 2
+        manager.is_connected.return_value = True
+        await poll_once(detector, settings())
+        assert detector._states[1].context is context
+        assert detector.get_per_printer()[1]["class"] == "safe"
+
+    async def test_each_printer_owns_a_separate_context_and_interval(self, detector, http_client, manager, clock):
+        manager.get_all_statuses.return_value[2] = printer_status(subtask_id="print-2")
+        count = 0
+
+        async def respond(url, **kwargs):
+            nonlocal count
+            if url == CREATE_CONTEXT_URL:
+                count += 1
+                return httpx.Response(200, json=context_payload(ContextId=f"context-{count}"))
+            return httpx.Response(
+                200, json=process_payload(NextProcessIntervalSec={"Minimum": 40 * count, "Recommended": 20})
+            )
+
+        http_client.post.side_effect = respond
+        await poll_once(detector, settings())
+        assert {state.context.context_id for state in detector._states.values()} == {"context-1", "context-2"}
+        assert detector._states[1].next_check_at == 140
+        assert detector._states[2].next_check_at == 180
+
+    def test_stop_cancels_service_and_clears_live_status(self, detector):
+        task = MagicMock()
+        detector._task = task
+        detector._last_error = "an old error"
+        detector.stop()
+        task.cancel.assert_called_once()
+        assert detector.get_status() == {
+            "is_running": False,
+            "last_error": None,
+            "last_error_code": None,
+            "per_printer": {},
+            "history": [],
+        }
+
+    async def test_enabled_without_key_reports_configuration_error(self, detector, http_client, manager):
+        await poll_once(detector, settings(api_key=""))
+        assert "API key is required" in detector.get_status()["last_error"]
+        http_client.post.assert_not_awaited()
+
+    async def test_start_is_idempotent(self, detector):
+        with patch.object(detector, "_loop", new=AsyncMock()):
+            await detector.start()
+            first_task = detector._task
+            await detector.start()
+            assert detector._task is first_task
+            assert detector.get_status()["is_running"] is True
+            detector.stop()
+            with pytest.raises(asyncio.CancelledError):
+                await first_task
+
+    @pytest.mark.parametrize(
+        "enabled, poll_interval, expected_poll", [(False, 20, 5), (True, 5, 1), (True, 21, 1), (True, 30, 1)]
+    )
+    async def test_enabled_detection_uses_one_second_scheduler_resolution(
+        self, detector, enabled, poll_interval, expected_poll
+    ):
+        detector._load_settings = AsyncMock(return_value=settings(enabled=enabled, poll_interval=poll_interval))
+        detector._poll_once = AsyncMock()
+        with patch(f"{MODULE}.asyncio.sleep", new=AsyncMock(side_effect=asyncio.CancelledError)) as sleep:
+            await detector._loop()
+        sleep.assert_awaited_once_with(expected_poll)
+
+    async def test_refresh_cancels_stale_settings_load_before_polling(self, detector, clock):
+        loading = asyncio.Event()
+        cancelled = asyncio.Event()
+
+        async def load_old_settings():
+            loading.set()
+            try:
+                await asyncio.Event().wait()
+            except asyncio.CancelledError:
+                cancelled.set()
+                raise
+
+        detector._load_settings = AsyncMock(side_effect=load_old_settings)
+        detector._poll_once = AsyncMock()
+        await detector.start()
+        await asyncio.wait_for(loading.wait(), timeout=1)
+        detector._load_settings = AsyncMock(return_value=settings(enabled=False))
+        await detector.refresh_settings()
+        assert cancelled.is_set()
+        assert detector.get_status()["is_running"] is True
+        await asyncio.sleep(0)
+        for call in detector._poll_once.await_args_list:
+            assert call.args[0]["enabled"] is False
+        task = detector._task
+        detector.stop()
+        await asyncio.gather(task, return_exceptions=True)
+
+    @pytest.mark.parametrize("change", ["print_ended", "disabled", "key_changed", "account_blocked"])
+    async def test_cancel_during_action_lookup_prevents_hardware_control(
+        self, detector, http_client, manager, clock, change
+    ):
+        lookup_started = asyncio.Event()
+        lookup_cancelled = asyncio.Event()
+
+        async def get_printer_name(printer_id):
+            lookup_started.set()
+            try:
+                await asyncio.Event().wait()
+            except asyncio.CancelledError:
+                lookup_cancelled.set()
+                raise
+
+        # Exercise the real action dispatcher: its printer-name database lookup
+        # awaits before pause/power-off, which used to leave a cancellation gap.
+        detector._dispatch_action = OctoEverywhereDetectionService._dispatch_action.__get__(detector)
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(PauseSuggested=True)),
+        ]
+        with (
+            patch("backend.app.services.obico_actions._get_printer_name", side_effect=get_printer_name),
+            patch("backend.app.services.obico_actions._pause_print") as pause,
+            patch("backend.app.services.obico_actions._turn_off_linked_plugs", new=AsyncMock()) as turn_off,
+            patch("backend.app.services.obico_actions._notify", new=AsyncMock()) as notify,
+        ):
+            poll = asyncio.create_task(poll_once(detector, settings(action="pause_and_off")))
+            await asyncio.wait_for(lookup_started.wait(), timeout=1)
+            if change == "print_ended":
+                detector.reset_printer(1)
+            elif change == "account_blocked":
+                http_client.post.side_effect = [httpx.Response(200, json=context_payload()), quota_response()]
+                await detector._check_printer(2, printer_status(subtask_id="print-2"), settings(action="pause_and_off"))
+            else:
+                detector._load_settings = AsyncMock(
+                    return_value=settings(enabled=False)
+                    if change == "disabled"
+                    else settings(api_key="replacement-key")
+                )
+                await detector.refresh_settings()
+            await asyncio.wait_for(poll, timeout=1)
+            assert lookup_cancelled.is_set()
+            pause.assert_not_called()
+            turn_off.assert_not_awaited()
+            notify.assert_not_awaited()
+            if change == "key_changed":
+                assert detector._states[1].action_fired is False
+                assert detector._states[1].warning_fired is False
+                assert detector._states[1].context is None
+            elif change == "account_blocked":
+                assert detector.get_status()["last_error_code"] == USAGE_LIMIT_ERROR
+                assert detector._states[1].action_fired is False
+                assert detector._states[1].warning_fired is False
+            else:
+                assert detector.get_per_printer() == {}
+
+    async def test_cancelled_request_preserves_deadline_on_key_change(self, detector, http_client, manager, clock):
+        processing = asyncio.Event()
+
+        async def respond(url, **kwargs):
+            if url == CREATE_CONTEXT_URL:
+                return httpx.Response(200, json=context_payload())
+            processing.set()
+            await asyncio.Event().wait()
+
+        http_client.post.side_effect = respond
+        poll = asyncio.create_task(poll_once(detector, settings()))
+        await asyncio.wait_for(processing.wait(), timeout=1)
+        clock.return_value = 120
+        detector._load_settings = AsyncMock(return_value=settings(api_key="replacement-key"))
+        await detector.refresh_settings()
+        await asyncio.wait_for(poll, timeout=1)
+        assert detector._states[1].next_check_at == 140
+        assert detector._states[1].context is None
+        await poll_once(detector, settings(api_key="replacement-key"))
+        assert http_client.post.await_count == 2
+
+    async def test_interval_edit_during_request_preserves_server_minimum(self, detector, http_client, manager, clock):
+        processing = asyncio.Event()
+
+        async def pending_response(url, **kwargs):
+            processing.set()
+            await asyncio.Event().wait()
+
+        http_client.post.side_effect = [
+            httpx.Response(200, json=context_payload()),
+            httpx.Response(200, json=process_payload(NextProcessIntervalSec={"Minimum": 60, "Recommended": 20})),
+        ]
+        await poll_once(detector, settings(poll_interval=30))
+        http_client.post.side_effect = pending_response
+        clock.return_value = 160
+        poll = asyncio.create_task(poll_once(detector, settings(poll_interval=30)))
+        await asyncio.wait_for(processing.wait(), timeout=1)
+        clock.return_value = 165
+        detector._load_settings = AsyncMock(return_value=settings(poll_interval=5))
+        await detector.refresh_settings()
+        await asyncio.wait_for(poll, timeout=1)
+        assert detector._states[1].next_check_at == 225
+        clock.return_value = 224
+        await poll_once(detector, settings(poll_interval=5))
+        assert http_client.post.await_count == 3
+
+
+class TestSettings:
+    @pytest.mark.parametrize(
+        "raw, expected", [("", None), ("[1, 3]", {1, 3}), ("[]", set()), ("bad", set()), ("{}", set())]
+    )
+    async def test_loads_settings_and_printer_selection(self, detector, raw, expected):
+        rows = [
+            SimpleNamespace(key="octoeverywhere_enabled", value="true"),
+            SimpleNamespace(key="octoeverywhere_api_key", value="  secret-api-key  "),
+            SimpleNamespace(key="octoeverywhere_enabled_printers", value=raw),
+        ]
+        db = AsyncMock()
+        result = MagicMock()
+        result.scalars.return_value.all.return_value = rows
+        db.execute.return_value = result
+        with patch(f"{MODULE}.async_session") as session:
+            session.return_value.__aenter__.return_value = db
+            loaded = await detector._load_settings()
+
+        assert loaded == settings(enabled_printers=expected)
+
+    @pytest.mark.parametrize(
+        "raw, expected",
+        [(None, 20), ("", 20), ("null", 20), ("5", 5), ("20", 20), ("21", 21), ("30", 30), ("4", 20), ("31", 20)],
+    )
+    async def test_loads_inspection_interval(self, detector, raw, expected):
+        db = AsyncMock()
+        result = MagicMock()
+        result.scalars.return_value.all.return_value = [SimpleNamespace(key="octoeverywhere_poll_interval", value=raw)]
+        db.execute.return_value = result
+        with patch(f"{MODULE}.async_session") as session:
+            session.return_value.__aenter__.return_value = db
+            loaded = await detector._load_settings()
+        assert loaded["poll_interval"] == expected

+ 47 - 0
backend/tests/unit/test_octoeverywhere_images.py

@@ -0,0 +1,47 @@
+"""Oversized Gadget snapshots are reduced without changing ordinary frames."""
+
+from io import BytesIO
+
+import pytest
+from PIL import Image
+
+from backend.app.services.octoeverywhere_images import INVALID_IMAGE_MESSAGE, MAX_IMAGE_BYTES, prepare_frame
+
+
+@pytest.fixture(scope="module")
+def oversized_jpeg():
+    with Image.effect_noise((3072, 2304), 100).convert("RGB") as image:
+        output = BytesIO()
+        exif = Image.Exif()
+        exif[274] = 6  # The camera image should be displayed rotated clockwise.
+        image.save(output, format="JPEG", quality=100, subsampling=0, exif=exif)
+        frame = output.getvalue()
+    assert len(frame) > MAX_IMAGE_BYTES
+    return frame
+
+
+@pytest.mark.parametrize("size", [100, MAX_IMAGE_BYTES])
+def test_normal_frames_pass_through_without_decoding(size):
+    # Deliberately not a valid JPEG: the size fast path must not decode it.
+    frame = b"x" * size
+    assert prepare_frame(frame) is frame
+
+
+def test_oversized_jpeg_is_valid_bounded_and_preserves_orientation(oversized_jpeg):
+    prepared = prepare_frame(oversized_jpeg)
+    assert 0 < len(prepared) <= MAX_IMAGE_BYTES
+    with Image.open(BytesIO(prepared)) as image:
+        image.load()
+        assert image.format == "JPEG"
+        assert image.mode == "RGB"
+        assert image.width < image.height
+        assert max(image.size) <= 2048
+        assert image.getexif().get(274, 1) == 1
+
+
+def test_invalid_oversized_frame_returns_safe_error():
+    frame = b"private-camera-data" * (MAX_IMAGE_BYTES // len(b"private-camera-data") + 1)
+    with pytest.raises(ValueError, match=f"^{INVALID_IMAGE_MESSAGE}$") as error:
+        prepare_frame(frame)
+    assert "private-camera-data" not in str(error.value)
+    assert error.value.__cause__ is None

+ 89 - 0
docs/octoeverywhere.md

@@ -0,0 +1,89 @@
+# OctoEverywhere AI Failure Detection
+
+Bambuddy can monitor running 3D prints with [OctoEverywhere's cloud AI failure detection.](https://docs.octoeverywhere.com/ai-failure-detection-apis/overview/) After you add the OctoEverywhere API key, the system will automatically monitor all your 3D printers (unless you configure it otherwise). It can send notifications or automatically pause the print when it detects a failure.
+
+## Privacy
+Camera snapshots are sent to OctoEverywhere for analysis and deleted immediately after analysis completes. OctoEverywhere does not store snapshots, nor does it use them for AI training. See the OctoEverywhere [privacy policy for details.](https://octoeverywhere.com/privacy#gadget-developer-api)
+
+## Setup
+
+1. [Set up an OctoEverywhere Gadget API key](https://octoeverywhere.com/gadgetapi) in your OctoEverywhere account.
+2. Open **Settings → Failure Detection** and choose **OctoEverywhere** under **Provider**.
+3. Turn on **OctoEverywhere AI Detection**, paste the key into **Gadget API key**, and click **Test**.
+4. Confirm **OctoEverywhere Gadget API key verification successful!** appears. Monitoring starts automatically for connected printers while they are printing.
+5. To receive alerts, configure a notification provider under **Settings → Notifications** and enable its **AI Failure Detection** event. Check that its printer filters include the printers you want to monitor.
+
+If notifications are missing or do not cover every monitored printer, the settings page shows an alert with a **Configure notifications** link for users with notification access. This checks the configured event subscription and printer filters; use the notification provider's own test to check delivery.
+
+## Detection settings
+
+| Setting | Behavior |
+|---------|----------|
+| **Confidence** | How sure OctoEverywhere must be before it suggests a warning or pause. Choose **Lowest**, **Low**, **Medium** (the default), **High**, or **Highest**. Lower confidence reports possible failures sooner but may have more false positives; higher confidence takes longer to report but is more certain before notifying you or pausing the print. |
+| **Action on detected failure** | **Notify only** is the default. **Pause print** sends a pause command when OctoEverywhere suggests pausing. **Pause and cut power** also turns off enabled smart plugs linked to that printer. |
+| **Monitored Printers** | **Monitor all connected printers** is selected by default. Clear it to choose individual printers. |
+| **Inspection interval** | **20 seconds** by default. Choose a fixed interval from **5 to 30 seconds** with the slider. |
+
+Warnings send at most one notification per monitored print session. If OctoEverywhere later suggests pausing, **Pause print** or **Pause and cut power** runs once and sends its own notification, so you know the printer stopped. With **Notify only**, no second notification is sent and the printer continues printing after a warning or failure suggestion.
+
+## Status and print quality
+
+The **Status** and **Recent Detections** cards show monitored prints and their latest results. **Monitoring** distinguishes disabled detection, a missing key, an unavailable service, waiting for the first result, available results, and errors that need attention. A running service by itself does not confirm that a print has been checked. The printer card's AI badge opens the detection details.
+
+| Badge | Meaning |
+|-------|---------|
+| **Starting** | The first usable result has not arrived yet. |
+| **Safe** | The latest successful check did not suggest a warning or pause. |
+| **Warning** | OctoEverywhere suggested warning about a possible issue. |
+| **Failure** | OctoEverywhere suggested pausing the print. |
+| **Not checking** | A camera, connection, or API problem prevented a result. Open the details or Status card for the reason. |
+
+**Print quality** ranges from **1/10 to 10/10**, with higher values indicating better quality. It is not a failure probability. Bambuddy bases warnings and actions on OctoEverywhere's warning/pause suggestions, so quality alone does not trigger an action. [Process API](https://docs.octoeverywhere.com/ai-failure-detection-apis/process/#printquality)
+
+## Troubleshooting
+
+- **Key rejected:** Check that the saved OctoEverywhere Gadget API key is correct, then click **Test**.
+- **Key disabled:** [Contact OctoEverywhere support](https://octoeverywhere.com/support) to restore access, then click **Test**.
+- **Usage limit reached:** When the API returns `OE_FREE_USAGE_LIMIT_REACHED`, Bambuddy shows “Usage limit reached.” with a [Set up billing to continue](https://octoeverywhere.com/gadgetapi) link. Wait for the next monthly allowance, or optionally set up billing and turn off **Free Usage Only** on the account page. If billing is already configured, only the **Free Usage Only** setting needs changing. After the allowance renews or the account settings are updated, click **Test** to resume inspections.
+- **IP restricted:** Use the original Gadget API key associated with the public IP address, or [contact OctoEverywhere support](https://octoeverywhere.com/support) if it is unavailable or the IP is shared with another account. Changing billing settings or creating another key does not remove the restriction. Click **Test** after resolving access.
+- **Account access problem:** Check the OctoEverywhere API account status and resolve the reported issue, then click **Test**.
+- **Camera capture failed:** Verify that the printer's camera works in Bambuddy and that the printer is connected. Detection uses the configured built-in or external camera.
+- **Connection failure:** Check Bambuddy's internet access, DNS, and outbound HTTPS access to OctoEverywhere. Temporary service failures retry automatically; repeated failures delay subsequent retries.
+- **Checks slower than the selected interval:** Camera capture, processing time, the server's minimum interval, rate limits, and error retry delays can lengthen the interval. The selected interval never overrides the server's minimum or retry timing.
+- **False alarms or late alerts:** Raise **Confidence** if healthy prints trigger warnings; lower it to report possible failures sooner.
+- **No active prints:** Check the enable toggle, monitored-printer selection, printer connection, and that the printer is actively printing.
+- **No notification:** Enable the **AI Failure Detection** event on a working notification provider and check its printer filters. Detection does not enable notification subscriptions automatically.
+
+Key, account, IP restriction, and usage-limit errors stop inspections across all monitored printers until access is restored and **Test** succeeds with the configured key, or a replacement key is saved. These errors do not pause the printers. **Test** verifies access by creating a context; it does not upload an image or verify the remaining inspection allowance. A successful test resumes inspections, but the next inspection can stop monitoring again if the allowance is still exhausted. Creating another context or switching processing URLs does not reset the allowance.
+
+A successful **Test** with the same key preserves existing monitoring contexts. Saving a replacement key creates new contexts while preserving notification/action tracking and the current inspection timing.
+
+The upstream [error-handling reference](https://docs.octoeverywhere.com/ai-failure-detection-apis/developer-docs/overview/#error-handling) explains API errors.
+
+### Inspection debug logs
+
+Each inspection logs its start and whether it reuses a context. Successful results include the printer ID, frame count, verdict, print quality, warning/pause suggestions, faster-inspection flag, server minimum, and effective interval until the next check. Failed attempts log a safe error message, recognized error code, and retry delay when another attempt is scheduled; canceled or discarded inspections are also recorded. Scheduled polls that are not yet due do not produce inspection logs. These diagnostics omit API keys, context IDs/URLs, image data, and raw API response bodies.
+
+## Bambuddy API reference
+
+The settings API uses these keys:
+
+| Key | Default / values |
+|-----|------------------|
+| `octoeverywhere_enabled` | `false` |
+| `octoeverywhere_api_key` | Write-only Gadget API key. Omit it from a settings update to preserve the saved key; send an empty string to clear it. Always empty in settings responses. |
+| `octoeverywhere_api_key_configured` | Read-only boolean indicating whether a key is saved; defaults to `false` |
+| `octoeverywhere_poll_interval` | `20` by default; an integer from `5` to `30` seconds; normally clamped to the server minimum, temporarily shortened to its recommended interval when `FasterInspectionSuggested` is true |
+| `octoeverywhere_confidence` | `medium`; accepts `lowest`, `low`, `medium`, `high`, `highest`, sent as API confidence levels 1, 2, 3, 4, 5 respectively for both `WarningConfidenceLevel` and `PauseConfidenceLevel` |
+| `octoeverywhere_action` | `notify`; accepts `notify`, `pause`, `pause_and_off` |
+| `octoeverywhere_enabled_printers` | Empty string for all printers, or a JSON-encoded array of printer IDs; `[]` monitors none |
+
+| Endpoint | Purpose | Permission when authentication is enabled |
+|----------|---------|-------------------------------------------|
+| `GET /api/v1/octoeverywhere/status` | Service status, `last_error_code`, `api_key_configured`, `poll_interval`, per-printer quality, recent history, and notification coverage | `settings:read` |
+| `GET /api/v1/octoeverywhere/printer-status` | Printer-card monitoring state; error messages and codes require `settings:read` | `printers:read` |
+| `POST /api/v1/octoeverywhere/test-connection` | Test key/account eligibility using optional `api_key` and `confidence` fields, falling back to saved settings when omitted; a successful test with the configured key resumes blocked inspections, but does not verify the remaining allowance; does not update settings or upload an image | `settings:update` |
+
+Notification providers use the existing `on_ai_failure_detection` event subscription. The status response's `notifications.configured` indicates that an enabled provider subscribes to this event, and `notifications.uncovered_printers` lists monitored active printer IDs without a matching subscription.
+
+Each printer status and Test result includes a nullable `error_code`. Status responses also include `last_error_code` alongside `last_error`. These codes identify known API errors without exposing remote response details.

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

@@ -125,7 +125,7 @@ function isAlwaysAllowedIdentical(value) {
   if (/^https?:\/\//.test(value)) return true;          // URL
   if (/^ON,\s+true,\s+1$/.test(value)) return true;     // literal example "ON, true, 1"
   // Brand / technical names that ship verbatim everywhere.
-  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Manyfold|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|SHA-256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Notify!|Pushover|Bark|Gotify|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Smooth PEI Plate \/ High Temp Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
+  if (/^(Bambuddy|BamBuddy|Obico|OctoEverywhere|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Manyfold|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|SHA-256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Notify!|Pushover|Bark|Gotify|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Smooth PEI Plate \/ High Temp Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
   return false;
 }
 

+ 82 - 0
frontend/src/__tests__/components/AiDetectionModal.test.tsx

@@ -0,0 +1,82 @@
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { AiDetectionModal } from '../../components/AiDetectionModal';
+import { setAuthToken } from '../../api/client';
+
+const limitDetection = {
+  class: 'error',
+  frame_count: 0,
+  print_quality: null,
+  error: 'Remote quota error',
+  error_code: 'OE_FREE_USAGE_LIMIT_REACHED',
+};
+
+afterEach(() => setAuthToken(null));
+
+describe('AiDetectionModal usage limit', () => {
+  it('uses a structured printer error code and clears the notice when that printer recovers', async () => {
+    const { rerender } = render(<AiDetectionModal printerName="X1 Carbon" detection={limitDetection} lastError={null} onClose={vi.fn()} />);
+
+    expect(await screen.findByText('Usage limit reached.')).toBeInTheDocument();
+    expect(screen.getByRole('link', { name: 'Set up billing to continue' })).toHaveAttribute('href', 'https://octoeverywhere.com/gadgetapi');
+    expect(screen.queryByText('Remote quota error')).not.toBeInTheDocument();
+
+    rerender(<AiDetectionModal printerName="X1 Carbon" detection={{ class: 'safe', frame_count: 1, print_quality: 10, error: null, error_code: null }} lastError={null} onClose={vi.fn()} />);
+    expect(screen.queryByText('Usage limit reached.')).not.toBeInTheDocument();
+    expect(screen.queryByRole('link', { name: /set up billing/i })).not.toBeInTheDocument();
+    expect(screen.getByText('10/10')).toBeInTheDocument();
+  });
+
+  it('uses the global reason and code together when this printer has no error', async () => {
+    render(<AiDetectionModal printerName="X1 Carbon" lastError="Remote global error" lastErrorCode="OE_FREE_USAGE_LIMIT_REACHED" onClose={vi.fn()} />);
+
+    expect(await screen.findByText('Usage limit reached.')).toBeInTheDocument();
+    expect(screen.getByRole('link', { name: 'Set up billing to continue' })).toBeInTheDocument();
+    expect(screen.queryByText('Remote global error')).not.toBeInTheDocument();
+  });
+
+  it('does not attach another printer’s usage-limit code to this printer’s camera error', async () => {
+    render(<AiDetectionModal
+      printerName="X1 Carbon"
+      detection={{ ...limitDetection, error: 'Camera unavailable', error_code: null }}
+      lastError="Another printer reached its usage limit"
+      lastErrorCode="OE_FREE_USAGE_LIMIT_REACHED"
+      onClose={vi.fn()}
+    />);
+
+    expect(await screen.findByText('Camera unavailable')).toBeInTheDocument();
+    expect(screen.queryByText('Usage limit reached.')).not.toBeInTheDocument();
+    expect(screen.queryByRole('link', { name: /set up billing/i })).not.toBeInTheDocument();
+  });
+
+  it('does not infer a usage-limit error from unstructured text', async () => {
+    render(<AiDetectionModal printerName="X1 Carbon" lastError="OE_FREE_USAGE_LIMIT_REACHED" onClose={vi.fn()} />);
+
+    expect(await screen.findByText('OE_FREE_USAGE_LIMIT_REACHED')).toBeInTheDocument();
+    expect(screen.queryByRole('link', { name: /set up billing/i })).not.toBeInTheDocument();
+  });
+
+  it.each([
+    [[], false],
+    [['settings:read'], true],
+  ])('respects settings:read permission for quota details and the billing setup link (%j)', async (permissions, canRead) => {
+    setAuthToken('test-token', 'session');
+    server.use(
+      http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+      http.get('*/api/v1/auth/me', () => HttpResponse.json({ id: 2, username: 'viewer', is_admin: false, permissions })),
+    );
+    render(<AiDetectionModal printerName="X1 Carbon" detection={limitDetection} lastError={null} onClose={vi.fn()} />);
+
+    if (canRead) {
+      expect(await screen.findByText('Usage limit reached.')).toBeInTheDocument();
+      expect(screen.getByRole('link', { name: /set up billing/i })).toBeInTheDocument();
+    } else {
+      await waitFor(() => expect(screen.queryByText('Usage limit reached.')).not.toBeInTheDocument());
+      expect(screen.queryByRole('link', { name: /set up billing/i })).not.toBeInTheDocument();
+    }
+    expect(screen.getByText('Not checking')).toBeInTheDocument();
+  });
+});

+ 485 - 0
frontend/src/__tests__/components/OctoEverywhereSettings.test.tsx

@@ -0,0 +1,485 @@
+/** Tests for provider selection and OctoEverywhere failure detection settings. */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor, fireEvent, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { FailureDetectionSettings } from '../../components/FailureDetectionSettings';
+import { setAuthToken } from '../../api/client';
+
+const baseSettings = {
+  obico_enabled: false,
+  obico_ml_url: '',
+  obico_ml_token: '',
+  obico_sensitivity: 'medium',
+  obico_action: 'notify',
+  obico_poll_interval: 10,
+  obico_enabled_printers: '',
+  octoeverywhere_enabled: true,
+  octoeverywhere_api_key: '',
+  octoeverywhere_api_key_configured: true,
+  octoeverywhere_confidence: 'medium',
+  octoeverywhere_action: 'notify',
+  octoeverywhere_enabled_printers: '',
+  octoeverywhere_poll_interval: 20,
+};
+
+const baseStatus = {
+  enabled: true,
+  api_key_configured: true,
+  confidence: 'medium',
+  action: 'notify',
+  poll_interval: 20,
+  is_running: true,
+  last_error: null,
+  last_error_code: null,
+  per_printer: {},
+  history: [],
+  notifications: { configured: true, uncovered_printers: [] },
+};
+
+async function waitForForm() {
+  const apiKey = await screen.findByLabelText('Gadget API key');
+  await waitFor(() => expect(apiKey).not.toBeDisabled());
+  return apiKey;
+}
+
+describe('OctoEverywhere settings', () => {
+  let savedSettings: Record<string, unknown>;
+  let updates: Record<string, unknown>[];
+  let storedApiKey: string;
+
+  const saveSettings = (update: Record<string, unknown>) => {
+    updates.push(update);
+    if (typeof update.octoeverywhere_api_key === 'string') storedApiKey = update.octoeverywhere_api_key;
+    savedSettings = {
+      ...savedSettings,
+      ...update,
+      octoeverywhere_api_key: '',
+      octoeverywhere_api_key_configured: !!storedApiKey,
+    };
+    if (update.octoeverywhere_enabled) savedSettings.obico_enabled = false;
+    return savedSettings;
+  };
+
+  beforeEach(() => {
+    savedSettings = { ...baseSettings };
+    updates = [];
+    storedApiKey = 'saved-api-key';
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(savedSettings)),
+      http.put('/api/v1/settings/', async ({ request }) => {
+        const update = await request.json() as Record<string, unknown>;
+        return HttpResponse.json(saveSettings(update));
+      }),
+      http.get('/api/v1/octoeverywhere/status', () => HttpResponse.json(baseStatus)),
+      http.get('/api/v1/printers/', () => HttpResponse.json([
+        { id: 1, name: 'X1 Carbon' },
+        { id: 2, name: 'P1S' },
+      ])),
+    );
+  });
+
+  afterEach(() => setAuthToken(null));
+
+  it('opens the enabled provider without returning the saved key to the input', async () => {
+    render(<FailureDetectionSettings />);
+
+    const apiKey = await waitForForm();
+    expect(apiKey).toHaveAttribute('type', 'password');
+    expect(apiKey).toHaveValue('');
+    expect(apiKey).toHaveAttribute('placeholder', 'Leave empty to keep current');
+    expect(screen.queryByDisplayValue('saved-api-key')).not.toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Test' })).not.toBeDisabled();
+    expect(screen.getByLabelText('Provider')).toHaveValue('octoeverywhere');
+    expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true');
+    expect(screen.queryByText('Obico ML API URL')).not.toBeInTheDocument();
+    expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
+    expect(screen.getByRole('slider', { name: 'Inspection interval' })).toHaveValue('20');
+    const confidence = screen.getByLabelText('Confidence');
+    expect(confidence).toHaveValue('medium');
+    expect(within(confidence).getAllByRole('option').map((option) => option.textContent)).toEqual([
+      'Lowest (reports sooner, more false positives)',
+      'Low',
+      'Medium (balanced)',
+      'High',
+      'Highest (reports later, fewer false positives)',
+    ]);
+    expect(screen.getByText(/Lower confidence reports issues sooner/)).toBeInTheDocument();
+    expect(screen.queryByText(/External URL is not configured/i)).not.toBeInTheDocument();
+  });
+
+  it('keeps credential and monitoring controls read-only without settings:update permission', async () => {
+    setAuthToken('test-token', 'session');
+    server.use(
+      http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+      http.get('*/api/v1/auth/me', () => HttpResponse.json({
+        id: 2,
+        username: 'viewer',
+        is_admin: false,
+        permissions: ['settings:read', 'printers:read'],
+      })),
+    );
+    render(<FailureDetectionSettings />);
+
+    expect(await screen.findByLabelText('Gadget API key')).toBeDisabled();
+    expect(screen.getByRole('switch')).toBeDisabled();
+    expect(screen.getByRole('button', { name: 'Test' })).toBeDisabled();
+    expect(screen.getByRole('slider', { name: 'Inspection interval' })).toBeDisabled();
+    expect(updates).toHaveLength(0);
+  });
+
+  it('lets an Obico user enter a key before enabling OctoEverywhere', async () => {
+    savedSettings = {
+      ...baseSettings,
+      obico_enabled: true,
+      obico_ml_url: 'http://obico:3333',
+      octoeverywhere_enabled: false,
+      octoeverywhere_api_key: '',
+      octoeverywhere_api_key_configured: false,
+    };
+    storedApiKey = '';
+    render(<FailureDetectionSettings />);
+    const user = userEvent.setup();
+    await screen.findByDisplayValue('http://obico:3333');
+    await user.selectOptions(screen.getByLabelText('Provider'), 'octoeverywhere');
+    const apiKey = await screen.findByLabelText('Gadget API key');
+    await waitFor(() => expect(apiKey).not.toBeDisabled());
+    await user.type(apiKey, 'new-api-key');
+    await user.click(screen.getByRole('switch'));
+
+    await waitFor(() => expect(updates.at(-1)).toMatchObject({
+      octoeverywhere_enabled: true,
+      octoeverywhere_api_key: 'new-api-key',
+      octoeverywhere_confidence: 'medium',
+      octoeverywhere_action: 'notify',
+      octoeverywhere_enabled_printers: '',
+    }));
+    expect(savedSettings.obico_enabled).toBe(false);
+  });
+
+  it('keeps the OctoEverywhere panel selected after disabling detection', async () => {
+    render(<FailureDetectionSettings />);
+    await waitForForm();
+    await userEvent.click(screen.getByRole('switch'));
+
+    await waitFor(() => expect(savedSettings.octoeverywhere_enabled).toBe(false));
+    expect(screen.getByLabelText('Provider')).toHaveValue('octoeverywhere');
+    expect(screen.getByLabelText('Gadget API key')).toHaveValue('');
+    expect(updates.at(-1)).not.toHaveProperty('octoeverywhere_api_key');
+    expect(storedApiKey).toBe('saved-api-key');
+  });
+
+  it('tests a saved key without retrieving or resending it', async () => {
+    let tested: unknown;
+    server.use(
+      http.post('/api/v1/octoeverywhere/test-connection', async ({ request }) => {
+        tested = await request.json();
+        return HttpResponse.json({ ok: true, status_code: 200, error: null });
+      }),
+    );
+    render(<FailureDetectionSettings />);
+    await waitForForm();
+    await userEvent.click(screen.getByRole('button', { name: 'Test' }));
+
+    await screen.findByText('OctoEverywhere Gadget API key verification successful!');
+    expect(tested).toEqual({ confidence: 'medium' });
+    expect(updates).toHaveLength(0);
+    expect(storedApiKey).toBe('saved-api-key');
+  });
+
+  it('preserves the saved key when a replacement edit is cleared before saving', async () => {
+    render(<FailureDetectionSettings />);
+    const apiKey = await waitForForm();
+    const user = userEvent.setup();
+    await user.type(apiKey, 'unsaved-replacement');
+    await user.clear(apiKey);
+    await user.selectOptions(screen.getByLabelText('Confidence'), 'high');
+
+    await waitFor(() => expect(updates.at(-1)?.octoeverywhere_confidence).toBe('high'));
+    expect(updates.every((update) => !('octoeverywhere_api_key' in update))).toBe(true);
+    expect(storedApiKey).toBe('saved-api-key');
+  });
+
+  it('does not save a partially typed replacement while the key field is focused', async () => {
+    render(<FailureDetectionSettings />);
+    const apiKey = await waitForForm();
+    const user = userEvent.setup({ delay: 550 });
+    await user.type(apiKey, 'new');
+    expect(apiKey).toHaveValue('new');
+    expect(updates).toHaveLength(0);
+    await user.tab();
+
+    await screen.findByText('Settings saved');
+    expect(storedApiKey).toBe('new');
+    expect(apiKey).toHaveValue('new');
+  }, 10000);
+
+  it('keeps a pasted key in the field after it auto-saves until the page is reopened', async () => {
+    let tested = false;
+    server.use(
+      http.post('/api/v1/octoeverywhere/test-connection', () => {
+        tested = true;
+        return HttpResponse.json({ ok: true, status_code: 200, error: null });
+      }),
+    );
+    const { unmount } = render(<FailureDetectionSettings />);
+    const apiKey = await waitForForm();
+    const user = userEvent.setup();
+    await user.click(apiKey);
+    await user.paste('pasted-api-key');
+    await user.click(document.body);
+
+    await screen.findByText('Settings saved');
+    expect(storedApiKey).toBe('pasted-api-key');
+    expect(apiKey).toHaveValue('pasted-api-key');
+
+    // The saved key is not an unsaved change, so testing it doesn't save again.
+    await user.click(screen.getByRole('button', { name: 'Test' }));
+    await screen.findByText('OctoEverywhere Gadget API key verification successful!');
+    expect(tested).toBe(true);
+    expect(updates).toHaveLength(1);
+    expect(apiKey).toHaveValue('pasted-api-key');
+
+    unmount();
+    render(<FailureDetectionSettings />);
+    const reopened = await waitForForm();
+    expect(reopened).toHaveValue('');
+    expect(reopened).toHaveAttribute('placeholder', 'Leave empty to keep current');
+  });
+
+  it('updates the 5–30 second inspection interval immediately and persists its value', async () => {
+    const { unmount } = render(<FailureDetectionSettings />);
+    await waitForForm();
+    const interval = screen.getByRole('slider', { name: 'Inspection interval' });
+    expect(interval).toHaveAttribute('min', '5');
+    expect(interval).toHaveAttribute('max', '30');
+    expect(interval).toHaveAttribute('step', '1');
+    expect(interval).toHaveValue('20');
+    expect(screen.getByText('20 seconds')).toBeInTheDocument();
+
+    fireEvent.change(interval, { target: { value: '5' } });
+    expect(interval).toHaveAttribute('aria-valuetext', '5 seconds');
+    expect(screen.getByText('5 seconds')).toBeInTheDocument();
+    expect(updates).toHaveLength(0); // The value changes before the debounced save.
+    await waitFor(() => expect(updates.at(-1)?.octoeverywhere_poll_interval).toBe(5));
+
+    await waitFor(() => expect(interval).not.toBeDisabled());
+    fireEvent.change(interval, { target: { value: '30' } });
+    expect(screen.getByText('30 seconds')).toBeInTheDocument();
+    await waitFor(() => expect(updates.at(-1)?.octoeverywhere_poll_interval).toBe(30));
+    expect(updates.every((update) => !('octoeverywhere_api_key' in update))).toBe(true);
+
+    unmount();
+    render(<FailureDetectionSettings />);
+    await waitForForm();
+    expect(screen.getByRole('slider', { name: 'Inspection interval' })).toHaveValue('30');
+    expect(screen.getByText('30 seconds')).toBeInTheDocument();
+  });
+
+  it('keeps setup focused on detection without pricing, billing, or usage limits', async () => {
+    const { container } = render(<FailureDetectionSettings />);
+    await waitForForm();
+
+    expect(screen.getByRole('link', { name: 'Get your Gadget API key' })).toHaveAttribute('href', 'https://octoeverywhere.com/gadgetapi');
+    expect(screen.getByRole('link', { name: 'Privacy policy' })).toHaveAttribute('href', 'https://octoeverywhere.com/privacy#gadget-developer-api');
+    expect(container).not.toHaveTextContent(/printer.hours|allowance|charges|paid usage|calls per hour|pricing|billing|usage limit|account limit/i);
+    expect(screen.queryByRole('link', { name: /set up billing/i })).not.toBeInTheDocument();
+  });
+
+  it('shows the localized usage-limit notice after a test and clears it after a successful retry', async () => {
+    let limitReached = true;
+    server.use(
+      http.post('/api/v1/octoeverywhere/test-connection', () => HttpResponse.json(limitReached
+        ? { ok: false, status_code: 402, error: 'Remote error text', error_code: 'OE_FREE_USAGE_LIMIT_REACHED' }
+        : { ok: true, status_code: 200, error: null, error_code: null })),
+    );
+    render(<FailureDetectionSettings />);
+    await waitForForm();
+    const user = userEvent.setup();
+    await user.click(screen.getByRole('button', { name: 'Test' }));
+
+    expect(await screen.findByText('Usage limit reached.')).toBeInTheDocument();
+    expect(screen.getByRole('link', { name: 'Set up billing to continue' })).toHaveAttribute('href', 'https://octoeverywhere.com/gadgetapi');
+    expect(screen.queryByText('Remote error text')).not.toBeInTheDocument();
+
+    limitReached = false;
+    await user.click(screen.getByRole('button', { name: 'Test' }));
+    await screen.findByText('OctoEverywhere Gadget API key verification successful!');
+    expect(screen.queryByRole('link', { name: /set up billing/i })).not.toBeInTheDocument();
+    expect(screen.queryByText('Usage limit reached.')).not.toBeInTheDocument();
+  });
+
+  it.each(['service', 'printer'])('localizes a %s usage-limit code and removes the notice after recovery', async (source) => {
+    let limitReached = true;
+    server.use(
+      http.get('/api/v1/octoeverywhere/status', () => HttpResponse.json({
+        ...baseStatus,
+        ...(limitReached && source === 'service' ? {
+          last_error: 'Remote service error',
+          last_error_code: 'OE_FREE_USAGE_LIMIT_REACHED',
+        } : {}),
+        ...(limitReached && source === 'printer' ? {
+          per_printer: {
+            '1': { class: 'error', print_quality: null, frame_count: 0, error: 'Remote printer error', error_code: 'OE_FREE_USAGE_LIMIT_REACHED' },
+          },
+        } : {}),
+      })),
+    );
+    render(<FailureDetectionSettings />);
+
+    expect(await screen.findByText('Usage limit reached.')).toBeInTheDocument();
+    expect(screen.getByRole('link', { name: 'Set up billing to continue' })).toHaveAttribute('href', 'https://octoeverywhere.com/gadgetapi');
+    expect(screen.queryByText(/Remote (service|printer) error/)).not.toBeInTheDocument();
+
+    limitReached = false;
+    await userEvent.click(screen.getByRole('button', { name: 'Refresh' }));
+    await waitFor(() => expect(screen.queryByText('Usage limit reached.')).not.toBeInTheDocument());
+    expect(screen.queryByRole('link', { name: /set up billing/i })).not.toBeInTheDocument();
+  });
+
+  it.each(['lowest', 'highest'] as const)('saves edited credentials and %s confidence before testing', async (confidence) => {
+    const calls: string[] = [];
+    let tested: unknown;
+    server.use(
+      http.put('/api/v1/settings/', async ({ request }) => {
+        calls.push('save');
+        return HttpResponse.json(saveSettings(await request.json() as Record<string, unknown>));
+      }),
+      http.post('/api/v1/octoeverywhere/test-connection', async ({ request }) => {
+        calls.push('test');
+        tested = await request.json();
+        return HttpResponse.json({ ok: true, status_code: 200, error: null });
+      }),
+    );
+    render(<FailureDetectionSettings />);
+    const user = userEvent.setup();
+    const apiKey = await waitForForm();
+    await user.clear(apiKey);
+    await user.type(apiKey, 'replacement-key');
+    await user.selectOptions(screen.getByLabelText('Confidence'), confidence);
+    await user.click(screen.getByRole('button', { name: 'Test' }));
+
+    expect(await screen.findByText('OctoEverywhere Gadget API key verification successful!')).toBeInTheDocument();
+    expect(calls[0]).toBe('save');
+    expect(calls.indexOf('test')).toBeGreaterThan(calls.indexOf('save'));
+    expect(tested).toEqual({ confidence });
+    expect(savedSettings.octoeverywhere_confidence).toBe(confidence);
+    expect(storedApiKey).toBe('replacement-key');
+    expect(apiKey).toHaveValue('replacement-key');
+  });
+
+  it('does not report a successful connection when settings could not be saved', async () => {
+    let tested = false;
+    server.use(
+      http.put('/api/v1/settings/', () => HttpResponse.json({ detail: 'Cannot save settings' }, { status: 500 })),
+      http.post('/api/v1/octoeverywhere/test-connection', () => {
+        tested = true;
+        return HttpResponse.json({ ok: true, status_code: 200, error: null });
+      }),
+    );
+    render(<FailureDetectionSettings />);
+    const user = userEvent.setup();
+    const apiKey = await waitForForm();
+    await user.type(apiKey, '-changed');
+    await user.click(screen.getByRole('button', { name: 'Test' }));
+
+    expect((await screen.findAllByText('Cannot save settings')).length).toBeGreaterThan(0);
+    expect(tested).toBe(false);
+    expect(screen.queryByText('OctoEverywhere Gadget API key verification successful!')).not.toBeInTheDocument();
+  });
+
+  it('shows rejected credentials without changing the saved key', async () => {
+    server.use(
+      http.post('/api/v1/octoeverywhere/test-connection', () => HttpResponse.json({
+        ok: false,
+        status_code: 401,
+        error: 'OctoEverywhere rejected the Gadget API key.',
+      })),
+    );
+    render(<FailureDetectionSettings />);
+    const user = userEvent.setup();
+    await waitForForm();
+    await user.click(screen.getByRole('button', { name: 'Test' }));
+    expect(await screen.findByText('OctoEverywhere rejected the Gadget API key.')).toBeInTheDocument();
+
+    expect(updates).toHaveLength(0);
+    expect(storedApiKey).toBe('saved-api-key');
+  });
+
+  it('persists printer subsets and represents monitor-all as an empty string', async () => {
+    render(<FailureDetectionSettings />);
+    const user = userEvent.setup();
+    await waitForForm();
+    await user.click(screen.getByRole('checkbox', { name: /monitor all/i }));
+    await user.click(screen.getByRole('checkbox', { name: 'P1S' }));
+
+    await waitFor(() => expect(updates.at(-1)?.octoeverywhere_enabled_printers).toBe('[1]'));
+    await user.click(screen.getByRole('checkbox', { name: /monitor all/i }));
+    await waitFor(() => expect(updates.at(-1)?.octoeverywhere_enabled_printers).toBe(''));
+  });
+
+  it('displays quality out of ten and keeps camera failures distinct from safe verdicts', async () => {
+    server.use(
+      http.get('/api/v1/octoeverywhere/status', () => HttpResponse.json({
+        ...baseStatus,
+        per_printer: {
+          '1': { class: 'safe', print_quality: 9, frame_count: 20, error: null },
+          '2': { class: 'error', print_quality: null, frame_count: 0, error: 'Camera unavailable' },
+        },
+        history: [{ timestamp: '2026-09-15T10:00:00Z', printer_id: 1, class: 'warning', print_quality: 5 }],
+      })),
+    );
+    render(<FailureDetectionSettings />);
+
+    expect(await screen.findByText('Print quality: 9/10')).toBeInTheDocument();
+    expect(screen.getByText('Warning 5/10')).toBeInTheDocument();
+    expect(screen.getByText('Not checking')).toBeInTheDocument();
+    expect(screen.getByText('Camera unavailable')).toBeInTheDocument();
+    expect(screen.getAllByText('Safe')).toHaveLength(1);
+    expect(screen.queryByText('Print quality: 0/10')).not.toBeInTheDocument();
+  });
+
+  it('links to notification setup even when the selected action pauses the printer', async () => {
+    savedSettings.octoeverywhere_action = 'pause';
+    server.use(
+      http.get('/api/v1/octoeverywhere/status', () => HttpResponse.json({
+        ...baseStatus,
+        notifications: { configured: false, uncovered_printers: [1, 2] },
+      })),
+    );
+    render(<FailureDetectionSettings />);
+
+    expect(await screen.findByText(/Alerts are not configured. Enable AI Failure Detection/)).toBeInTheDocument();
+    expect(screen.getByRole('link', { name: 'Configure notifications' })).toHaveAttribute('href', '/settings?tab=notifications');
+  });
+
+  it('identifies monitored printers not covered by notification subscriptions', async () => {
+    server.use(
+      http.get('/api/v1/octoeverywhere/status', () => HttpResponse.json({
+        ...baseStatus,
+        notifications: { configured: true, uncovered_printers: [2] },
+      })),
+    );
+    render(<FailureDetectionSettings />);
+
+    expect(await screen.findByText('Alerts are not configured for: P1S.')).toBeInTheDocument();
+  });
+
+  it.each([
+    ['disabled despite a running scheduler', { enabled: false }, 'Disabled'],
+    ['missing credentials', { api_key_configured: false }, 'Gadget API key required'],
+    ['stopped service', { is_running: false }, 'Service unavailable'],
+    ['no results yet', {}, 'Waiting for the first result'],
+    ['a pending inference', { per_printer: { '1': { class: 'unknown', print_quality: null, frame_count: 0 } } }, 'Waiting for the first result'],
+    ['a camera error', { per_printer: { '1': { class: 'error', print_quality: null, frame_count: 0 } } }, 'Monitoring needs attention'],
+    ['an observed result', { per_printer: { '1': { class: 'safe', print_quality: 9, frame_count: 1 } } }, 'Detection results available'],
+  ])('reports monitoring readiness for %s', async (_name, status, message) => {
+    server.use(http.get('/api/v1/octoeverywhere/status', () => HttpResponse.json({ ...baseStatus, ...status })));
+    render(<FailureDetectionSettings />);
+
+    expect(await screen.findByText(message)).toBeInTheDocument();
+  });
+});

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

@@ -605,6 +605,24 @@ export const handlers = [
   http.get('/api/v1/obico/printer-status', () =>
     HttpResponse.json({ enabled: false, monitored_printers: null, per_printer: {}, last_error: null })
   ),
+  http.get('/api/v1/octoeverywhere/status', () =>
+    HttpResponse.json({
+      enabled: false,
+      api_key_configured: false,
+      confidence: 'medium',
+      action: 'notify',
+      poll_interval: 20,
+      is_running: false,
+      last_error: null,
+      last_error_code: null,
+      per_printer: {},
+      history: [],
+      notifications: { configured: false, uncovered_printers: [] },
+    })
+  ),
+  http.get('/api/v1/octoeverywhere/printer-status', () =>
+    HttpResponse.json({ enabled: false, monitored_printers: null, per_printer: {}, last_error: null, last_error_code: null })
+  ),
   // Per-file project print progress (#1897) — empty means "no completed runs"
   http.get('/api/v1/projects/:id/file-progress', () => HttpResponse.json([])),
   http.get('/api/v1/printers/:id/current-print-user', () => HttpResponse.json(null)),

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

@@ -128,6 +128,91 @@ describe('PrintersPage AI detection badge (#1546)', () => {
     expect(await screen.findAllByText('Idle')).toHaveLength(1);
   });
 
+  it('shows OctoEverywhere quality in the badge and detail modal for monitored printers', async () => {
+    server.use(
+      http.get('/api/v1/octoeverywhere/printer-status', () =>
+        HttpResponse.json({
+          enabled: true,
+          monitored_printers: [1],
+          per_printer: { '1': { class: 'warning', frame_count: 12, print_quality: 5, error: null } },
+          last_error: null,
+        })
+      )
+    );
+    render(<PrintersPage />);
+
+    const badge = await screen.findByText('Warning');
+    expect(badge.closest('button')).toHaveAttribute(
+      'title',
+      'AI Failure Detection: Warning (print quality 5/10) - click for details'
+    );
+    expect(screen.queryByText('Idle')).not.toBeInTheDocument();
+    await userEvent.click(badge);
+    expect(await screen.findByText('Print quality')).toBeInTheDocument();
+    expect(screen.getByText('5/10')).toBeInTheDocument();
+    expect(screen.queryByText('Score')).not.toBeInTheDocument();
+  });
+
+  it('shows an OctoEverywhere camera error without a made-up quality value', async () => {
+    server.use(
+      http.get('/api/v1/octoeverywhere/printer-status', () =>
+        HttpResponse.json({
+          enabled: true,
+          monitored_printers: [1],
+          per_printer: { '1': { class: 'error', frame_count: 0, print_quality: null, error: 'Camera unavailable' } },
+          last_error: null,
+        })
+      )
+    );
+    render(<PrintersPage />);
+
+    await userEvent.click(await screen.findByText('Not checking'));
+    expect(await screen.findByText('Camera unavailable')).toBeInTheDocument();
+    expect(screen.queryByText('Print quality')).not.toBeInTheDocument();
+    expect(screen.queryByText('Safe')).not.toBeInTheDocument();
+  });
+
+  it('localizes an OctoEverywhere usage-limit code in the badge and opens its billing setup link', async () => {
+    server.use(
+      http.get('/api/v1/octoeverywhere/printer-status', () => HttpResponse.json({
+        enabled: true,
+        monitored_printers: [1],
+        per_printer: {
+          '1': { class: 'error', frame_count: 0, print_quality: null, error: 'Remote quota text', error_code: 'OE_FREE_USAGE_LIMIT_REACHED' },
+        },
+        last_error: 'Remote quota text',
+        last_error_code: 'OE_FREE_USAGE_LIMIT_REACHED',
+      })),
+    );
+    render(<PrintersPage />);
+
+    const badge = await screen.findByText('Not checking');
+    expect(badge.closest('button')?.getAttribute('title')).toContain('Usage limit reached.');
+    expect(badge.closest('button')?.getAttribute('title')).not.toContain('Remote quota text');
+    await userEvent.click(badge);
+    expect(await screen.findByRole('link', { name: 'Set up billing to continue' })).toHaveAttribute('href', 'https://octoeverywhere.com/gadgetapi');
+    expect(screen.queryByText('Remote quota text')).not.toBeInTheDocument();
+  });
+
+  it('keeps a redacted OctoEverywhere error generic without exposing a quota link', async () => {
+    server.use(
+      http.get('/api/v1/octoeverywhere/printer-status', () => HttpResponse.json({
+        enabled: true,
+        monitored_printers: [1],
+        per_printer: {
+          '1': { class: 'error', frame_count: 0, print_quality: null, error: null, error_code: null },
+        },
+        last_error: null,
+        last_error_code: null,
+      })),
+    );
+    render(<PrintersPage />);
+
+    await userEvent.click(await screen.findByText('Not checking'));
+    expect(screen.queryByText('Usage limit reached.')).not.toBeInTheDocument();
+    expect(screen.queryByRole('link', { name: /set up billing/i })).not.toBeInTheDocument();
+  });
+
   it('clicking the badge opens a modal with live status and the last error', async () => {
     server.use(
       http.get('/api/v1/obico/printer-status', () =>

+ 19 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -92,6 +92,25 @@ describe('SettingsPage', () => {
   });
 
   describe('rendering', () => {
+    it('opens notification settings from the OctoEverywhere readiness link', async () => {
+      window.history.replaceState({}, '', '/settings?tab=failure-detection');
+      server.use(
+        http.get('/api/v1/settings/', () => HttpResponse.json({
+          ...mockSettings,
+          octoeverywhere_enabled: true,
+          octoeverywhere_api_key: '',
+          octoeverywhere_api_key_configured: true,
+        })),
+      );
+      render(<SettingsPage />);
+
+      await userEvent.click(await screen.findByRole('link', { name: 'Configure notifications' }));
+
+      expect(await screen.findByRole('button', { name: /add provider/i })).toBeInTheDocument();
+      expect(screen.queryByRole('heading', { name: 'OctoEverywhere AI Detection' })).not.toBeInTheDocument();
+      expect(window.location.search).toBe('?tab=notifications');
+    });
+
     it('renders the page title', async () => {
       render(<SettingsPage />);
 

+ 70 - 1
frontend/src/api/client.ts

@@ -1637,6 +1637,13 @@ export interface AppSettings {
   obico_action: 'notify' | 'pause' | 'pause_and_off';
   obico_poll_interval: number;
   obico_enabled_printers: string;
+  octoeverywhere_enabled: boolean;
+  octoeverywhere_api_key: string;
+  octoeverywhere_api_key_configured: boolean;
+  octoeverywhere_confidence: OctoEverywhereConfidence;
+  octoeverywhere_action: 'notify' | 'pause' | 'pause_and_off';
+  octoeverywhere_enabled_printers: string;
+  octoeverywhere_poll_interval: number;
   // Inventory forecasting global lead time
   forecast_global_lead_time_days: number;
   location_sensor_poll_interval: number;
@@ -1647,7 +1654,7 @@ export interface AppSettings {
   location_sensor_alert_defaults: string;
 }
 
-export type AppSettingsUpdate = Partial<AppSettings>;
+export type AppSettingsUpdate = Partial<Omit<AppSettings, 'octoeverywhere_api_key_configured'>>;
 
 // Settings as a page sees them: everything from GET /settings for a user who
 // may read it, otherwise the subset /settings/ui-preferences and
@@ -3649,6 +3656,54 @@ export interface ObicoTestConnection {
   auth_ok: boolean | null;
 }
 
+// How sure Gadget must be before suggesting a warning or pause (API levels 1-5).
+export type OctoEverywhereConfidence = 'lowest' | 'low' | 'medium' | 'high' | 'highest';
+
+export interface OctoEverywhereDetection {
+  class: 'safe' | 'warning' | 'failure' | 'error' | 'unknown';
+  print_quality: number | null;
+  frame_count: number;
+  error?: string | null;
+  error_code: string | null;
+}
+
+export interface OctoEverywhereDetectionEvent {
+  timestamp: string;
+  printer_id: number;
+  class: OctoEverywhereDetection['class'];
+  print_quality: number | null;
+}
+
+export interface OctoEverywhereStatus {
+  enabled: boolean;
+  api_key_configured: boolean;
+  confidence: OctoEverywhereConfidence;
+  action: 'notify' | 'pause' | 'pause_and_off';
+  poll_interval: number;
+  is_running: boolean;
+  last_error: string | null;
+  last_error_code: string | null;
+  per_printer: Record<string, OctoEverywhereDetection>;
+  history: OctoEverywhereDetectionEvent[];
+  notifications: { configured: boolean; uncovered_printers: number[] };
+}
+
+export interface OctoEverywherePrinterStatus {
+  enabled: boolean;
+  // null = all printers are monitored
+  monitored_printers: number[] | null;
+  per_printer: Record<string, OctoEverywhereDetection>;
+  last_error: string | null;
+  last_error_code: string | null;
+}
+
+export interface OctoEverywhereTestConnection {
+  ok: boolean;
+  status_code: number | null;
+  error: string | null;
+  error_code: string | null;
+}
+
 export interface GitHubTestConnectionResponse {
   success: boolean;
   message: string;
@@ -8384,6 +8439,20 @@ export const api = {
       body: JSON.stringify(token === undefined ? { url } : { url, token }),
     }),
 
+  // OctoEverywhere AI failure detection
+  getOctoEverywhereStatus: () =>
+    request<OctoEverywhereStatus>('/octoeverywhere/status'),
+
+  getOctoEverywherePrinterStatus: () =>
+    request<OctoEverywherePrinterStatus>('/octoeverywhere/printer-status'),
+
+  // An omitted key uses the saved key; an explicit empty key is rejected.
+  testOctoEverywhereConnection: (apiKey?: string, confidence?: OctoEverywhereConfidence) =>
+    request<OctoEverywhereTestConnection>('/octoeverywhere/test-connection', {
+      method: 'POST',
+      body: JSON.stringify({ api_key: apiKey, confidence }),
+    }),
+
   // Slicer API — slice in the background. Both endpoints return 202 + a
   // job_id; poll /slice-jobs/{id} until status is `completed` or `failed`.
   sliceLibraryFile: (fileId: number, body: SliceRequest) =>

+ 21 - 6
frontend/src/components/AiDetectionModal.tsx

@@ -5,7 +5,9 @@ import { useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router-dom';
 import { X, ScanEye, AlertCircle, Settings } from 'lucide-react';
-import { aiDetectionClass, hasVerdict, type AiDetection } from '../utils/aiDetection';
+import { aiDetectionClass, hasVerdict, OCTOEVERYWHERE_USAGE_LIMIT, type AiDetection } from '../utils/aiDetection';
+import { useAuth } from '../contexts/AuthContext';
+import { OctoEverywhereLimitNotice } from './OctoEverywhereLimitNotice';
 
 interface AiDetectionModalProps {
   printerName: string;
@@ -13,12 +15,14 @@ interface AiDetectionModalProps {
   // 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;
+  lastErrorCode?: string | null;
   onClose: () => void;
 }
 
-export function AiDetectionModal({ printerName, detection, lastError, onClose }: AiDetectionModalProps) {
+export function AiDetectionModal({ printerName, detection, lastError, lastErrorCode, onClose }: AiDetectionModalProps) {
   const { t } = useTranslation();
   const navigate = useNavigate();
+  const { hasPermission } = useAuth();
 
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -43,7 +47,10 @@ export function AiDetectionModal({ printerName, detection, lastError, onClose }:
   // This printer's own reason beats the service-wide one: with several printers
   // monitored, the global string is whichever failed most recently and may be
   // about someone else's printer entirely.
-  const reason = detection?.error ?? lastError;
+  const hasPrinterError = !!(detection?.error || detection?.error_code);
+  const reason = hasPrinterError ? detection?.error : lastError;
+  const errorCode = hasPrinterError ? detection?.error_code : lastErrorCode;
+  const canSeeError = hasPermission('settings:read');
 
   return (
     <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
@@ -75,8 +82,14 @@ export function AiDetectionModal({ printerName, detection, lastError, onClose }:
             {detection && hasVerdict(cls) && (
               <>
                 <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>
+                  <span className="text-bambu-gray">
+                    {t('print_quality' in detection ? 'octoeverywhere.printQuality' : 'printers.aiDetection.score')}
+                  </span>
+                  <span className="text-white font-mono">
+                    {'print_quality' in detection
+                      ? detection.print_quality === null ? '—' : `${detection.print_quality}/10`
+                      : detection.score.toFixed(3)}
+                  </span>
                 </div>
                 <div className="flex justify-between">
                   <span className="text-bambu-gray">{t('printers.aiDetection.framesAnalyzed')}</span>
@@ -91,7 +104,9 @@ export function AiDetectionModal({ printerName, detection, lastError, onClose }:
             {!detection && <p className="text-bambu-gray">{t('printers.aiDetection.idleHint')}</p>}
           </div>
 
-          {reason && (
+          {canSeeError && errorCode === OCTOEVERYWHERE_USAGE_LIMIT ? (
+            <OctoEverywhereLimitNotice />
+          ) : canSeeError && reason && (
             <div className="flex items-start gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
               <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-700 dark:text-red-400" />
               <div className="min-w-0">

+ 30 - 474
frontend/src/components/FailureDetectionSettings.tsx

@@ -1,492 +1,48 @@
-import { useState, useEffect, useMemo } from 'react';
+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, ApiError } from '../api/client';
-import type { AppSettings } from '../api/client';
-import { Card, CardContent, CardHeader } from './Card';
-import { Button } from './Button';
-import { Toggle } from './Toggle';
-import { useToast } from '../contexts/ToastContext';
-import { NumberInput } from './NumberInput';
-import { SECRET_PLACEHOLDER, selectPlaceholderOnFocus } from '../utils/secrets';
+import { useQuery } from '@tanstack/react-query';
+import { api } from '../api/client';
+import { Card, CardContent } from './Card';
+import { ObicoSettings } from './ObicoSettings';
+import { OctoEverywhereSettings } from './OctoEverywhereSettings';
 
-type TestResult = { ok: boolean; message: string } | null;
-
-// The ML server fetches snapshots from this address, so it needs a scheme.
-// Empty falls back to External URL. Mirrors the backend validator, so a value
-// accepted here is never refused on save.
-function isValidInternalUrl(value: string): boolean {
-  const trimmed = value.trim();
-  if (trimmed === '') return true;
-  if (/\s/.test(trimmed) || !/^https?:\/\/[^\s/?#]/i.test(trimmed)) return false;
-  try {
-    return new URL(trimmed).hostname !== '';
-  } catch {
-    return false;
-  }
-}
-
-// The printers Obico watches as stored; null is all of them.
-function storedPrinterList(stored: AppSettings): number[] | null {
-  try {
-    const list = stored.obico_enabled_printers ? (JSON.parse(stored.obico_enabled_printers) as number[]) : null;
-    return Array.isArray(list) ? list : null;
-  } catch {
-    return null;
-  }
-}
+type Provider = 'obico' | 'octoeverywhere';
 
 export function FailureDetectionSettings() {
   const { t } = useTranslation();
-  const queryClient = useQueryClient();
-  const { showToast } = useToast();
-
-  const [enabled, setEnabled] = useState(false);
-  const [mlUrl, setMlUrl] = useState('');
-  const [bambuddyInternalUrl, setBambuddyInternalUrl] = useState('');
-  const [mlToken, setMlToken] = 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 internalUrlValid = isValidInternalUrl(bambuddyInternalUrl);
-
+  const [selectedProvider, setSelectedProvider] = useState<Provider | null>(null);
   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,
-  });
+  const provider = selectedProvider ?? (settings?.octoeverywhere_enabled ? 'octoeverywhere' : 'obico');
 
   useEffect(() => {
     if (!settings) return;
-    setEnabled(settings.obico_enabled ?? false);
-    setMlUrl(settings.obico_ml_url ?? '');
-    setBambuddyInternalUrl(settings.bambuddy_internal_url ?? '');
-    setMlToken(settings.obico_ml_token ?? '');
-    setSensitivity(settings.obico_sensitivity ?? 'medium');
-    setAction(settings.obico_action ?? 'notify');
-    setPollInterval(settings.obico_poll_interval ?? 10);
-    setEnabledPrinters(storedPrinterList(settings));
-    setInitialized(true);
+    // Keep the selected panel visible when its provider is disabled or settings
+    // refresh after a save. Only the first load follows the active provider.
+    setSelectedProvider((current) => current ?? (settings.octoeverywhere_enabled ? 'octoeverywhere' : 'obico'));
   }, [settings]);
 
-  const saveMutation = useMutation({
-    mutationFn: () =>
-      api.updateSettings({
-        obico_enabled: enabled,
-        obico_ml_url: mlUrl,
-        // An invalid address is left out so the other fields still save; the
-        // field shows why it was not applied.
-        ...(internalUrlValid ? { bambuddy_internal_url: bambuddyInternalUrl.trim() } : {}),
-        obico_ml_token: mlToken,
-        obico_sensitivity: sensitivity,
-        obico_action: action,
-        obico_poll_interval: pollInterval,
-        obico_enabled_printers: enabledPrinters === null ? '' : JSON.stringify(enabledPrinters),
-      }),
-    onError: async (error: Error) => {
-      showToast(t('settings.toast.settingsSaveFailed', { message: error.message }), 'error');
-      // A 400 is a printer deleted while this page was open. The printer list
-      // goes back to what the server kept, or every later edit would send the
-      // refused id again; set here, as unchanged settings keep their reference
-      // and the effect would not run. Not filtered against the printers list,
-      // which holds only the caller's printers. Other fields keep what was
-      // typed: a half-typed address is refused with 422 until it is finished.
-      if (!(error instanceof ApiError) || error.status !== 400) return;
-      queryClient.invalidateQueries({ queryKey: ['printers'] });
-      try {
-        const stored = await queryClient.fetchQuery({ queryKey: ['settings'], queryFn: api.getSettings, staleTime: 0 });
-        setEnabledPrinters(storedPrinterList(stored));
-      } catch {
-        // Server not reachable: the list keeps the edit, and the next one tries again.
-      }
-    },
-    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'));
-    },
-  });
-
-  const hasUnsavedChanges = useMemo(() => {
-    if (!initialized || !settings) return false;
-    return (
-      settings.obico_enabled !== enabled ||
-      settings.obico_ml_url !== mlUrl ||
-      (internalUrlValid && (settings.bambuddy_internal_url ?? '') !== bambuddyInternalUrl.trim()) ||
-      (settings.obico_ml_token ?? '') !== mlToken ||
-      settings.obico_sensitivity !== sensitivity ||
-      settings.obico_action !== action ||
-      settings.obico_poll_interval !== pollInterval ||
-      settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters))
-    );
-  }, [settings, initialized, enabled, mlUrl, bambuddyInternalUrl, internalUrlValid, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
-
-  // A saved token does not follow a changed URL: it must be entered again, and
-  // nothing is saved until it is (the backend would refuse it).
-  const tokenNeedsReentry =
-    mlToken === SECRET_PLACEHOLDER && (settings?.obico_ml_url ?? '') !== mlUrl;
-
-  // Auto-save on change (debounced)
-  useEffect(() => {
-    if (!hasUnsavedChanges || tokenNeedsReentry) return;
-    const id = setTimeout(() => saveMutation.mutate(), 500);
-    return () => clearTimeout(id);
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [hasUnsavedChanges, tokenNeedsReentry, enabled, mlUrl, bambuddyInternalUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
-
-  const handleTest = async () => {
-    setTestResult(null);
-    try {
-      // Flush first, so a green result describes the configuration the
-      // detection loop is actually running with. The loop reads the saved
-      // settings; this form tests what is typed in the boxes. Inside the 500ms
-      // auto-save debounce — or after a save that failed — those are different
-      // values, and "reachable and healthy" for a token the service never
-      // received is the reassuring-green-light problem all over again (#2952).
-      if (hasUnsavedChanges && !tokenNeedsReentry) {
-        await saveMutation.mutateAsync();
-      }
-      const res = await api.testObicoConnection(mlUrl, mlToken);
-      if (res.ok) {
-        // auth_ok is null when the token could not be checked — don't claim it
-        // works. It is true both for an accepted token and for a server that
-        // requires none, which is the same outcome for the user.
-        setTestResult({
-          ok: true,
-          message:
-            res.auth_ok === null
-              ? t('failureDetection.testSuccessTokenUnknown')
-              : 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 id="card-fd-ml">
-          <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>
-              <label className="block text-sm text-bambu-gray mb-1 mt-3">
-                {t('failureDetection.mlToken')}
-              </label>
-              <input
-                type="password"
-                value={mlToken}
-                onChange={(e) => setMlToken(e.target.value)}
-                onFocus={selectPlaceholderOnFocus}
-                autoComplete="off"
-                placeholder={t('failureDetection.mlTokenPlaceholder')}
-                className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
-                disabled={!enabled}
-              />
-              {mlToken === SECRET_PLACEHOLDER && (
-                <p className={`text-xs mt-1 ${tokenNeedsReentry ? 'text-yellow-400' : 'text-bambu-gray'}`}>
-                  {t(tokenNeedsReentry ? 'common.secretReenter' : 'common.secretKept')}
-                </p>
-              )}
-              <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlTokenHint')}</p>
-              {testResult && (
-                <div
-                  className={`flex items-start gap-2 mt-2 text-sm ${
-                    testResult.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark: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.bambuddyInternalUrl')}
-              </label>
-              <input
-                type="text"
-                value={bambuddyInternalUrl}
-                onChange={(e) => setBambuddyInternalUrl(e.target.value)}
-                placeholder="http://bambuddy:8000"
-                className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
-                disabled={!enabled}
-                aria-invalid={!internalUrlValid}
-              />
-              {!internalUrlValid && (
-                <p className="text-xs text-red-700 dark:text-red-400 mt-1">
-                  {t('failureDetection.bambuddyInternalUrlInvalid')}
-                </p>
-              )}
-              <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.bambuddyInternalUrlHint')}</p>
-            </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>
-              <NumberInput
-                value={pollInterval}
-                onChange={setPollInterval}
-                fallback={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-50 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded text-sm text-amber-800 dark: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 id="card-fd-perprinter">
-          <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 id="card-fd-status">
-          <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-700 dark:text-green-400' : 'text-red-700 dark: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-700 dark: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-700 dark:text-red-400'
-                            : info.class === 'warning'
-                              ? 'text-amber-700 dark:text-amber-400'
-                              : 'text-green-700 dark: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 id="card-fd-history">
-          <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-700 dark:text-red-400'
-                      : ev.class === 'warning'
-                        ? 'text-amber-700 dark: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 className="space-y-4">
+      <Card id="card-fd-provider">
+        <CardContent>
+          <label htmlFor="fd-provider" className="block text-sm text-bambu-gray mb-1">
+            {t('failureDetection.provider')}
+          </label>
+          <select
+            id="fd-provider"
+            value={provider}
+            onChange={(e) => setSelectedProvider(e.target.value as Provider)}
+            className="w-full lg:max-w-xl bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white text-sm"
+          >
+            <option value="obico">{t('failureDetection.providerObico')}</option>
+            <option value="octoeverywhere">{t('failureDetection.providerOctoEverywhere')}</option>
+          </select>
+        </CardContent>
+      </Card>
+      {provider === 'octoeverywhere' ? <OctoEverywhereSettings /> : <ObicoSettings />}
     </div>
   );
 }

+ 494 - 0
frontend/src/components/ObicoSettings.tsx

@@ -0,0 +1,494 @@
+import { useState, useEffect, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Loader2, ScanEye, Check, X, AlertTriangle, Info } from 'lucide-react';
+import { api, ApiError } from '../api/client';
+import type { AppSettings } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { Toggle } from './Toggle';
+import { useToast } from '../contexts/ToastContext';
+import { NumberInput } from './NumberInput';
+import { SECRET_PLACEHOLDER, selectPlaceholderOnFocus } from '../utils/secrets';
+
+type TestResult = { ok: boolean; message: string } | null;
+
+// The ML server fetches snapshots from this address, so it needs a scheme.
+// Empty falls back to External URL. Mirrors the backend validator, so a value
+// accepted here is never refused on save.
+function isValidInternalUrl(value: string): boolean {
+  const trimmed = value.trim();
+  if (trimmed === '') return true;
+  if (/\s/.test(trimmed) || !/^https?:\/\/[^\s/?#]/i.test(trimmed)) return false;
+  try {
+    return new URL(trimmed).hostname !== '';
+  } catch {
+    return false;
+  }
+}
+
+// The printers Obico watches as stored; null is all of them.
+function storedPrinterList(stored: AppSettings): number[] | null {
+  try {
+    const list = stored.obico_enabled_printers ? (JSON.parse(stored.obico_enabled_printers) as number[]) : null;
+    return Array.isArray(list) ? list : null;
+  } catch {
+    return null;
+  }
+}
+
+export function ObicoSettings() {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+
+  const [enabled, setEnabled] = useState(false);
+  const [mlUrl, setMlUrl] = useState('');
+  const [bambuddyInternalUrl, setBambuddyInternalUrl] = useState('');
+  const [mlToken, setMlToken] = 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 internalUrlValid = isValidInternalUrl(bambuddyInternalUrl);
+
+  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 ?? '');
+    setBambuddyInternalUrl(settings.bambuddy_internal_url ?? '');
+    setMlToken(settings.obico_ml_token ?? '');
+    setSensitivity(settings.obico_sensitivity ?? 'medium');
+    setAction(settings.obico_action ?? 'notify');
+    setPollInterval(settings.obico_poll_interval ?? 10);
+    setEnabledPrinters(storedPrinterList(settings));
+    setInitialized(true);
+  }, [settings]);
+
+  const saveMutation = useMutation({
+    mutationFn: () =>
+      api.updateSettings({
+        obico_enabled: enabled,
+        obico_ml_url: mlUrl,
+        // An invalid address is left out so the other fields still save; the
+        // field shows why it was not applied.
+        ...(internalUrlValid ? { bambuddy_internal_url: bambuddyInternalUrl.trim() } : {}),
+        obico_ml_token: mlToken,
+        obico_sensitivity: sensitivity,
+        obico_action: action,
+        obico_poll_interval: pollInterval,
+        obico_enabled_printers: enabledPrinters === null ? '' : JSON.stringify(enabledPrinters),
+      }),
+    onError: async (error: Error) => {
+      showToast(t('settings.toast.settingsSaveFailed', { message: error.message }), 'error');
+      // A 400 is a printer deleted while this page was open. The printer list
+      // goes back to what the server kept, or every later edit would send the
+      // refused id again; set here, as unchanged settings keep their reference
+      // and the effect would not run. Not filtered against the printers list,
+      // which holds only the caller's printers. Other fields keep what was
+      // typed: a half-typed address is refused with 422 until it is finished.
+      if (!(error instanceof ApiError) || error.status !== 400) return;
+      queryClient.invalidateQueries({ queryKey: ['printers'] });
+      try {
+        const stored = await queryClient.fetchQuery({ queryKey: ['settings'], queryFn: api.getSettings, staleTime: 0 });
+        setEnabledPrinters(storedPrinterList(stored));
+      } catch {
+        // Server not reachable: the list keeps the edit, and the next one tries again.
+      }
+    },
+    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'] });
+      queryClient.invalidateQueries({ queryKey: ['octoeverywhere-status'] });
+      queryClient.invalidateQueries({ queryKey: ['octoeverywhere-printer-status'] });
+      showToast(t('settings.toast.settingsSaved'));
+    },
+  });
+
+  const hasUnsavedChanges = useMemo(() => {
+    if (!initialized || !settings) return false;
+    return (
+      settings.obico_enabled !== enabled ||
+      settings.obico_ml_url !== mlUrl ||
+      (internalUrlValid && (settings.bambuddy_internal_url ?? '') !== bambuddyInternalUrl.trim()) ||
+      (settings.obico_ml_token ?? '') !== mlToken ||
+      settings.obico_sensitivity !== sensitivity ||
+      settings.obico_action !== action ||
+      settings.obico_poll_interval !== pollInterval ||
+      settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters))
+    );
+  }, [settings, initialized, enabled, mlUrl, bambuddyInternalUrl, internalUrlValid, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
+
+  // A saved token does not follow a changed URL: it must be entered again, and
+  // nothing is saved until it is (the backend would refuse it).
+  const tokenNeedsReentry =
+    mlToken === SECRET_PLACEHOLDER && (settings?.obico_ml_url ?? '') !== mlUrl;
+
+  // Auto-save on change (debounced)
+  useEffect(() => {
+    if (!hasUnsavedChanges || tokenNeedsReentry) return;
+    const id = setTimeout(() => saveMutation.mutate(), 500);
+    return () => clearTimeout(id);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [hasUnsavedChanges, tokenNeedsReentry, enabled, mlUrl, bambuddyInternalUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
+
+  const handleTest = async () => {
+    setTestResult(null);
+    try {
+      // Flush first, so a green result describes the configuration the
+      // detection loop is actually running with. The loop reads the saved
+      // settings; this form tests what is typed in the boxes. Inside the 500ms
+      // auto-save debounce — or after a save that failed — those are different
+      // values, and "reachable and healthy" for a token the service never
+      // received is the reassuring-green-light problem all over again (#2952).
+      if (hasUnsavedChanges && !tokenNeedsReentry) {
+        await saveMutation.mutateAsync();
+      }
+      const res = await api.testObicoConnection(mlUrl, mlToken);
+      if (res.ok) {
+        // auth_ok is null when the token could not be checked — don't claim it
+        // works. It is true both for an accepted token and for a server that
+        // requires none, which is the same outcome for the user.
+        setTestResult({
+          ok: true,
+          message:
+            res.auth_ok === null
+              ? t('failureDetection.testSuccessTokenUnknown')
+              : 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 id="card-fd-ml">
+          <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>
+              <label className="block text-sm text-bambu-gray mb-1 mt-3">
+                {t('failureDetection.mlToken')}
+              </label>
+              <input
+                type="password"
+                value={mlToken}
+                onChange={(e) => setMlToken(e.target.value)}
+                onFocus={selectPlaceholderOnFocus}
+                autoComplete="off"
+                placeholder={t('failureDetection.mlTokenPlaceholder')}
+                className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
+                disabled={!enabled}
+              />
+              {mlToken === SECRET_PLACEHOLDER && (
+                <p className={`text-xs mt-1 ${tokenNeedsReentry ? 'text-yellow-400' : 'text-bambu-gray'}`}>
+                  {t(tokenNeedsReentry ? 'common.secretReenter' : 'common.secretKept')}
+                </p>
+              )}
+              <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlTokenHint')}</p>
+              {testResult && (
+                <div
+                  className={`flex items-start gap-2 mt-2 text-sm ${
+                    testResult.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark: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.bambuddyInternalUrl')}
+              </label>
+              <input
+                type="text"
+                value={bambuddyInternalUrl}
+                onChange={(e) => setBambuddyInternalUrl(e.target.value)}
+                placeholder="http://bambuddy:8000"
+                className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
+                disabled={!enabled}
+                aria-invalid={!internalUrlValid}
+              />
+              {!internalUrlValid && (
+                <p className="text-xs text-red-700 dark:text-red-400 mt-1">
+                  {t('failureDetection.bambuddyInternalUrlInvalid')}
+                </p>
+              )}
+              <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.bambuddyInternalUrlHint')}</p>
+            </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>
+              <NumberInput
+                value={pollInterval}
+                onChange={setPollInterval}
+                fallback={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-50 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded text-sm text-amber-800 dark: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 id="card-fd-perprinter">
+          <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 id="card-fd-status">
+          <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-700 dark:text-green-400' : 'text-red-700 dark: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-700 dark: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-700 dark:text-red-400'
+                            : info.class === 'warning'
+                              ? 'text-amber-700 dark:text-amber-400'
+                              : 'text-green-700 dark: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 id="card-fd-history">
+          <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-700 dark:text-red-400'
+                      : ev.class === 'warning'
+                        ? 'text-amber-700 dark: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>
+  );
+}

+ 27 - 0
frontend/src/components/OctoEverywhereLimitNotice.tsx

@@ -0,0 +1,27 @@
+import { useTranslation } from 'react-i18next';
+import { AlertTriangle } from 'lucide-react';
+import { useAuth } from '../contexts/AuthContext';
+
+export function OctoEverywhereLimitNotice() {
+  const { t } = useTranslation();
+  const { hasPermission } = useAuth();
+
+  if (!hasPermission('settings:read')) return null;
+
+  return (
+    <div role="alert" className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded text-sm text-amber-800 dark:text-amber-200">
+      <AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
+      <div>
+        <p>{t('octoeverywhere.limitReached')}</p>
+        <a
+          href="https://octoeverywhere.com/gadgetapi"
+          target="_blank"
+          rel="noopener noreferrer"
+          className="inline-block mt-1 underline hover:no-underline"
+        >
+          {t('octoeverywhere.setupBilling')}
+        </a>
+      </div>
+    </div>
+  );
+}

+ 470 - 0
frontend/src/components/OctoEverywhereSettings.tsx

@@ -0,0 +1,470 @@
+import { useState, useEffect, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Loader2, ScanEye, Check, X, Info, AlertTriangle } from 'lucide-react';
+import { api, type OctoEverywhereConfidence } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { Toggle } from './Toggle';
+import { OctoEverywhereLimitNotice } from './OctoEverywhereLimitNotice';
+import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
+import { aiDetectionClass, hasVerdict, OCTOEVERYWHERE_USAGE_LIMIT, type AiDetectionClass } from '../utils/aiDetection';
+
+type TestResult = { ok: boolean; message: string; errorCode?: string | null } | null;
+
+function detectionColor(cls: AiDetectionClass) {
+  return cls === 'failure'
+    ? 'text-red-700 dark:text-red-400'
+    : cls === 'warning' || cls === 'error'
+      ? 'text-amber-700 dark:text-amber-400'
+      : cls === 'safe'
+        ? 'text-green-700 dark:text-green-400'
+        : 'text-bambu-gray';
+}
+
+export function OctoEverywhereSettings() {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { hasPermission, loading: authLoading } = useAuth();
+  const canUpdate = !authLoading && hasPermission('settings:update');
+
+  const [enabled, setEnabled] = useState(false);
+  const [apiKey, setApiKey] = useState('');
+  // The server never returns a saved key, so the field keeps showing the key
+  // this page saved (until the page is left) instead of clearing after a save.
+  const [savedApiKey, setSavedApiKey] = useState('');
+  const [editingApiKey, setEditingApiKey] = useState(false);
+  const [confidence, setConfidence] = useState<OctoEverywhereConfidence>('medium');
+  const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify');
+  const [pollInterval, setPollInterval] = useState(20);
+  const [enabledPrinters, setEnabledPrinters] = useState<number[] | null>(null); // null = all
+  const [testResult, setTestResult] = useState<TestResult>(null);
+  const [testing, setTesting] = useState(false);
+  const [initialized, setInitialized] = useState(false);
+
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+
+  const { data: status, refetch: refetchStatus } = useQuery({
+    queryKey: ['octoeverywhere-status'],
+    queryFn: api.getOctoEverywhereStatus,
+    refetchInterval: 10000,
+  });
+
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  useEffect(() => {
+    if (!settings) return;
+    setEnabled(settings.octoeverywhere_enabled ?? false);
+    setConfidence(settings.octoeverywhere_confidence ?? 'medium');
+    setAction(settings.octoeverywhere_action ?? 'notify');
+    setPollInterval(settings.octoeverywhere_poll_interval ?? 20);
+    try {
+      const list = settings.octoeverywhere_enabled_printers
+        ? (JSON.parse(settings.octoeverywhere_enabled_printers) as number[])
+        : null;
+      setEnabledPrinters(Array.isArray(list) ? list : null);
+    } catch {
+      setEnabledPrinters(null);
+    }
+    setInitialized(true);
+  }, [settings]);
+
+  const saveMutation = useMutation({
+    mutationFn: api.updateSettings,
+    onSuccess: (saved, update) => {
+      if (update.octoeverywhere_api_key !== undefined) setSavedApiKey(update.octoeverywhere_api_key);
+      queryClient.setQueryData(['settings'], saved);
+      queryClient.invalidateQueries({ queryKey: ['settings'] });
+      queryClient.invalidateQueries({ queryKey: ['octoeverywhere-status'] });
+      queryClient.invalidateQueries({ queryKey: ['octoeverywhere-printer-status'] });
+      queryClient.invalidateQueries({ queryKey: ['obico-status'] });
+      queryClient.invalidateQueries({ queryKey: ['obico-printer-status'] });
+      showToast(t('settings.toast.settingsSaved'));
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  // An empty field keeps the saved key, and a key already saved from this
+  // field is not resent with every other change.
+  const pendingApiKey = apiKey.trim() === savedApiKey ? '' : apiKey.trim();
+
+  const settingsToSave = useMemo(() => ({
+    octoeverywhere_enabled: enabled,
+    ...(pendingApiKey ? { octoeverywhere_api_key: pendingApiKey } : {}),
+    octoeverywhere_confidence: confidence,
+    octoeverywhere_action: action,
+    octoeverywhere_poll_interval: pollInterval,
+    octoeverywhere_enabled_printers: enabledPrinters === null ? '' : JSON.stringify(enabledPrinters),
+  }), [enabled, pendingApiKey, confidence, action, pollInterval, enabledPrinters]);
+
+  const hasUnsavedChanges = useMemo(() => {
+    if (!initialized || !settings) return false;
+    return (
+      (settings.octoeverywhere_enabled ?? false) !== enabled ||
+      pendingApiKey.length > 0 ||
+      (settings.octoeverywhere_confidence ?? 'medium') !== confidence ||
+      (settings.octoeverywhere_action ?? 'notify') !== action ||
+      (settings.octoeverywhere_poll_interval ?? 20) !== pollInterval ||
+      (settings.octoeverywhere_enabled_printers ?? '') !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters))
+    );
+  }, [settings, initialized, enabled, pendingApiKey, confidence, action, pollInterval, enabledPrinters]);
+
+  // Auto-save on change (debounced), matching the other detection provider.
+  useEffect(() => {
+    if (!hasUnsavedChanges || testing || editingApiKey || saveMutation.isPending || !canUpdate) return;
+    const id = setTimeout(() => saveMutation.mutate(settingsToSave), 500);
+    return () => clearTimeout(id);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [hasUnsavedChanges, settingsToSave, testing, editingApiKey, saveMutation.isPending, canUpdate]);
+
+  const handleTest = async () => {
+    if (!canUpdate) return;
+    setTestResult(null);
+    setTesting(true);
+    try {
+      // A successful test must describe the configuration used by the service,
+      // including edits still inside the auto-save debounce.
+      if (hasUnsavedChanges) {
+        await saveMutation.mutateAsync(settingsToSave);
+      }
+      const res = await api.testOctoEverywhereConnection(undefined, confidence);
+      setTestResult({
+        ok: res.ok,
+        message: res.ok ? t('octoeverywhere.testSuccess') : res.error || t('failureDetection.testFailed'),
+        errorCode: res.error_code,
+      });
+    } catch (e: unknown) {
+      setTestResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
+    } finally {
+      setTesting(false);
+    }
+  };
+
+  const togglePrinter = (printerId: number, checked: boolean) => {
+    const selected = enabledPrinters ?? printers?.map((p) => p.id) ?? [];
+    setEnabledPrinters(checked ? [...selected, printerId] : selected.filter((id) => id !== printerId));
+  };
+
+  const keyConfigured = settings?.octoeverywhere_api_key_configured ?? false;
+  const busy = testing || saveMutation.isPending;
+  const monitoringState = !status?.enabled
+    ? 'monitoringDisabled'
+    : !status.api_key_configured
+      ? 'monitoringMissingKey'
+      : !status.is_running
+        ? 'monitoringStopped'
+        : status.last_error || status.last_error_code || Object.values(status.per_printer).some((info) => info.class === 'error')
+          ? 'monitoringError'
+          : Object.values(status.per_printer).some((info) => hasVerdict(aiDetectionClass(info)))
+            ? 'monitoringObserved'
+            : 'monitoringWaiting';
+  const notifications = status?.notifications;
+  const uncoveredPrinters = notifications?.uncovered_printers.map((id) => printers?.find((p) => p.id === id)?.name ?? `#${id}`);
+
+  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 id="card-fd-ml">
+          <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('octoeverywhere.title')}</h2>
+              </div>
+              <Toggle checked={enabled} onChange={setEnabled} disabled={!initialized || busy || !canUpdate} />
+            </div>
+            <p className="text-sm text-bambu-gray mt-2">
+              {t('octoeverywhere.description')}{' '}
+              <a
+                href="https://docs.octoeverywhere.com/ai-failure-detection-apis/overview/"
+                target="_blank"
+                rel="noopener noreferrer"
+                className="text-bambu-green hover:underline"
+              >
+                {t('octoeverywhere.learnMore')}
+              </a>
+            </p>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <div>
+              <label htmlFor="oe-api-key" className="block text-sm text-bambu-gray mb-1">
+                {t('octoeverywhere.apiKey')}
+              </label>
+              <div className="flex gap-2">
+                <input
+                  id="oe-api-key"
+                  type="password"
+                  value={apiKey}
+                  onChange={(e) => { setApiKey(e.target.value); setTestResult(null); }}
+                  onFocus={() => setEditingApiKey(true)}
+                  onBlur={() => setEditingApiKey(false)}
+                  autoComplete="off"
+                  placeholder={t(keyConfigured ? 'octoeverywhere.apiKeyConfigured' : 'octoeverywhere.apiKeyPlaceholder')}
+                  className="flex-1 min-w-0 bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white text-sm"
+                  disabled={!initialized || busy || !canUpdate}
+                />
+                <Button
+                  onClick={handleTest}
+                  disabled={(!apiKey.trim() && !keyConfigured) || busy || !canUpdate}
+                  variant="secondary"
+                >
+                  {testing && <Loader2 className="w-4 h-4 animate-spin" />}
+                  {t('failureDetection.test')}
+                </Button>
+              </div>
+              <p className="text-xs text-bambu-gray mt-1">
+                {t('octoeverywhere.apiKeyHint')}{' '}
+                <a
+                  href="https://octoeverywhere.com/privacy#gadget-developer-api"
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="text-bambu-green hover:underline"
+                >
+                  {t('octoeverywhere.privacyPolicy')}
+                </a>
+              </p>
+              <a
+                href="https://octoeverywhere.com/gadgetapi"
+                target="_blank"
+                rel="noopener noreferrer"
+                className="inline-block text-xs text-bambu-green hover:underline mt-1"
+              >
+                {t('octoeverywhere.getApiKey')}
+              </a>
+              {testResult?.errorCode === OCTOEVERYWHERE_USAGE_LIMIT ? (
+                <div className="mt-2"><OctoEverywhereLimitNotice /></div>
+              ) : testResult && (
+                <div
+                  className={`flex items-start gap-2 mt-2 text-sm ${
+                    testResult.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark: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 htmlFor="oe-confidence" className="block text-sm text-bambu-gray mb-1">
+                {t('octoeverywhere.confidence')}
+              </label>
+              <select
+                id="oe-confidence"
+                value={confidence}
+                onChange={(e) => { setConfidence(e.target.value as OctoEverywhereConfidence); setTestResult(null); }}
+                disabled={!enabled || busy || !canUpdate}
+                className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white text-sm"
+              >
+                <option value="lowest">{t('octoeverywhere.confidenceLowest')}</option>
+                <option value="low">{t('octoeverywhere.confidenceLow')}</option>
+                <option value="medium">{t('octoeverywhere.confidenceMedium')}</option>
+                <option value="high">{t('octoeverywhere.confidenceHigh')}</option>
+                <option value="highest">{t('octoeverywhere.confidenceHighest')}</option>
+              </select>
+              <p className="text-xs text-bambu-gray mt-1">{t('octoeverywhere.confidenceHint')}</p>
+            </div>
+
+            <div>
+              <label htmlFor="oe-action" className="block text-sm text-bambu-gray mb-1">
+                {t('failureDetection.action')}
+              </label>
+              <select
+                id="oe-action"
+                value={action}
+                onChange={(e) => setAction(e.target.value as 'notify' | 'pause' | 'pause_and_off')}
+                disabled={!enabled || busy || !canUpdate}
+                className="w-full bg-bambu-dark border border-bambu-dark-tertiary 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>
+              <div className="flex items-center justify-between gap-2 mb-2">
+                <label htmlFor="oe-poll-interval" className="text-sm text-bambu-gray">
+                  {t('octoeverywhere.inspectionInterval')}
+                </label>
+                <span className="text-sm text-white tabular-nums">{pollInterval} {t('common.seconds')}</span>
+              </div>
+              <input
+                id="oe-poll-interval"
+                type="range"
+                value={pollInterval}
+                onChange={(e) => setPollInterval(Math.max(5, Math.min(30, Math.round(Number(e.target.value) || 20))))}
+                min={5}
+                max={30}
+                step={1}
+                aria-valuetext={`${pollInterval} ${t('common.seconds')}`}
+                aria-describedby="oe-interval-hint"
+                disabled={!enabled || busy || !canUpdate}
+                className="w-full h-1 accent-bambu-green cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
+              />
+              <p id="oe-interval-hint" className="text-xs text-bambu-gray mt-2">
+                {t('octoeverywhere.inspectionIntervalHint')}
+              </p>
+            </div>
+
+            {notifications && (!notifications.configured || notifications.uncovered_printers.length > 0) && (
+              <div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded text-sm text-amber-800 dark:text-amber-200">
+                <AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
+                <div>
+                  <p>{notifications.configured
+                    ? t('octoeverywhere.notificationsUncovered', { printers: uncoveredPrinters?.join(', ') })
+                    : t('octoeverywhere.notificationsMissing')}</p>
+                  {hasPermission('notifications:read') && (
+                    <Link to="/settings?tab=notifications" className="inline-block mt-1 underline hover:no-underline">
+                      {t('octoeverywhere.configureNotifications')}
+                    </Link>
+                  )}
+                </div>
+              </div>
+            )}
+          </CardContent>
+        </Card>
+
+        <Card id="card-fd-perprinter">
+          <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 || busy || !canUpdate}
+              />
+              <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 || busy || !canUpdate}
+                    />
+                    <span className="text-white">{p.name}</span>
+                  </label>
+                ))}
+              </div>
+            )}
+          </CardContent>
+        </Card>
+      </div>
+
+      <div className="space-y-3 flex-1 lg:max-w-xl">
+        <Card id="card-fd-status">
+          <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('octoeverywhere.monitoringStatus')}</span>
+                  <span className={monitoringState === 'monitoringObserved' ? 'text-green-700 dark:text-green-400' : 'text-bambu-gray'}>
+                    {t(`octoeverywhere.${monitoringState}`)}
+                  </span>
+                </div>
+                {status.last_error_code === OCTOEVERYWHERE_USAGE_LIMIT ? (
+                  <OctoEverywhereLimitNotice />
+                ) : status.last_error && (
+                  <div className="flex items-start gap-2 text-red-700 dark: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-2">
+                      {Object.entries(status.per_printer).map(([pid, info]) => {
+                        const printer = printers?.find((p) => String(p.id) === pid);
+                        const cls = aiDetectionClass(info);
+                        return (
+                          <div key={pid}>
+                            <div className="flex justify-between gap-2">
+                              <span className="text-white">{printer?.name ?? `#${pid}`}</span>
+                              <span className={detectionColor(cls)}>{t(`printers.aiDetection.${cls}`)}</span>
+                            </div>
+                            {hasVerdict(cls) && info.print_quality !== null && (
+                              <div className="text-xs text-bambu-gray">
+                                {t('octoeverywhere.printQuality')}: {info.print_quality}/10
+                              </div>
+                            )}
+                            {info.error_code === OCTOEVERYWHERE_USAGE_LIMIT ? (
+                              <OctoEverywhereLimitNotice />
+                            ) : info.error && <p className="text-xs text-amber-700 dark:text-amber-400 break-words">{info.error}</p>}
+                          </div>
+                        );
+                      })}
+                    </div>
+                  )}
+                </div>
+              </div>
+            )}
+          </CardContent>
+        </Card>
+
+        <Card id="card-fd-history">
+          <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 cls = ev.class;
+                  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={detectionColor(cls)}>
+                        {t(`printers.aiDetection.${cls}`)}
+                        {hasVerdict(cls) && ev.print_quality !== null && ` ${ev.print_quality}/10`}
+                      </span>
+                    </div>
+                  );
+                })}
+              </div>
+            )}
+          </CardContent>
+        </Card>
+      </div>
+    </div>
+  );
+}

+ 40 - 1
frontend/src/i18n/locales/de.ts

@@ -6908,7 +6908,7 @@ export default {
     printerOffline: 'Drucker offline',
     printerError: 'Druckerfehler',
     aiFailureDetection: 'KI-Fehlererkennung',
-    aiFailureDetectionDescription: 'Benachrichtigen, wenn die Obico-KI einen möglichen Druckfehler erkennt',
+    aiFailureDetectionDescription: 'Benachrichtigen, wenn die KI einen möglichen Fehldruck erkennt',
     lowFilamentLabel: 'Filament niedrig',
     maintenanceDue: 'Wartung fällig',
     maintenanceDueDescription: 'Benachrichtigen, wenn Wartung erforderlich ist',
@@ -7976,6 +7976,9 @@ export default {
     unexpectedError: 'Ein unerwarteter Fehler ist aufgetreten',
   },
   failureDetection: {
+    provider: 'Anbieter',
+    providerObico: 'Obico',
+    providerOctoEverywhere: 'OctoEverywhere',
     title: 'KI-Fehlererkennung',
     description: 'Überwacht Drucke über eine selbst gehostete Obico-ML-API und reagiert automatisch auf erkannte Fehldrucke.',
     mlUrl: 'Obico-ML-API-URL',
@@ -8015,6 +8018,42 @@ export default {
     noHistory: 'Noch keine Erkennungen.',
   },
 
+  octoeverywhere: {
+    title: 'KI-Erkennung mit OctoEverywhere',
+    description: 'Erkennt Fehldrucke automatisch mit OctoEverywhere.',
+    learnMore: 'Mehr erfahren',
+    apiKey: 'Gadget-API-Schlüssel',
+    apiKeyPlaceholder: 'OctoEverywhere-Gadget-API-Schlüssel einfügen',
+    apiKeyHint: 'Kamerabilder überwachter Drucke werden zur Analyse an OctoEverywhere gesendet. Die Bilder werden nach der KI-Prüfung gelöscht und nicht zum Trainieren von KI-Modellen verwendet.',
+    privacyPolicy: 'Datenschutzerklärung',
+    testSuccess: 'OctoEverywhere-Gadget-API-Schlüssel erfolgreich verifiziert!',
+    confidence: 'Erkennungssicherheit',
+    confidenceHint: 'Bei niedrigerer Erkennungssicherheit werden Probleme früher gemeldet, es kann aber mehr Fehlalarme geben. Bei höherer dauert es länger, dafür ist das Ergebnis zuverlässiger.',
+    confidenceLowest: 'Sehr niedrig (meldet früher, mehr Fehlalarme)',
+    confidenceLow: 'Niedrig',
+    confidenceMedium: 'Mittel (ausgewogen)',
+    confidenceHigh: 'Hoch',
+    confidenceHighest: 'Sehr hoch (meldet später, weniger Fehlalarme)',
+    printQuality: 'Druckqualität',
+    tooltipQuality: 'KI-Fehlererkennung: {{status}} (Druckqualität {{quality}}/10) - klicken für Details',
+    apiKeyConfigured: 'Leer lassen, um den aktuellen Schlüssel zu behalten',
+    notificationsMissing: 'Benachrichtigungen sind nicht eingerichtet. Aktiviere „KI-Fehlererkennung“ bei einem Benachrichtigungsanbieter, um benachrichtigt zu werden.',
+    notificationsUncovered: 'Für diese Drucker sind keine Benachrichtigungen eingerichtet: {{printers}}.',
+    configureNotifications: 'Benachrichtigungen einrichten',
+    monitoringStatus: 'Überwachung',
+    monitoringDisabled: 'Deaktiviert',
+    monitoringMissingKey: 'Gadget-API-Schlüssel erforderlich',
+    monitoringStopped: 'Dienst nicht verfügbar',
+    monitoringWaiting: 'Warten auf das erste Ergebnis',
+    monitoringObserved: 'Erkennungsergebnisse verfügbar',
+    monitoringError: 'Fehler – bitte prüfen',
+    inspectionInterval: 'Prüfintervall',
+    inspectionIntervalHint: 'Häufigere Prüfungen erkennen Fehldrucke früher, verbrauchen aber mehr API-Credits.',
+    limitReached: 'Nutzungslimit erreicht.',
+    setupBilling: 'Abrechnung einrichten, um fortzufahren',
+    getApiKey: 'Gadget-API-Schlüssel anfordern',
+  },
+
   modelSources: {
     title: 'Modellquellen',
     tabMakerworld: 'MakerWorld',

+ 40 - 1
frontend/src/i18n/locales/en.ts

@@ -6961,7 +6961,7 @@ export default {
     printerOffline: 'Printer Offline',
     printerError: 'Printer Error',
     aiFailureDetection: 'AI Failure Detection',
-    aiFailureDetectionDescription: 'Notify when Obico AI detects a possible print failure',
+    aiFailureDetectionDescription: 'Notify when AI detects a possible print failure',
     lowFilamentLabel: 'Low Filament',
     maintenanceDue: 'Maintenance Due',
     maintenanceDueDescription: 'Notify when maintenance is needed',
@@ -8029,6 +8029,9 @@ export default {
     unexpectedError: 'An unexpected error occurred',
   },
   failureDetection: {
+    provider: 'Provider',
+    providerObico: 'Obico',
+    providerOctoEverywhere: 'OctoEverywhere',
     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',
@@ -8068,6 +8071,42 @@ export default {
     noHistory: 'No detections yet.',
   },
 
+  octoeverywhere: {
+    title: 'OctoEverywhere AI Detection',
+    description: 'Automatically detect print failures with OctoEverywhere.',
+    learnMore: 'Learn more',
+    apiKey: 'Gadget API key',
+    apiKeyPlaceholder: 'Paste your OctoEverywhere Gadget API key',
+    apiKeyHint: 'Camera images from monitored prints are sent to OctoEverywhere for analysis. Images are deleted after the AI check and are not used for AI training.',
+    privacyPolicy: 'Privacy policy',
+    testSuccess: 'OctoEverywhere Gadget API key verification successful!',
+    confidence: 'Confidence',
+    confidenceHint: 'Lower confidence reports issues sooner but may have more false positives; higher confidence takes longer but is more certain.',
+    confidenceLowest: 'Lowest (reports sooner, more false positives)',
+    confidenceLow: 'Low',
+    confidenceMedium: 'Medium (balanced)',
+    confidenceHigh: 'High',
+    confidenceHighest: 'Highest (reports later, fewer false positives)',
+    printQuality: 'Print quality',
+    tooltipQuality: 'AI Failure Detection: {{status}} (print quality {{quality}}/10) - click for details',
+    apiKeyConfigured: 'Leave empty to keep current',
+    notificationsMissing: 'Alerts are not configured. Enable AI Failure Detection on a notification provider to receive alerts.',
+    notificationsUncovered: 'Alerts are not configured for: {{printers}}.',
+    configureNotifications: 'Configure notifications',
+    monitoringStatus: 'Monitoring',
+    monitoringDisabled: 'Disabled',
+    monitoringMissingKey: 'Gadget API key required',
+    monitoringStopped: 'Service unavailable',
+    monitoringWaiting: 'Waiting for the first result',
+    monitoringObserved: 'Detection results available',
+    monitoringError: 'Monitoring needs attention',
+    inspectionInterval: 'Inspection interval',
+    inspectionIntervalHint: 'More frequent checks detect failures sooner but use more API credits.',
+    limitReached: 'Usage limit reached.',
+    setupBilling: 'Set up billing to continue',
+    getApiKey: 'Get your Gadget API key',
+  },
+
   modelSources: {
     title: 'Model Sources',
     tabMakerworld: 'MakerWorld',

+ 40 - 1
frontend/src/i18n/locales/es.ts

@@ -6916,7 +6916,7 @@ export default {
     printerOffline: 'Impresora desconectada',
     printerError: 'Error de la impresora',
     aiFailureDetection: 'Detección de fallos por IA',
-    aiFailureDetectionDescription: 'Notificar cuando la IA de Obico detecte un posible fallo de impresión',
+    aiFailureDetectionDescription: 'Notificar cuando la IA detecte un posible fallo de impresión',
     lowFilamentLabel: 'Filamento bajo',
     maintenanceDue: 'Mantenimiento pendiente',
     maintenanceDueDescription: 'Notificar cuando se necesite mantenimiento',
@@ -7984,6 +7984,9 @@ export default {
     unexpectedError: 'Se produjo un error inesperado',
   },
   failureDetection: {
+    provider: 'Proveedor',
+    providerObico: 'Obico',
+    providerOctoEverywhere: 'OctoEverywhere',
     title: 'Detección de fallos por IA',
     description: 'Supervise las impresiones con una API de ML de Obico autoalojada y actúe automáticamente ante los fallos detectados.',
     mlUrl: 'URL de la API de ML de Obico',
@@ -8023,6 +8026,42 @@ export default {
     noHistory: 'Aún no hay detecciones.',
   },
 
+  octoeverywhere: {
+    title: 'Detección por IA de OctoEverywhere',
+    description: 'Detecte automáticamente los fallos de impresión con OctoEverywhere.',
+    learnMore: 'Más información',
+    apiKey: 'Clave API de Gadget',
+    apiKeyPlaceholder: 'Pegue su clave API de Gadget de OctoEverywhere',
+    apiKeyHint: 'Las imágenes de la cámara de las impresiones supervisadas se envían a OctoEverywhere para su análisis. Se eliminan tras la comprobación por IA y no se utilizan para entrenar modelos de IA.',
+    privacyPolicy: 'Política de privacidad',
+    testSuccess: '¡Clave API de Gadget de OctoEverywhere verificada correctamente!',
+    confidence: 'Nivel de confianza',
+    confidenceHint: 'Con un nivel de confianza más bajo, los problemas se notifican antes, pero puede haber más falsos positivos; con uno más alto, se tarda más, pero el resultado es más fiable.',
+    confidenceLowest: 'Mínimo (avisa antes, más falsos positivos)',
+    confidenceLow: 'Bajo',
+    confidenceMedium: 'Medio (equilibrado)',
+    confidenceHigh: 'Alto',
+    confidenceHighest: 'Máximo (avisa más tarde, menos falsos positivos)',
+    printQuality: 'Calidad de impresión',
+    tooltipQuality: 'Detección de fallos por IA: {{status}} (calidad de impresión {{quality}}/10) - haga clic para más detalles',
+    apiKeyConfigured: 'Déjelo vacío para conservar la clave actual',
+    notificationsMissing: 'Las alertas no están configuradas. Para recibirlas, active «Detección de fallos por IA» en un proveedor de notificaciones.',
+    notificationsUncovered: 'No hay alertas configuradas para: {{printers}}.',
+    configureNotifications: 'Configurar notificaciones',
+    monitoringStatus: 'Supervisión',
+    monitoringDisabled: 'Desactivada',
+    monitoringMissingKey: 'Se requiere la clave API de Gadget',
+    monitoringStopped: 'Servicio no disponible',
+    monitoringWaiting: 'Esperando el primer resultado',
+    monitoringObserved: 'Resultados de detección disponibles',
+    monitoringError: 'La supervisión requiere atención',
+    inspectionInterval: 'Intervalo de comprobación',
+    inspectionIntervalHint: 'Las comprobaciones más frecuentes detectan fallos antes, pero consumen más créditos de API.',
+    limitReached: 'Se ha alcanzado el límite de uso.',
+    setupBilling: 'Configurar la facturación para continuar',
+    getApiKey: 'Obtener una clave API de Gadget',
+  },
+
   modelSources: {
     title: 'Fuentes de modelos',
     tabMakerworld: 'MakerWorld',

+ 40 - 1
frontend/src/i18n/locales/fr.ts

@@ -6896,7 +6896,7 @@ export default {
     printerOffline: 'Imprimante hors ligne',
     printerError: 'Erreur de l\'imprimante',
     aiFailureDetection: 'Détection de défaillance par IA',
-    aiFailureDetectionDescription: 'Notifier lorsque l\'IA Obico détecte une défaillance d\'impression possible',
+    aiFailureDetectionDescription: 'Notifier lorsque l\'IA détecte une défaillance d\'impression possible',
     lowFilamentLabel: 'Filament bas',
     maintenanceDue: 'Maintenance requise',
     maintenanceDueDescription: 'Notifier lorsqu\'une maintenance est nécessaire',
@@ -7964,6 +7964,9 @@ export default {
     unexpectedError: 'Une erreur inattendue est survenue',
   },
   failureDetection: {
+    provider: 'Fournisseur',
+    providerObico: 'Obico',
+    providerOctoEverywhere: 'OctoEverywhere',
     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',
@@ -8002,6 +8005,42 @@ export default {
     historyTitle: 'Détections récentes',
     noHistory: 'Aucune détection pour le moment.',
   },
+  octoeverywhere: {
+    title: 'Détection par IA d\'OctoEverywhere',
+    description: 'Détecte automatiquement les échecs d\'impression avec OctoEverywhere.',
+    learnMore: 'En savoir plus',
+    apiKey: 'Clé API Gadget',
+    apiKeyPlaceholder: 'Collez votre clé API Gadget OctoEverywhere',
+    apiKeyHint: 'Pendant les impressions surveillées, les images de la caméra sont envoyées à OctoEverywhere pour analyse. Elles sont supprimées après la vérification par l\'IA et ne servent pas à entraîner des modèles d\'IA.',
+    privacyPolicy: 'Politique de confidentialité',
+    testSuccess: 'Clé API Gadget OctoEverywhere vérifiée avec succès !',
+    confidence: 'Niveau de confiance',
+    confidenceHint: 'Avec un niveau de confiance plus faible, les problèmes sont signalés plus tôt, mais les faux positifs peuvent être plus nombreux ; avec un niveau plus élevé, la détection prend plus de temps, mais elle est plus fiable.',
+    confidenceLowest: 'Très faible (signale plus tôt, plus de faux positifs)',
+    confidenceLow: 'Faible',
+    confidenceMedium: 'Moyen (équilibré)',
+    confidenceHigh: 'Élevé',
+    confidenceHighest: 'Très élevé (signale plus tard, moins de faux positifs)',
+    printQuality: 'Qualité d\'impression',
+    tooltipQuality: 'Détection d\'échec par IA : {{status}} (qualité d\'impression {{quality}}/10) - cliquez pour les détails',
+    apiKeyConfigured: 'Laissez vide pour conserver la clé actuelle',
+    notificationsMissing: 'Les alertes ne sont pas configurées. Pour en recevoir, activez l\'option « Détection de défaillance par IA » d\'un fournisseur de notifications.',
+    notificationsUncovered: 'Aucune alerte n\'est configurée pour : {{printers}}.',
+    configureNotifications: 'Configurer les notifications',
+    monitoringStatus: 'Surveillance',
+    monitoringDisabled: 'Désactivée',
+    monitoringMissingKey: 'Clé API Gadget requise',
+    monitoringStopped: 'Service indisponible',
+    monitoringWaiting: 'En attente du premier résultat',
+    monitoringObserved: 'Résultats de détection disponibles',
+    monitoringError: 'La surveillance nécessite votre attention',
+    inspectionInterval: 'Intervalle de vérification',
+    inspectionIntervalHint: 'Des vérifications plus fréquentes permettent de détecter les échecs plus tôt, mais consomment davantage de crédits API.',
+    limitReached: 'Limite d\'utilisation atteinte.',
+    setupBilling: 'Configurer la facturation pour continuer',
+    getApiKey: 'Obtenir votre clé API Gadget',
+  },
+
   modelSources: {
     title: 'Sources de modèles',
     tabMakerworld: 'MakerWorld',

+ 40 - 1
frontend/src/i18n/locales/it.ts

@@ -6895,7 +6895,7 @@ export default {
     printerOffline: 'Stampante offline',
     printerError: 'Errore stampante',
     aiFailureDetection: 'Rilevamento guasti IA',
-    aiFailureDetectionDescription: 'Notifica quando l\'IA di Obico rileva un possibile guasto di stampa',
+    aiFailureDetectionDescription: 'Notifica quando l\'IA rileva un possibile problema di stampa',
     lowFilamentLabel: 'Filamento scarso',
     maintenanceDue: 'Manutenzione necessaria',
     maintenanceDueDescription: 'Notifica quando è necessaria la manutenzione',
@@ -7963,6 +7963,9 @@ export default {
     unexpectedError: 'Si è verificato un errore imprevisto',
   },
   failureDetection: {
+    provider: 'Servizio',
+    providerObico: 'Obico',
+    providerOctoEverywhere: 'OctoEverywhere',
     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',
@@ -8001,6 +8004,42 @@ export default {
     historyTitle: 'Rilevamenti recenti',
     noHistory: 'Nessun rilevamento finora.',
   },
+  octoeverywhere: {
+    title: 'Rilevamento IA di OctoEverywhere',
+    description: 'Rileva automaticamente le stampe fallite con OctoEverywhere.',
+    learnMore: 'Scopri di più',
+    apiKey: 'Chiave API Gadget',
+    apiKeyPlaceholder: 'Incolla la tua chiave API Gadget di OctoEverywhere',
+    apiKeyHint: 'Durante le stampe monitorate, le immagini della telecamera vengono inviate a OctoEverywhere per l\'analisi. Vengono eliminate dopo il controllo dell\'IA e non vengono usate per addestrare modelli di IA.',
+    privacyPolicy: 'Informativa sulla privacy',
+    testSuccess: 'Chiave API Gadget di OctoEverywhere verificata correttamente!',
+    confidence: 'Livello di confidenza',
+    confidenceHint: 'Con un livello di confidenza più basso i problemi vengono segnalati prima, ma i falsi positivi possono aumentare; con uno più alto serve più tempo, ma il rilevamento è più affidabile.',
+    confidenceLowest: 'Minimo (segnala prima, più falsi positivi)',
+    confidenceLow: 'Basso',
+    confidenceMedium: 'Medio (equilibrato)',
+    confidenceHigh: 'Alto',
+    confidenceHighest: 'Massimo (segnala più tardi, meno falsi positivi)',
+    printQuality: 'Qualità di stampa',
+    tooltipQuality: 'Rilevamento guasti con IA: {{status}} (qualità di stampa {{quality}}/10) - clicca per i dettagli',
+    apiKeyConfigured: 'Lascia vuoto per mantenere la chiave attuale',
+    notificationsMissing: 'Gli avvisi non sono configurati. Per riceverli, attiva "Rilevamento guasti IA" in un provider di notifica.',
+    notificationsUncovered: 'Nessun avviso configurato per: {{printers}}.',
+    configureNotifications: 'Configura le notifiche',
+    monitoringStatus: 'Monitoraggio',
+    monitoringDisabled: 'Disattivato',
+    monitoringMissingKey: 'Chiave API Gadget richiesta',
+    monitoringStopped: 'Servizio non disponibile',
+    monitoringWaiting: 'In attesa del primo risultato',
+    monitoringObserved: 'Risultati del rilevamento disponibili',
+    monitoringError: 'Il monitoraggio richiede attenzione',
+    inspectionInterval: 'Intervallo di controllo',
+    inspectionIntervalHint: 'Controlli più frequenti rilevano prima i problemi, ma consumano più crediti API.',
+    limitReached: 'Limite di utilizzo raggiunto.',
+    setupBilling: 'Configura la fatturazione per continuare',
+    getApiKey: 'Ottieni la tua chiave API Gadget',
+  },
+
   modelSources: {
     title: 'Fonti dei modelli',
     tabMakerworld: 'MakerWorld',

+ 40 - 1
frontend/src/i18n/locales/ja.ts

@@ -6909,7 +6909,7 @@ export default {
     printerOffline: 'プリンターオフライン',
     printerError: 'プリンターエラー',
     aiFailureDetection: 'AI 故障検出',
-    aiFailureDetectionDescription: 'Obico AI が印刷の不具合の可能性を検出したときに通知',
+    aiFailureDetectionDescription: 'AI が印刷失敗の可能性を検出したときに通知',
     lowFilamentLabel: 'フィラメント残量低下',
     maintenanceDue: 'メンテナンス期限',
     maintenanceDueDescription: 'メンテナンスが必要な場合に通知',
@@ -7977,6 +7977,9 @@ export default {
     unexpectedError: '予期しないエラーが発生しました',
   },
   failureDetection: {
+    provider: 'プロバイダー',
+    providerObico: 'Obico',
+    providerOctoEverywhere: 'OctoEverywhere',
     title: 'AI 失敗検出',
     description: 'セルフホストされた Obico ML API で印刷を監視し、検出された失敗に自動的に対応します。',
     mlUrl: 'Obico ML API の URL',
@@ -8015,6 +8018,42 @@ export default {
     historyTitle: '最近の検出',
     noHistory: 'まだ検出はありません。',
   },
+  octoeverywhere: {
+    title: 'OctoEverywhere AI 検出',
+    description: 'OctoEverywhere で印刷の失敗を自動検出します。',
+    learnMore: '詳細はこちら',
+    apiKey: 'Gadget API キー',
+    apiKeyPlaceholder: 'OctoEverywhere の Gadget API キーを貼り付け',
+    apiKeyHint: '監視中の印刷のカメラ画像は、分析のため OctoEverywhere に送信されます。画像は AI チェック後に削除され、AI の学習には使用されません。',
+    privacyPolicy: 'プライバシーポリシー',
+    testSuccess: 'OctoEverywhere Gadget API キーの確認に成功しました!',
+    confidence: '信頼度',
+    confidenceHint: '信頼度を低くすると問題を早く報告しますが、誤検出が増える可能性があります。高くすると報告まで時間がかかりますが、より確実になります。',
+    confidenceLowest: '最低(報告が早い、誤検出が多い)',
+    confidenceLow: '低',
+    confidenceMedium: '中(バランス型)',
+    confidenceHigh: '高',
+    confidenceHighest: '最高(報告が遅い、誤検出が少ない)',
+    printQuality: '印刷品質',
+    tooltipQuality: 'AI 失敗検出: {{status}}(印刷品質 {{quality}}/10)- クリックで詳細を表示',
+    apiKeyConfigured: '現在のキーを保持する場合は空欄のままにしてください',
+    notificationsMissing: 'アラートが設定されていません。アラートを受け取るには、通知プロバイダーで「AI 故障検出」を有効にしてください。',
+    notificationsUncovered: '次のプリンターのアラートが設定されていません: {{printers}}',
+    configureNotifications: '通知を設定',
+    monitoringStatus: '監視',
+    monitoringDisabled: '無効',
+    monitoringMissingKey: 'Gadget API キーが必要です',
+    monitoringStopped: 'サービスを利用できません',
+    monitoringWaiting: '最初の結果を待機中',
+    monitoringObserved: '検出結果があります',
+    monitoringError: '要確認',
+    inspectionInterval: 'チェック間隔',
+    inspectionIntervalHint: 'チェックの頻度を上げると印刷の失敗をより早く検出できますが、API クレジットの消費量が増えます。',
+    limitReached: '利用上限に達しました。',
+    setupBilling: 'お支払い方法を設定して続行',
+    getApiKey: 'Gadget API キーを取得',
+  },
+
   modelSources: {
     title: 'モデルソース',
     tabMakerworld: 'MakerWorld',

+ 40 - 1
frontend/src/i18n/locales/ko.ts

@@ -6628,7 +6628,7 @@ export default {
     printerOffline: '프린터 오프라인',
     printerError: '프린터 오류',
     aiFailureDetection: 'AI 실패 감지',
-    aiFailureDetectionDescription: 'Obico AI가 인쇄 실패 가능성을 감지하면 알림',
+    aiFailureDetectionDescription: 'AI가 인쇄 실패 가능성을 감지하면 알림',
     lowFilamentLabel: '필라멘트 부족',
     maintenanceDue: '유지 관리 필요',
     maintenanceDueDescription: '유지 관리가 필요할 때 알림',
@@ -7419,6 +7419,9 @@ export default {
     logHealthIntro: '최근 로그가 알려진 문제와 일치합니다. 아래 수정 방법을 확인하세요 — 해결하면 버그 보고서 없이 문제가 해결될 수 있습니다. 아래에서 보고서를 제출할 수도 있습니다.'
   },
   failureDetection: {
+    provider: '제공자',
+    providerObico: 'Obico',
+    providerOctoEverywhere: 'OctoEverywhere',
     title: 'AI 실패 감지',
     description: '자체 호스팅 Obico ML API로 인쇄를 모니터링하고 감지된 실패에 자동으로 조치합니다.',
     mlUrl: 'Obico ML API URL',
@@ -7457,6 +7460,42 @@ export default {
     historyTitle: '최근 감지',
     noHistory: '아직 감지 없음.'
   },
+  octoeverywhere: {
+    title: 'OctoEverywhere AI 감지',
+    description: 'OctoEverywhere로 인쇄 실패를 자동으로 감지합니다.',
+    learnMore: '자세히 알아보기',
+    apiKey: 'Gadget API 키',
+    apiKeyPlaceholder: 'OctoEverywhere Gadget API 키를 붙여넣으세요',
+    apiKeyHint: '모니터링 중인 인쇄의 카메라 이미지가 분석을 위해 OctoEverywhere로 전송됩니다. 이미지는 AI 검사 후 삭제되며 AI 학습에 사용되지 않습니다.',
+    privacyPolicy: '개인정보 처리방침',
+    testSuccess: 'OctoEverywhere Gadget API 키가 성공적으로 확인되었습니다!',
+    confidence: '신뢰도',
+    confidenceHint: '신뢰도가 낮을수록 문제를 더 빨리 보고하지만 오탐이 많아질 수 있고, 높을수록 시간이 더 걸리지만 더 확실합니다.',
+    confidenceLowest: '매우 낮음 (빠른 보고, 오탐 많음)',
+    confidenceLow: '낮음',
+    confidenceMedium: '중간 (균형)',
+    confidenceHigh: '높음',
+    confidenceHighest: '매우 높음 (늦은 보고, 오탐 적음)',
+    printQuality: '인쇄 품질',
+    tooltipQuality: 'AI 실패 감지: {{status}} (인쇄 품질 {{quality}}/10) - 클릭하여 자세히 보기',
+    apiKeyConfigured: '현재 키를 유지하려면 비워두세요',
+    notificationsMissing: '알림이 설정되지 않았습니다. 알림을 받으려면 알림 제공자에서 "AI 실패 감지"를 활성화하세요.',
+    notificationsUncovered: '다음 프린터에 알림이 설정되지 않았습니다: {{printers}}.',
+    configureNotifications: '알림 설정',
+    monitoringStatus: '모니터링',
+    monitoringDisabled: '비활성화됨',
+    monitoringMissingKey: 'Gadget API 키 필요',
+    monitoringStopped: '서비스 이용 불가',
+    monitoringWaiting: '첫 번째 결과를 기다리는 중',
+    monitoringObserved: '감지 결과 있음',
+    monitoringError: '확인 필요',
+    inspectionInterval: '검사 간격',
+    inspectionIntervalHint: '검사를 자주 할수록 인쇄 실패를 더 빨리 감지하지만 API 크레딧을 더 많이 사용합니다.',
+    limitReached: '사용 한도에 도달했습니다.',
+    setupBilling: '계속하려면 결제 설정',
+    getApiKey: 'Gadget API 키 발급받기',
+  },
+
   modelSources: {
     title: '모델 소스',
     tabMakerworld: 'MakerWorld',

+ 40 - 1
frontend/src/i18n/locales/nl.ts

@@ -6959,7 +6959,7 @@ export default {
     printerOffline: 'Printer offline',
     printerError: 'Printerfout',
     aiFailureDetection: 'AI-foutdetectie',
-    aiFailureDetectionDescription: 'Melden wanneer Obico AI een mogelijke afdrukfout detecteert',
+    aiFailureDetectionDescription: 'Melden wanneer de AI een mogelijk mislukte afdruk detecteert',
     lowFilamentLabel: 'Weinig filament',
     maintenanceDue: 'Onderhoud nodig',
     maintenanceDueDescription: 'Melden wanneer onderhoud nodig is',
@@ -8027,6 +8027,9 @@ export default {
     unexpectedError: 'Er is een onverwachte fout opgetreden',
   },
   failureDetection: {
+    provider: 'Aanbieder',
+    providerObico: 'Obico',
+    providerOctoEverywhere: 'OctoEverywhere',
     title: 'AI-foutdetectie',
     description: 'Bewaak afdrukken met een zelf-gehoste Obico ML API en voer automatisch acties uit bij gedetecteerde fouten.',
     mlUrl: 'Obico ML API-URL',
@@ -8066,6 +8069,42 @@ export default {
     noHistory: 'Nog geen detecties.',
   },
 
+  octoeverywhere: {
+    title: 'AI-detectie van OctoEverywhere',
+    description: 'Detecteer mislukte afdrukken automatisch met OctoEverywhere.',
+    learnMore: 'Meer informatie',
+    apiKey: 'Gadget-API-sleutel',
+    apiKeyPlaceholder: 'Plak je OctoEverywhere-Gadget-API-sleutel',
+    apiKeyHint: 'Camerabeelden van bewaakte afdrukken worden voor analyse naar OctoEverywhere verzonden. De beelden worden na de AI-controle verwijderd en niet gebruikt om AI-modellen te trainen.',
+    privacyPolicy: 'Privacybeleid',
+    testSuccess: 'OctoEverywhere-Gadget-API-sleutel succesvol geverifieerd!',
+    confidence: 'Betrouwbaarheid',
+    confidenceHint: 'Bij een lagere betrouwbaarheid worden problemen eerder gemeld, maar zijn er mogelijk meer fout-positieven; bij een hogere duurt het langer, maar is het resultaat zekerder.',
+    confidenceLowest: 'Zeer laag (meldt eerder, meer fout-positieven)',
+    confidenceLow: 'Laag',
+    confidenceMedium: 'Gemiddeld (gebalanceerd)',
+    confidenceHigh: 'Hoog',
+    confidenceHighest: 'Zeer hoog (meldt later, minder fout-positieven)',
+    printQuality: 'Afdrukkwaliteit',
+    tooltipQuality: 'AI-foutdetectie: {{status}} (afdrukkwaliteit {{quality}}/10) - klik voor details',
+    apiKeyConfigured: 'Laat leeg om de huidige sleutel te behouden',
+    notificationsMissing: 'Er zijn geen meldingen ingesteld. Schakel "AI-foutdetectie" in bij een meldingsprovider om meldingen te ontvangen.',
+    notificationsUncovered: 'Geen meldingen ingesteld voor: {{printers}}.',
+    configureNotifications: 'Meldingen instellen',
+    monitoringStatus: 'Bewaking',
+    monitoringDisabled: 'Uitgeschakeld',
+    monitoringMissingKey: 'Gadget-API-sleutel vereist',
+    monitoringStopped: 'Dienst niet beschikbaar',
+    monitoringWaiting: 'Wachten op het eerste resultaat',
+    monitoringObserved: 'Detectieresultaten beschikbaar',
+    monitoringError: 'Bewaking heeft aandacht nodig',
+    inspectionInterval: 'Controle-interval',
+    inspectionIntervalHint: 'Door vaker te controleren worden mislukte afdrukken eerder gedetecteerd, maar dat kost meer API-credits.',
+    limitReached: 'Gebruikslimiet bereikt.',
+    setupBilling: 'Facturering instellen om door te gaan',
+    getApiKey: 'Gadget-API-sleutel aanvragen',
+  },
+
   modelSources: {
     title: 'Modelbronnen',
     tabMakerworld: 'MakerWorld',

+ 40 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -6895,7 +6895,7 @@ export default {
     printerOffline: 'Impressora Offline',
     printerError: 'Erro da Impressora',
     aiFailureDetection: 'Detecção de Falhas por IA',
-    aiFailureDetectionDescription: 'Notificar quando a IA do Obico detectar uma possível falha de impressão',
+    aiFailureDetectionDescription: 'Notificar quando a IA detectar uma possível falha de impressão',
     lowFilamentLabel: 'Filamento Baixo',
     maintenanceDue: 'Manutenção Necessária',
     maintenanceDueDescription: 'Notificar quando manutenção for necessária',
@@ -7963,6 +7963,9 @@ export default {
     unexpectedError: 'Ocorreu um erro inesperado',
   },
   failureDetection: {
+    provider: 'Provedor',
+    providerObico: 'Obico',
+    providerOctoEverywhere: 'OctoEverywhere',
     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',
@@ -8001,6 +8004,42 @@ export default {
     historyTitle: 'Detecções recentes',
     noHistory: 'Nenhuma detecção ainda.',
   },
+  octoeverywhere: {
+    title: 'Detecção por IA do OctoEverywhere',
+    description: 'Detecta falhas de impressão automaticamente com o OctoEverywhere.',
+    learnMore: 'Saiba mais',
+    apiKey: 'Chave da API Gadget',
+    apiKeyPlaceholder: 'Cole sua chave da API Gadget do OctoEverywhere',
+    apiKeyHint: 'Durante as impressões monitoradas, as imagens da câmera são enviadas ao OctoEverywhere para análise. Elas são excluídas após a verificação por IA e não são usadas para treinar modelos de IA.',
+    privacyPolicy: 'Política de privacidade',
+    testSuccess: 'Chave da API Gadget do OctoEverywhere verificada com sucesso!',
+    confidence: 'Nível de confiança',
+    confidenceHint: 'Com um nível de confiança mais baixo, os problemas são sinalizados mais cedo, mas pode haver mais falsos positivos; com um mais alto, leva mais tempo, mas a detecção é mais confiável.',
+    confidenceLowest: 'Mínimo (avisa mais cedo, mais falsos positivos)',
+    confidenceLow: 'Baixo',
+    confidenceMedium: 'Médio (equilibrado)',
+    confidenceHigh: 'Alto',
+    confidenceHighest: 'Máximo (avisa mais tarde, menos falsos positivos)',
+    printQuality: 'Qualidade de impressão',
+    tooltipQuality: 'Detecção de Falhas por IA: {{status}} (qualidade de impressão {{quality}}/10) - clique para detalhes',
+    apiKeyConfigured: 'Deixe em branco para manter a chave atual',
+    notificationsMissing: 'Os alertas não estão configurados. Para recebê-los, ative "Detecção de Falhas por IA" em um provedor de notificação.',
+    notificationsUncovered: 'Não há alertas configurados para: {{printers}}.',
+    configureNotifications: 'Configurar notificações',
+    monitoringStatus: 'Monitoramento',
+    monitoringDisabled: 'Desativado',
+    monitoringMissingKey: 'Chave da API Gadget necessária',
+    monitoringStopped: 'Serviço indisponível',
+    monitoringWaiting: 'Aguardando o primeiro resultado',
+    monitoringObserved: 'Resultados de detecção disponíveis',
+    monitoringError: 'O monitoramento precisa de atenção',
+    inspectionInterval: 'Intervalo de verificação',
+    inspectionIntervalHint: 'Verificações mais frequentes detectam falhas mais cedo, mas consomem mais créditos de API.',
+    limitReached: 'Limite de uso atingido.',
+    setupBilling: 'Configurar o faturamento para continuar',
+    getApiKey: 'Obter uma chave da API Gadget',
+  },
+
   modelSources: {
     title: 'Fontes de modelos',
     tabMakerworld: 'MakerWorld',

+ 40 - 1
frontend/src/i18n/locales/ru.ts

@@ -6615,7 +6615,7 @@ export default {
     printerOffline: "Принтер не в сети",
     printerError: "Ошибка принтера",
     aiFailureDetection: "Обнаружение сбоев с помощью ИИ",
-    aiFailureDetectionDescription: "Уведомить, если Obico AI обнаружит возможный сбой печати",
+    aiFailureDetectionDescription: "Уведомить, если ИИ обнаружит возможный сбой печати",
     lowFilamentLabel: "Заканчивается филамент",
     maintenanceDue: "Требуется обслуживание",
     maintenanceDueDescription: "Уведомить, когда принтеру потребуется обслуживание",
@@ -7603,6 +7603,9 @@ export default {
     unexpectedError: "Произошла непредвиденная ошибка",
   },
   failureDetection: {
+    provider: "Провайдер",
+    providerObico: "Obico",
+    providerOctoEverywhere: "OctoEverywhere",
     title: "Обнаружение сбоев с помощью ИИ",
     description: "Контролируйте печать через собственный сервер Obico ML API и автоматически реагируйте на обнаруженные сбои.",
     mlUrl: "URL Obico ML API",
@@ -7641,6 +7644,42 @@ export default {
     historyTitle: "Последние обнаружения",
     noHistory: "Обнаружений пока нет.",
   },
+  octoeverywhere: {
+    title: "Обнаружение сбоев с помощью ИИ от OctoEverywhere",
+    description: "Автоматически обнаруживайте сбои печати с помощью OctoEverywhere.",
+    learnMore: "Подробнее",
+    apiKey: "Ключ API Gadget",
+    apiKeyPlaceholder: "Вставьте ключ API Gadget от OctoEverywhere",
+    apiKeyHint: "Снимки с камеры во время отслеживаемой печати отправляются в OctoEverywhere на анализ. После проверки снимки удаляются и не используются для обучения ИИ.",
+    privacyPolicy: "Политика конфиденциальности",
+    testSuccess: "Ключ API Gadget OctoEverywhere успешно проверен!",
+    confidence: "Уровень уверенности",
+    confidenceHint: "При низком уровне уверенности о проблемах сообщается раньше, но ложных срабатываний может быть больше; при высоком — позже, зато надёжнее.",
+    confidenceLowest: "Очень низкий (сообщает раньше, больше ложных срабатываний)",
+    confidenceLow: "Низкий",
+    confidenceMedium: "Средний (сбалансированный)",
+    confidenceHigh: "Высокий",
+    confidenceHighest: "Очень высокий (сообщает позже, меньше ложных срабатываний)",
+    printQuality: "Качество печати",
+    tooltipQuality: "Обнаружение сбоев с помощью ИИ: {{status}} (качество печати {{quality}}/10) - нажмите для подробностей",
+    apiKeyConfigured: "Оставьте пустым, чтобы сохранить текущий ключ",
+    notificationsMissing: "Оповещения не настроены. Чтобы получать их, включите «Обнаружение сбоев с помощью ИИ» в настройках провайдера уведомлений.",
+    notificationsUncovered: "Оповещения не настроены для принтеров: {{printers}}.",
+    configureNotifications: "Настроить уведомления",
+    monitoringStatus: "Мониторинг",
+    monitoringDisabled: "Отключён",
+    monitoringMissingKey: "Требуется ключ API Gadget",
+    monitoringStopped: "Служба недоступна",
+    monitoringWaiting: "Ожидание первого результата",
+    monitoringObserved: "Есть результаты обнаружения",
+    monitoringError: "Требует внимания",
+    inspectionInterval: "Интервал проверки",
+    inspectionIntervalHint: "Более частые проверки позволяют раньше обнаруживать сбои, но расходуют больше кредитов API.",
+    limitReached: "Достигнут лимит использования.",
+    setupBilling: "Настройте оплату, чтобы продолжить",
+    getApiKey: "Получить ключ API Gadget",
+  },
+
   modelSources: {
     title: "Источники моделей",
     tabMakerworld: "MakerWorld",

+ 40 - 1
frontend/src/i18n/locales/sv.ts

@@ -6957,7 +6957,7 @@ errors: {
     printerOffline: 'Skrivare offline',
     printerError: 'Skrivarfel',
     aiFailureDetection: 'AI-felsdetektering',
-    aiFailureDetectionDescription: 'Notis när Obico AI upptäcker ett möjligt utskriftsfel',
+    aiFailureDetectionDescription: 'Notis när AI upptäcker ett möjligt utskriftsfel',
     lowFilamentLabel: 'Lågt filament',
     maintenanceDue: 'Underhåll förfallit',
     maintenanceDueDescription: 'Notis när underhåll behövs',
@@ -8025,6 +8025,9 @@ errors: {
     unexpectedError: 'Ett oväntat fel uppstod',
   },
   failureDetection: {
+    provider: 'Leverantör',
+    providerObico: 'Obico',
+    providerOctoEverywhere: 'OctoEverywhere',
     title: 'AI-feldetektering',
     description: 'Övervaka utskrifter med ett självhostat Obico ML-API och agera automatiskt på upptäckta fel.',
     mlUrl: 'Obico ML-API-URL',
@@ -8064,6 +8067,42 @@ errors: {
     noHistory: 'Inga upptäckter än.',
   },
 
+  octoeverywhere: {
+    title: 'AI-detektering med OctoEverywhere',
+    description: 'Upptäck utskriftsfel automatiskt med OctoEverywhere.',
+    learnMore: 'Läs mer',
+    apiKey: 'Gadget-API-nyckel',
+    apiKeyPlaceholder: 'Klistra in din OctoEverywhere Gadget-API-nyckel',
+    apiKeyHint: 'Kamerabilder från övervakade utskrifter skickas till OctoEverywhere för analys. Bilderna raderas efter AI-kontrollen och används inte för att träna AI.',
+    privacyPolicy: 'Integritetspolicy',
+    testSuccess: 'OctoEverywhere Gadget-API-nyckeln har verifierats!',
+    confidence: 'Konfidens',
+    confidenceHint: 'Lägre konfidens rapporterar problem tidigare men kan ge fler falska larm; högre konfidens tar längre tid men är säkrare.',
+    confidenceLowest: 'Lägst (rapporterar tidigare, fler falska larm)',
+    confidenceLow: 'Låg',
+    confidenceMedium: 'Medium (balanserad)',
+    confidenceHigh: 'Hög',
+    confidenceHighest: 'Högst (rapporterar senare, färre falska larm)',
+    printQuality: 'Utskriftskvalitet',
+    tooltipQuality: 'AI-feldetektering: {{status}} (utskriftskvalitet {{quality}}/10) - klicka för detaljer',
+    apiKeyConfigured: 'Lämna tomt för att behålla nuvarande',
+    notificationsMissing: 'Notiser är inte konfigurerade. Aktivera AI-feldetektering hos en notisleverantör för att få notiser.',
+    notificationsUncovered: 'Notiser är inte konfigurerade för: {{printers}}.',
+    configureNotifications: 'Konfigurera notiser',
+    monitoringStatus: 'Övervakning',
+    monitoringDisabled: 'Inaktiverad',
+    monitoringMissingKey: 'Gadget-API-nyckel krävs',
+    monitoringStopped: 'Tjänsten är inte tillgänglig',
+    monitoringWaiting: 'Väntar på det första resultatet',
+    monitoringObserved: 'Detekteringsresultat finns',
+    monitoringError: 'Övervakningen behöver åtgärdas',
+    inspectionInterval: 'Kontrollintervall',
+    inspectionIntervalHint: 'Tätare kontroller upptäcker fel tidigare men använder fler API-krediter.',
+    limitReached: 'Användningsgränsen har nåtts.',
+    setupBilling: 'Konfigurera fakturering för att fortsätta',
+    getApiKey: 'Skaffa din Gadget-API-nyckel',
+  },
+
   modelSources: {
     title: 'Modellkällor',
     tabMakerworld: 'MakerWorld',

+ 40 - 1
frontend/src/i18n/locales/tr.ts

@@ -6864,7 +6864,7 @@ export default {
     printerOffline: 'Yazıcı Çevrimdışı',
     printerError: 'Yazıcı Hatası',
     aiFailureDetection: 'AI Hata Tespiti',
-    aiFailureDetectionDescription: 'Obico AI olası bir baskı hatası tespit ettiğinde bildir',
+    aiFailureDetectionDescription: 'Yapay zekâ olası bir baskı hatası tespit ettiğinde bildir',
     lowFilamentLabel: 'Az Filament',
     maintenanceDue: 'Bakım Zamanı',
     maintenanceDueDescription: 'Bakım gerektiğinde bildir',
@@ -7915,6 +7915,9 @@ export default {
     unexpectedError: 'Beklenmedik bir hata oluştu',
   },
   failureDetection: {
+    provider: 'Sağlayıcı',
+    providerObico: 'Obico',
+    providerOctoEverywhere: 'OctoEverywhere',
     title: 'AI Başarısızlık Algılama',
     description: 'Baskıları kendi barındırılan bir Obico ML API ile izle ve algılanan başarısızlıklara otomatik olarak yanıt ver.',
     mlUrl: 'Obico ML API URL\'si',
@@ -7954,6 +7957,42 @@ export default {
     noHistory: 'Henüz algılama yok.',
   },
 
+  octoeverywhere: {
+    title: 'OctoEverywhere AI Hata Tespiti',
+    description: 'Baskı hatalarını OctoEverywhere ile otomatik olarak tespit edin.',
+    learnMore: 'Daha fazla bilgi',
+    apiKey: 'Gadget API anahtarı',
+    apiKeyPlaceholder: 'OctoEverywhere Gadget API anahtarınızı yapıştırın',
+    apiKeyHint: 'İzlenen baskıların kamera görüntüleri analiz için OctoEverywhere hizmetine gönderilir. Görüntüler yapay zekâ kontrolünden sonra silinir ve yapay zekâ eğitimi için kullanılmaz.',
+    privacyPolicy: 'Gizlilik politikası',
+    testSuccess: 'OctoEverywhere Gadget API anahtarı başarıyla doğrulandı!',
+    confidence: 'Güven düzeyi',
+    confidenceHint: 'Düşük güven düzeyinde sorunlar daha erken bildirilir ancak yanlış pozitifler artabilir; yüksek güven düzeyinde süre uzar ancak sonuç daha kesin olur.',
+    confidenceLowest: 'En düşük (daha erken bildirir, daha fazla yanlış pozitif)',
+    confidenceLow: 'Düşük',
+    confidenceMedium: 'Orta (dengeli)',
+    confidenceHigh: 'Yüksek',
+    confidenceHighest: 'En yüksek (daha geç bildirir, daha az yanlış pozitif)',
+    printQuality: 'Baskı kalitesi',
+    tooltipQuality: 'AI Başarısızlık Algılama: {{status}} (baskı kalitesi {{quality}}/10) - ayrıntılar için tıklayın',
+    apiKeyConfigured: 'Mevcut anahtarı korumak için boş bırakın',
+    notificationsMissing: 'Uyarılar yapılandırılmamış. Uyarı almak için bir bildirim sağlayıcısında "AI Hata Tespiti" seçeneğini etkinleştirin.',
+    notificationsUncovered: 'Şu yazıcılar için uyarılar yapılandırılmamış: {{printers}}.',
+    configureNotifications: 'Bildirimleri yapılandır',
+    monitoringStatus: 'İzleme',
+    monitoringDisabled: 'Devre dışı',
+    monitoringMissingKey: 'Gadget API anahtarı gerekli',
+    monitoringStopped: 'Hizmet kullanılamıyor',
+    monitoringWaiting: 'İlk sonuç bekleniyor',
+    monitoringObserved: 'Algılama sonuçları mevcut',
+    monitoringError: 'Dikkat gerektiriyor',
+    inspectionInterval: 'Kontrol aralığı',
+    inspectionIntervalHint: 'Daha sık kontrol, hataları daha erken tespit eder ancak daha fazla API kredisi harcar.',
+    limitReached: 'Kullanım sınırına ulaşıldı.',
+    setupBilling: 'Devam etmek için faturalandırmayı ayarlayın',
+    getApiKey: 'Gadget API anahtarınızı alın',
+  },
+
   modelSources: {
     title: 'Model Kaynakları',
     tabMakerworld: 'MakerWorld',

+ 40 - 1
frontend/src/i18n/locales/uk.ts

@@ -6949,7 +6949,7 @@ export default {
     printerOffline: "Принтер не в мережі",
     printerError: "Помилка принтера",
     aiFailureDetection: "Виявлення помилок за допомогою ШІ",
-    aiFailureDetectionDescription: "Сповіщати, коли ШІ Obico виявляє можливу помилку друку",
+    aiFailureDetectionDescription: "Сповіщати, коли ШІ виявляє можливий збій друку",
     lowFilamentLabel: "Закінчується філамент",
     maintenanceDue: "Настав час технічного обслуговування",
     maintenanceDueDescription: "Повідомити, коли потрібне обслуговування",
@@ -8017,6 +8017,9 @@ export default {
     unexpectedError: "Сталася неочікувана помилка",
   },
   failureDetection: {
+    provider: "Постачальник",
+    providerObico: "Obico",
+    providerOctoEverywhere: "OctoEverywhere",
     title: "Виявлення помилок за допомогою ШІ",
     description: "Відстежуйте друк за допомогою самостійно розгорнутого Obico ML API та автоматично реагуйте на виявлені помилки.",
     mlUrl: "URL Obico ML API",
@@ -8056,6 +8059,42 @@ export default {
     noHistory: "Виявлень ще немає.",
   },
 
+  octoeverywhere: {
+    title: "Виявлення помилок за допомогою ШІ від OctoEverywhere",
+    description: "Автоматично виявляйте збої друку за допомогою OctoEverywhere.",
+    learnMore: "Докладніше",
+    apiKey: "Ключ API Gadget",
+    apiKeyPlaceholder: "Вставте ключ API Gadget від OctoEverywhere",
+    apiKeyHint: "Знімки з камери під час відстежуваного друку надсилаються до OctoEverywhere на аналіз. Після перевірки знімки видаляються й не використовуються для навчання ШІ.",
+    privacyPolicy: "Політика конфіденційності",
+    testSuccess: "Ключ API Gadget OctoEverywhere успішно перевірено!",
+    confidence: "Рівень впевненості",
+    confidenceHint: "За низького рівня впевненості про проблеми повідомляється раніше, але помилкових спрацьовувань може бути більше; за високого — пізніше, зате надійніше.",
+    confidenceLowest: "Дуже низький (повідомляє раніше, більше помилкових спрацьовувань)",
+    confidenceLow: "Низький",
+    confidenceMedium: "Середній (збалансований)",
+    confidenceHigh: "Високий",
+    confidenceHighest: "Дуже високий (повідомляє пізніше, менше помилкових спрацьовувань)",
+    printQuality: "Якість друку",
+    tooltipQuality: "Виявлення помилок за допомогою ШІ: {{status}} (якість друку {{quality}}/10) — натисніть, щоб переглянути деталі",
+    apiKeyConfigured: "Залиште порожнім, щоб не змінювати",
+    notificationsMissing: "Сповіщення не налаштовано. Щоб отримувати їх, увімкніть «Виявлення помилок за допомогою ШІ» в налаштуваннях постачальника сповіщень.",
+    notificationsUncovered: "Сповіщення не налаштовано для принтерів: {{printers}}.",
+    configureNotifications: "Налаштувати сповіщення",
+    monitoringStatus: "Моніторинг",
+    monitoringDisabled: "Вимкнено",
+    monitoringMissingKey: "Потрібен ключ API Gadget",
+    monitoringStopped: "Служба недоступна",
+    monitoringWaiting: "Очікування першого результату",
+    monitoringObserved: "Є результати виявлення",
+    monitoringError: "Потребує уваги",
+    inspectionInterval: "Інтервал перевірки",
+    inspectionIntervalHint: "Частіші перевірки дають змогу раніше виявляти збої, але витрачають більше кредитів API.",
+    limitReached: "Досягнуто ліміту використання.",
+    setupBilling: "Налаштуйте оплату, щоб продовжити",
+    getApiKey: "Отримати ключ API Gadget",
+  },
+
   modelSources: {
     title: "Джерела моделей",
     tabMakerworld: "MakerWorld",

+ 40 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -6894,7 +6894,7 @@ export default {
     printerOffline: '打印机离线',
     printerError: '打印机错误',
     aiFailureDetection: 'AI 失败检测',
-    aiFailureDetectionDescription: '当 Obico AI 检测到可能的打印失败时通知',
+    aiFailureDetectionDescription: '当 AI 检测到可能的打印失败时通知',
     lowFilamentLabel: '耗材不足',
     maintenanceDue: '需要维护',
     maintenanceDueDescription: '需要维护时通知',
@@ -7961,6 +7961,9 @@ export default {
     unexpectedError: '发生了意外错误',
   },
   failureDetection: {
+    provider: '服务提供商',
+    providerObico: 'Obico',
+    providerOctoEverywhere: 'OctoEverywhere',
     title: 'AI 故障检测',
     description: '通过自托管的 Obico ML API 监控打印,并对检测到的故障自动采取行动。',
     mlUrl: 'Obico ML API 地址',
@@ -7999,6 +8002,42 @@ export default {
     historyTitle: '最近检测',
     noHistory: '暂无检测记录。',
   },
+  octoeverywhere: {
+    title: 'OctoEverywhere AI 检测',
+    description: '使用 OctoEverywhere 自动检测打印失败。',
+    learnMore: '了解更多',
+    apiKey: 'Gadget API 密钥',
+    apiKeyPlaceholder: '粘贴您的 OctoEverywhere Gadget API 密钥',
+    apiKeyHint: '受监控打印任务的摄像头图像将发送至 OctoEverywhere 进行分析。图像在 AI 检查完成后即被删除,且不会用于 AI 训练。',
+    privacyPolicy: '隐私政策',
+    testSuccess: 'OctoEverywhere Gadget API 密钥验证成功!',
+    confidence: '置信度',
+    confidenceHint: '置信度越低,越早报告问题,但误报可能更多;置信度越高,所需时间越长,但结果更可靠。',
+    confidenceLowest: '最低(报告更早,误报更多)',
+    confidenceLow: '低',
+    confidenceMedium: '中(平衡)',
+    confidenceHigh: '高',
+    confidenceHighest: '最高(报告更晚,误报更少)',
+    printQuality: '打印质量',
+    tooltipQuality: 'AI 故障检测:{{status}}(打印质量 {{quality}}/10)- 点击查看详情',
+    apiKeyConfigured: '留空则保留当前密钥',
+    notificationsMissing: '尚未配置通知。如需接收通知,请在通知提供商中启用“AI 失败检测”。',
+    notificationsUncovered: '以下打印机尚未配置通知:{{printers}}。',
+    configureNotifications: '配置通知',
+    monitoringStatus: '监控',
+    monitoringDisabled: '已禁用',
+    monitoringMissingKey: '需要 Gadget API 密钥',
+    monitoringStopped: '服务不可用',
+    monitoringWaiting: '正在等待首个结果',
+    monitoringObserved: '已有检测结果',
+    monitoringError: '需要处理',
+    inspectionInterval: '检查间隔',
+    inspectionIntervalHint: '检查越频繁,越能及早发现打印失败,但会消耗更多 API 额度。',
+    limitReached: '已达到使用限额。',
+    setupBilling: '设置付款方式以继续使用',
+    getApiKey: '获取 Gadget API 密钥',
+  },
+
   modelSources: {
     title: '模型来源',
     tabMakerworld: 'MakerWorld',

+ 40 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -6894,7 +6894,7 @@ export default {
     printerOffline: '印表機離線',
     printerError: '印表機錯誤',
     aiFailureDetection: 'AI 失敗偵測',
-    aiFailureDetectionDescription: '當 Obico AI 偵測到可能的列印失敗時通知',
+    aiFailureDetectionDescription: '當 AI 偵測到可能的列印失敗時通知',
     lowFilamentLabel: '耗材不足',
     maintenanceDue: '需要維護',
     maintenanceDueDescription: '需要維護時通知',
@@ -7961,6 +7961,9 @@ export default {
     unexpectedError: '發生了意外錯誤',
   },
   failureDetection: {
+    provider: '服務提供者',
+    providerObico: 'Obico',
+    providerOctoEverywhere: 'OctoEverywhere',
     title: 'AI 故障檢測',
     description: '透過自託管的 Obico ML API 監控列印,並對偵測到的故障自動採取行動。',
     mlUrl: 'Obico ML API 地址',
@@ -7999,6 +8002,42 @@ export default {
     historyTitle: '最近檢測',
     noHistory: '尚無檢測紀錄。',
   },
+  octoeverywhere: {
+    title: 'OctoEverywhere AI 偵測',
+    description: '使用 OctoEverywhere 自動偵測列印失敗。',
+    learnMore: '瞭解更多',
+    apiKey: 'Gadget API 金鑰',
+    apiKeyPlaceholder: '貼上您的 OctoEverywhere Gadget API 金鑰',
+    apiKeyHint: '受監控列印工作的攝影機影像會傳送至 OctoEverywhere 進行分析。影像會在 AI 檢查完成後刪除,且不會用於 AI 訓練。',
+    privacyPolicy: '隱私權政策',
+    testSuccess: 'OctoEverywhere Gadget API 金鑰驗證成功!',
+    confidence: '信賴度',
+    confidenceHint: '信賴度越低,越早回報問題,但誤報可能較多;信賴度越高,所需時間越長,但結果更可靠。',
+    confidenceLowest: '最低(回報較早,誤報較多)',
+    confidenceLow: '低',
+    confidenceMedium: '中(平衡)',
+    confidenceHigh: '高',
+    confidenceHighest: '最高(回報較晚,誤報較少)',
+    printQuality: '列印品質',
+    tooltipQuality: 'AI 故障檢測:{{status}}(列印品質 {{quality}}/10)- 點擊查看詳情',
+    apiKeyConfigured: '留空則保留目前的金鑰',
+    notificationsMissing: '尚未設定通知。如需接收通知,請在通知提供者中啟用「AI 失敗偵測」。',
+    notificationsUncovered: '以下印表機尚未設定通知:{{printers}}。',
+    configureNotifications: '設定通知',
+    monitoringStatus: '監控',
+    monitoringDisabled: '已停用',
+    monitoringMissingKey: '需要 Gadget API 金鑰',
+    monitoringStopped: '服務無法使用',
+    monitoringWaiting: '正在等待第一個結果',
+    monitoringObserved: '已有偵測結果',
+    monitoringError: '需要處理',
+    inspectionInterval: '檢查間隔',
+    inspectionIntervalHint: '檢查越頻繁,越能及早發現列印失敗,但會消耗更多 API 點數。',
+    limitReached: '已達使用上限。',
+    setupBilling: '設定付款方式以繼續使用',
+    getApiKey: '取得 Gadget API 金鑰',
+  },
+
   modelSources: {
     title: '模型來源',
     tabMakerworld: 'MakerWorld',

+ 33 - 13
frontend/src/pages/PrintersPage.tsx

@@ -179,7 +179,7 @@ import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
 import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors, isSevereHMSError } from '../components/HMSErrorModal';
 import { AiDetectionModal } from '../components/AiDetectionModal';
-import { aiDetectionClass, type AiDetection } from '../utils/aiDetection';
+import { aiDetectionClass, OCTOEVERYWHERE_USAGE_LIMIT, type AiDetection } from '../utils/aiDetection';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { PrinterHASensorRow } from '../components/PrinterHASensorRow';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
@@ -2097,6 +2097,7 @@ function PrinterCard({
   aiDetectionEnabled = false,
   aiDetection,
   aiLastError = null,
+  aiLastErrorCode = null,
 }: {
   printer: Printer;
   hideIfDisconnected?: boolean;
@@ -2138,6 +2139,7 @@ function PrinterCard({
   aiDetectionEnabled?: boolean;
   aiDetection?: AiDetection;
   aiLastError?: string | null;
+  aiLastErrorCode?: string | null;
 }) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -4125,15 +4127,24 @@ function PrinterCard({
                 const title =
                   cls === 'error'
                     ? t('printers.aiDetection.tooltipError', {
-                        reason: aiDetection?.error ?? t('printers.aiDetection.error'),
+                        reason: hasPermission('settings:read')
+                          ? aiDetection?.error_code === OCTOEVERYWHERE_USAGE_LIMIT
+                            ? t('octoeverywhere.limitReached')
+                            : aiDetection?.error ?? t('printers.aiDetection.error')
+                          : t('printers.aiDetection.error'),
                       })
                     : cls === 'unknown'
                       ? t('printers.aiDetection.tooltipUnknown')
                       : aiDetection
-                        ? t('printers.aiDetection.tooltip', {
-                            status: t(`printers.aiDetection.${cls}`),
-                            score: aiDetection.score.toFixed(3),
-                          })
+                        ? 'print_quality' in aiDetection
+                          ? t('octoeverywhere.tooltipQuality', {
+                              status: t(`printers.aiDetection.${cls}`),
+                              quality: aiDetection.print_quality ?? '—',
+                            })
+                          : t('printers.aiDetection.tooltip', {
+                              status: t(`printers.aiDetection.${cls}`),
+                              score: aiDetection.score.toFixed(3),
+                            })
                         : t('printers.aiDetection.tooltipIdle');
                 const Icon = cls === 'error' ? EyeOff : ScanEye;
                 return (
@@ -7309,6 +7320,7 @@ function PrinterCard({
           printerName={printer.name}
           detection={aiDetection}
           lastError={aiLastError}
+          lastErrorCode={aiLastErrorCode}
           onClose={() => setShowAiModal(false)}
         />
       )}
@@ -9101,13 +9113,19 @@ export function PrintersPage() {
     queryFn: api.getObicoPrinterStatus,
     refetchInterval: 10000,
   });
+  const { data: octoEverywherePrinterStatus } = useQuery({
+    queryKey: ['octoeverywhere-printer-status'],
+    queryFn: api.getOctoEverywherePrinterStatus,
+    refetchInterval: 10000,
+  });
+  const aiPrinterStatus = octoEverywherePrinterStatus?.enabled ? octoEverywherePrinterStatus : obicoPrinterStatus;
   // 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],
+      aiPrinterStatus?.enabled === true &&
+      (aiPrinterStatus.monitored_printers === null || aiPrinterStatus.monitored_printers.includes(printerId)),
+    [aiPrinterStatus],
   );
 
   // Fetch Spoolman status to enable link spool feature
@@ -10098,8 +10116,9 @@ export function PrintersPage() {
                       onToggleSelect={toggleSelect}
                       onOpenCompactCard={openCompactCard}
                       aiDetectionEnabled={isAiMonitored(printer.id)}
-                      aiDetection={obicoPrinterStatus?.enabled ? obicoPrinterStatus.per_printer[String(printer.id)] : undefined}
-                      aiLastError={obicoPrinterStatus?.last_error ?? null}
+                      aiDetection={aiPrinterStatus?.enabled ? aiPrinterStatus.per_printer[String(printer.id)] : undefined}
+                      aiLastError={aiPrinterStatus?.last_error ?? null}
+                      aiLastErrorCode={octoEverywherePrinterStatus?.enabled ? octoEverywherePrinterStatus.last_error_code : null}
                     />
                   ))}
                 </div>
@@ -10150,8 +10169,9 @@ export function PrintersPage() {
               onToggleSelect={toggleSelect}
               onOpenCompactCard={openCompactCard}
               aiDetectionEnabled={isAiMonitored(printer.id)}
-              aiDetection={obicoPrinterStatus?.enabled ? obicoPrinterStatus.per_printer[String(printer.id)] : undefined}
-              aiLastError={obicoPrinterStatus?.last_error ?? null}
+              aiDetection={aiPrinterStatus?.enabled ? aiPrinterStatus.per_printer[String(printer.id)] : undefined}
+              aiLastError={aiPrinterStatus?.last_error ?? null}
+              aiLastErrorCode={octoEverywherePrinterStatus?.enabled ? octoEverywherePrinterStatus.last_error_code : null}
             />
           ))}
         </div>

+ 15 - 8
frontend/src/pages/SettingsPage.tsx

@@ -133,7 +133,7 @@ registerSettingsSearch({ labelKey: 'settings.filamentTracking', tab: 'filament',
 registerSettingsSearch({ labelKey: 'settings.catalog.spoolCatalog', labelFallback: 'Spool Catalog', tab: 'filament', keywords: 'spool catalog entries brand material reset import export', anchor: 'card-spool-catalog' });
 registerSettingsSearch({ labelKey: 'settings.colorCatalog.title', labelFallback: 'Color Catalog', tab: 'filament', keywords: 'color catalog hex swatch palette sync reset', anchor: 'card-color-catalog' });
 // Failure detection sub-cards
-registerSettingsSearch({ labelKey: 'settings.tabs.failureDetection', labelFallback: 'Failure Detection', tab: 'failure-detection', keywords: 'failure detection ai ml obico spaghetti detect monitoring', anchor: 'card-fd-ml' });
+registerSettingsSearch({ labelKey: 'settings.tabs.failureDetection', labelFallback: 'Failure Detection', tab: 'failure-detection', keywords: 'failure detection ai ml obico octoeverywhere spaghetti detect monitoring api key confidence', anchor: 'card-fd-provider' });
 registerSettingsSearch({ labelKey: 'failureDetection.perPrinterTitle', labelFallback: 'Per-Printer Settings', tab: 'failure-detection', keywords: 'failure detection per printer enable per-printer sensitivity', anchor: 'card-fd-perprinter' });
 registerSettingsSearch({ labelKey: 'failureDetection.statusTitle', labelFallback: 'Detection Status', tab: 'failure-detection', keywords: 'failure detection status running connection', anchor: 'card-fd-status' });
 registerSettingsSearch({ labelKey: 'failureDetection.historyTitle', labelFallback: 'Detection History', tab: 'failure-detection', keywords: 'failure detection history log events', anchor: 'card-fd-history' });
@@ -312,7 +312,8 @@ export function SettingsPage() {
   const [isRebuildLoading, setIsRebuildLoading] = useState(false);
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
 
-  // Initialize tab from URL params (handle legacy ?tab=email → users tab + email sub-tab)
+  // Read the active tab from the URL so in-page links and browser navigation
+  // stay in sync (handle legacy ?tab=email → users tab + email sub-tab).
   const tabParam = searchParams.get('tab');
   const isLegacyEmailTab = tabParam === 'email';
   // Camera tokens and the overlay builder used to live under API Keys; links
@@ -320,10 +321,9 @@ export function SettingsPage() {
   const canSeeApiKeysTab = hasPermission('api_keys:read') || hasPermission('settings:update');
   const isLegacyCameraLink = tabParam === 'apikeys'
     && ['#card-camera-tokens', '#card-stream-overlay'].includes(window.location.hash);
-  const initialTab = isLegacyEmailTab ? 'users'
+  const activeTab: TabType = isLegacyEmailTab ? 'users'
     : isLegacyCameraLink ? 'camera'
     : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general');
-  const [activeTab, setActiveTab] = useState<TabType>(initialTab);
   // Only Printer access is deep-linked (?tab=users&sub=printer-access), from the printer card menu
   const [usersSubTab, setUsersSubTab] = useState<UsersSubTab>(
     isLegacyEmailTab ? 'email' : tabParam === 'users' && searchParams.get('sub') === 'printer-access' ? 'printer-access' : 'users'
@@ -352,7 +352,6 @@ export function SettingsPage() {
 
   // Update URL when tab changes
   const handleTabChange = (tab: TabType) => {
-    setActiveTab(tab);
     if (tab === 'users') {
       setUsersSubTab('users');
     }
@@ -705,13 +704,21 @@ export function SettingsPage() {
   const spoolbuddyDeviceCount = spoolbuddyDevices?.length ?? 0;
   const spoolbuddyAnyOnline = spoolbuddyDevices?.some((d) => d.online) ?? false;
 
-  // Obico failure-detection service status for tab indicator
+  // 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: octoEverywhereStatus } = useQuery({
+    queryKey: ['octoeverywhere-status'],
+    queryFn: api.getOctoEverywhereStatus,
+    refetchInterval: 10000,
+  });
+  const failureDetectionActive = !!(
+    (obicoStatus?.is_running && obicoStatus?.enabled) ||
+    (octoEverywhereStatus?.is_running && octoEverywhereStatus?.enabled)
+  );
 
   const { data: ffmpegStatus } = useQuery({
     queryKey: ['ffmpeg-status'],
@@ -1704,7 +1711,7 @@ export function SettingsPage() {
       : []),
     { tab: 'virtual-printer', icon: Printer, label: t('settings.tabs.virtualPrinter'), extra: statusDot(!!virtualPrinterRunning) },
     { tab: 'spoolbuddy', icon: Scale, label: t('settings.tabs.spoolbuddy'), extra: <>{countBadge(spoolbuddyDeviceCount)}{statusDot(!!spoolbuddyAnyOnline)}</> },
-    { tab: 'failure-detection', icon: ScanEye, label: t('settings.tabs.failureDetection'), extra: statusDot(!!obicoActive) },
+    { tab: 'failure-detection', icon: ScanEye, label: t('settings.tabs.failureDetection'), extra: statusDot(failureDetectionActive) },
     { tab: 'users', icon: Users, label: t('settings.tabs.users'), extra: authEnabled && <span className="w-2 h-2 rounded-full shrink-0 bg-green-400" /> },
     { tab: 'backup', icon: Database, label: t('settings.tabs.backup'), extra: statusDot(!!((cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled) || settings?.local_backup_enabled)) },
   ] as SettingsTabEntry[]).sort((x, y) => x.label.localeCompare(y.label, i18n.language));

+ 9 - 3
frontend/src/utils/aiDetection.ts

@@ -1,21 +1,27 @@
-// Shared shape + class mapping for the Obico AI failure-detection surfaces
+// Shared shape + class mapping for the AI failure-detection surfaces
 // (the printer card badge and the detail modal), so the two cannot disagree
 // about what a given backend class means.
 
 export type AiDetectionClass = 'failure' | 'warning' | 'safe' | 'error' | 'unknown' | 'idle';
 
-export interface AiDetection {
+export const OCTOEVERYWHERE_USAGE_LIMIT = 'OE_FREE_USAGE_LIMIT_REACHED';
+
+interface AiDetectionState {
   class: string;
   frame_count: number;
-  score: number;
   // Why the most recent poll produced no verdict. null when the last poll
   // succeeded, and also when the viewer lacks settings:read — the backend
   // withholds the reason (it can name configured URLs) but still sends the
   // 'error' class, because "your print is not being watched" is not
   // configuration.
   error?: string | null;
+  error_code?: string | null;
 }
 
+// Provider measurements have different scales: Obico returns a failure score,
+// while OctoEverywhere returns print quality from 1 (worst) to 10 (best).
+export type AiDetection = AiDetectionState & ({ score: number } | { print_quality: number | null });
+
 /**
  * Canonical display class for a printer's detection state.
  *