Browse Source

fix(failure-detection): address OctoEverywhere integration review

Quinn Damerell 3 tuần trước cách đây
mục cha
commit
c30029c978
40 tập tin đã thay đổi với 694 bổ sung và 217 xóa
  1. 0 10
      .basedpyright/baseline.json
  2. 1 1
      CHANGELOG.md
  3. 11 3
      backend/app/api/routes/octoeverywhere.py
  4. 7 5
      backend/app/api/routes/settings.py
  5. 21 0
      backend/app/core/database.py
  6. 23 21
      backend/app/core/printer_scope.py
  7. 1 0
      backend/app/main.py
  8. 1 1
      backend/app/models/notification_template.py
  9. 3 3
      backend/app/schemas/settings.py
  10. 1 1
      backend/app/services/failure_detection_camera.py
  11. 1 13
      backend/app/services/notification_service.py
  12. 4 2
      backend/app/services/octoeverywhere_actions.py
  13. 39 9
      backend/app/services/octoeverywhere_detection.py
  14. 89 9
      backend/tests/integration/test_octoeverywhere_api.py
  15. 26 17
      backend/tests/integration/test_printer_delete_scopes.py
  16. 33 5
      backend/tests/unit/services/test_octoeverywhere_actions.py
  17. 84 0
      backend/tests/unit/test_ai_failure_template_migration_3236.py
  18. 26 0
      backend/tests/unit/test_forget_printer.py
  19. 29 0
      backend/tests/unit/test_github_backup_settings.py
  20. 142 4
      backend/tests/unit/test_octoeverywhere_detection.py
  21. 0 89
      docs/octoeverywhere.md
  22. 56 4
      frontend/src/__tests__/components/OctoEverywhereSettings.test.tsx
  23. 71 2
      frontend/src/__tests__/pages/PrintersPageAiDetection.test.tsx
  24. 7 2
      frontend/src/components/OctoEverywhereSettings.tsx
  25. 1 1
      frontend/src/i18n/locales/de.ts
  26. 1 1
      frontend/src/i18n/locales/en.ts
  27. 1 1
      frontend/src/i18n/locales/es.ts
  28. 1 1
      frontend/src/i18n/locales/fr.ts
  29. 1 1
      frontend/src/i18n/locales/it.ts
  30. 1 1
      frontend/src/i18n/locales/ja.ts
  31. 1 1
      frontend/src/i18n/locales/ko.ts
  32. 1 1
      frontend/src/i18n/locales/nl.ts
  33. 1 1
      frontend/src/i18n/locales/pt-BR.ts
  34. 1 1
      frontend/src/i18n/locales/ru.ts
  35. 1 1
      frontend/src/i18n/locales/sv.ts
  36. 1 1
      frontend/src/i18n/locales/tr.ts
  37. 1 1
      frontend/src/i18n/locales/uk.ts
  38. 1 1
      frontend/src/i18n/locales/zh-CN.ts
  39. 1 1
      frontend/src/i18n/locales/zh-TW.ts
  40. 3 1
      frontend/src/pages/PrintersPage.tsx

+ 0 - 10
.basedpyright/baseline.json

@@ -3282,16 +3282,6 @@
                 }
             }
         ],
-        "./backend/app/services/obico_detection.py": [
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 16,
-                    "endColumn": 44,
-                    "lineCount": 1
-                }
-            }
-        ],
         "./backend/app/services/opentag3d.py": [
             {
                 "code": "reportArgumentType",

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 1 - 1
CHANGELOG.md


+ 11 - 3
backend/app/api/routes/octoeverywhere.py

@@ -28,12 +28,17 @@ class TestConnectionRequest(BaseModel):
 @router.get("/status")
 async def get_status(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
-    db: AsyncSession = Depends(get_db),
+    db: AsyncSession = Depends(get_db, scope="function"),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """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")
+    providers = await notification_service._get_providers_for_event(db, "on_ai_failure_detection", fleet_wide=True)
+    providers = [provider for provider in providers if printer_scope.allows(provider.printer_id)]
     printers = select(Printer.id).where(Printer.is_active.is_(True))
+    scope_filter = printer_scope.where_strict(Printer.id)
+    if scope_filter is not None:
+        printers = printers.where(scope_filter)
     if settings["enabled_printers"] is not None:
         printers = printers.where(Printer.id.in_(settings["enabled_printers"]))
     printer_ids = (await db.execute(printers)).scalars().all()
@@ -42,8 +47,11 @@ async def get_status(
     uncovered_printers = (
         sorted(pid for pid in printer_ids if pid not in covered_printers) if None not in covered_printers else []
     )
+    status = octoeverywhere_detection_service.get_status()
     return {
-        **octoeverywhere_detection_service.get_status(),
+        **status,
+        "per_printer": {pid: entry for pid, entry in status["per_printer"].items() if printer_scope.allows(int(pid))},
+        "history": [entry for entry in status["history"] if printer_scope.allows(entry["printer_id"])],
         "enabled": settings["enabled"],
         "api_key_configured": bool(settings["api_key"]),
         "confidence": settings["confidence"],

+ 7 - 5
backend/app/api/routes/settings.py

@@ -421,15 +421,17 @@ async def update_settings(
     # printer created (the validator has already checked it is a list of ints).
     # Only ids not stored yet are checked: the page sends the whole list, and an
     # id already there must not block every save.
-    if update_data.get("obico_enabled_printers"):
+    for key in ("obico_enabled_printers", "octoeverywhere_enabled_printers"):
+        if not update_data.get(key):
+            continue
         from backend.app.core.printer_scope import ensure_printers_exist
 
         try:
-            stored = json.loads(await get_setting(db, "obico_enabled_printers") or "[]")
+            stored_printers = json.loads(await get_setting(db, key) or "[]")
         except ValueError:
-            stored = []
-        stored = set(stored) if isinstance(stored, list) else set()
-        added = [i for i in json.loads(update_data["obico_enabled_printers"]) if i not in stored]
+            stored_printers = []
+        stored_printers = set(stored_printers) if isinstance(stored_printers, list) else set()
+        added = [i for i in json.loads(update_data[key]) if i not in stored_printers]
         await ensure_printers_exist(db, added)
 
     # Safety refusals on disabling local login (#1589). Two failure modes

+ 21 - 0
backend/app/core/database.py

@@ -5782,6 +5782,10 @@ async def _run_migrations(conn):
     # alone (a {password} in it renders as nothing).
     await _migrate_welcome_template_without_password(conn)
 
+    # Migration: use the same AI detection template for either provider (#3236).
+    # Seeding only inserts missing templates; preserve any customized wording.
+    await _migrate_ai_failure_detection_template(conn)
+
     # Migration: supplier master list + spool assignments (#2988).
     # create_all() covers fresh installs; this covers upgrades.
     await _migrate_create_supplier_tables(conn)
@@ -7089,6 +7093,23 @@ async def _migrate_welcome_template_without_password(conn) -> None:
     )
 
 
+async def _migrate_ai_failure_detection_template(conn) -> None:
+    """Replace the legacy confidence-only AI body while preserving custom text."""
+    from sqlalchemy import text
+
+    from backend.app.models.notification_template import DEFAULT_TEMPLATES
+
+    default = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "ai_failure_detection")
+    await conn.execute(
+        text("UPDATE notification_templates SET body_template = :new WHERE event_type = :et AND body_template = :old"),
+        {
+            "new": default["body_template"],
+            "et": "ai_failure_detection",
+            "old": "{printer}: {task_name}\nConfidence: {confidence}\nAction taken: {action}",
+        },
+    )
+
+
 async def _migrate_location_ha_sensor_unique_binding(conn) -> None:
     """Unique index on location_ha_sensors (location_id, entity_id) (#2824).
 

+ 23 - 21
backend/app/core/printer_scope.py

@@ -225,7 +225,7 @@ async def drop_printers_from_api_keys(db, deleted: Iterable[int] | None = None)
 
 
 async def drop_printers_from_obico(db, deleted: Iterable[int] | None = None) -> None:
-    """Take deleted printers out of the printers Obico detection watches.
+    """Take deleted printers out of either AI failure detection provider's list.
 
     As drop_printers_from_api_keys: SQLite would enrol the next printer given a
     stale id, sending its camera to the ML server and pausing its prints. An
@@ -239,27 +239,29 @@ async def drop_printers_from_obico(db, deleted: Iterable[int] | None = None) ->
     from backend.app.models.printer import Printer
     from backend.app.models.settings import Settings
 
-    key = "obico_enabled_printers"
-    raw = (await db.execute(select(Settings.value).where(Settings.key == key))).scalar()
-    if not raw:
-        return
-    try:
-        printer_ids = json.loads(raw)
-    except ValueError:
-        return
-    if not isinstance(printer_ids, list):
-        return
-    if deleted is None:
+    gone = set(deleted) if deleted is not None else None
+    existing = set()
+    if gone is None:
         existing = set((await db.execute(select(Printer.id))).scalars().all())
-        kept = [printer_id for printer_id in printer_ids if printer_id in existing]
-    else:
-        gone = set(deleted)
-        kept = [printer_id for printer_id in printer_ids if printer_id not in gone]
-    if kept != printer_ids:
-        # Written as the settings page writes it ("[1,3]"); "[1, 3]" reads there
-        # as an unsaved change and saves itself again.
-        value = json.dumps(kept, separators=(",", ":"))
-        await db.execute(update(Settings).where(Settings.key == key).values(value=value))
+    for key in ("obico_enabled_printers", "octoeverywhere_enabled_printers"):
+        raw = (await db.execute(select(Settings.value).where(Settings.key == key))).scalar()
+        if not raw:
+            continue
+        try:
+            printer_ids = json.loads(raw)
+        except ValueError:
+            continue
+        if not isinstance(printer_ids, list):
+            continue
+        if gone is None:
+            kept = [printer_id for printer_id in printer_ids if printer_id in existing]
+        else:
+            kept = [printer_id for printer_id in printer_ids if printer_id not in gone]
+        if kept != printer_ids:
+            # Written as the settings page writes it ("[1,3]"); "[1, 3]" reads there
+            # as an unsaved change and saves itself again.
+            value = json.dumps(kept, separators=(",", ":"))
+            await db.execute(update(Settings).where(Settings.key == key).values(value=value))
 
 
 async def ensure_printers_exist(db: AsyncSession, printer_ids: Iterable[int | None] | None) -> None:

+ 1 - 0
backend/app/main.py

@@ -1472,6 +1472,7 @@ async def forget_printer(printer_id: int) -> None:
     layer_timelapse.cancel_session(printer_id)
     usage_tracker.forget_printer(printer_id)
     obico_detection_service.forget_printer(printer_id)
+    octoeverywhere_detection_service.forget_printer(printer_id)
     bambu_ftp.clear_3mf_cache(printer_id)
     await camera.forget_printer(printer_id)
     printers.clear_cover_cache(printer_id)

+ 1 - 1
backend/app/models/notification_template.py

@@ -97,7 +97,7 @@ DEFAULT_TEMPLATES = [
         "event_type": "ai_failure_detection",
         "name": "AI Failure Detection",
         "title_template": "Possible Print Failure Detected",
-        "body_template": "{printer}: {task_name}\nConfidence: {confidence}\nAction taken: {action}",
+        "body_template": "{printer}: {task_name}\nProvider: {provider}\nPrint quality: {print_quality}\nConfidence: {confidence}\nAction taken: {action}",
     },
     {
         "event_type": "plate_not_empty",

+ 3 - 3
backend/app/schemas/settings.py

@@ -892,9 +892,9 @@ class AppSettingsUpdate(BaseModel):
     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)
+    # Unset fields default to None; the settings route rejects an explicit null
+    # for numeric settings before it can be stored.
+    octoeverywhere_poll_interval: int | None = 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

+ 1 - 1
backend/app/services/failure_detection_camera.py

@@ -37,7 +37,7 @@ async def capture_detection_frame(printer_id: int, *, session_factory=async_sess
             return None
         return await capture_external_frame(
             printer.external_camera_url,
-            printer.external_camera_type,
+            printer.external_camera_type or "",
             timeout=timeout,
             snapshot_url=printer.external_camera_snapshot_url,
         )

+ 1 - 13
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 DEFAULT_TEMPLATES, NotificationTemplate
+from backend.app.models.notification_template import 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
@@ -2217,18 +2217,6 @@ class NotificationService:
         }
 
         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,

+ 4 - 2
backend/app/services/octoeverywhere_actions.py

@@ -3,6 +3,8 @@
 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:
+async def execute_action(
+    printer_id: int, action: str, task_name: str, print_quality: int, frame: bytes | None = None
+) -> 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)
+    await execute_detection_action(printer_id, action, task_name, None, frame, print_quality=print_quality)

+ 39 - 9
backend/app/services/octoeverywhere_detection.py

@@ -167,6 +167,7 @@ def _response_payload(response: httpx.Response) -> dict:
         # retain their context so a transient outage does not reset the model.
         context_missing = (
             response.status_code == 500
+            and isinstance(payload, dict)
             and error_type == "OE_INTERNAL_ERROR"
             and payload.get("ErrorDetails") == "Failed to get context object."
         )
@@ -201,6 +202,8 @@ class OctoEverywhereDetectionService:
         self._checks: dict[int, asyncio.Task] = {}
         self._states: dict[int, _PrintState] = {}
         self._configuration: tuple[str, str] | None = None
+        self._settings: dict | None = None
+        self._settings_refresh_at = 0.0
         self._generation = 0
         self._blocked_api_key: str | None = None
         self._blocked_error_code: str | None = None
@@ -253,6 +256,7 @@ class OctoEverywhereDetectionService:
         self._states.clear()
         self._generation += 1
         self._configuration = None
+        self._settings = None
         self._clear_account_error()
         self._set_last_error(None)
 
@@ -264,11 +268,22 @@ class OctoEverywhereDetectionService:
         self._states.pop(printer_id, None)
         self._refresh_last_error()
 
+    def forget_printer(self, printer_id: int) -> None:
+        """Forget a deleted printer and reload its committed monitoring limits."""
+        self.reset_printer(printer_id)
+        self._history = deque(
+            (entry for entry in self._history if entry["printer_id"] != printer_id), maxlen=HISTORY_MAX
+        )
+        self._generation += 1
+        self._settings = None
+        self._settings_refresh_at = 0
+
     # ---- settings ----
 
     async def refresh_settings(self):
         """Invalidate in-flight decisions immediately after settings are saved."""
         self._generation += 1
+        self._settings = None
         generation = self._generation
         task = self._task
         restart = task is not None and not task.done()
@@ -285,10 +300,15 @@ class OctoEverywhereDetectionService:
             await asyncio.gather(*checks, return_exceptions=True)
         settings = await self._load_settings()
         if self._generation == generation:
+            self._cache_settings(settings)
             self._apply_settings(settings)
         if restart and self._task is None:
             await self.start()
 
+    def _cache_settings(self, settings: dict):
+        self._settings = settings
+        self._settings_refresh_at = time.monotonic() + settings["poll_interval"]
+
     def _apply_settings(self, settings: dict):
         configuration = (settings["api_key"], settings["confidence"])
         if self._configuration != configuration:
@@ -371,10 +391,16 @@ class OctoEverywhereDetectionService:
     async def _loop(self):
         while True:
             try:
-                generation = self._generation
-                settings = await self._load_settings()
-                if generation != self._generation:
-                    continue
+                # Printer deadlines need one-second resolution, but settings
+                # only need the configured polling cadence or an explicit refresh.
+                if self._settings is None or time.monotonic() >= self._settings_refresh_at:
+                    generation = self._generation
+                    settings = await self._load_settings()
+                    if generation != self._generation:
+                        continue
+                    self._cache_settings(settings)
+                else:
+                    settings = self._settings
                 await self._poll_once(settings)
                 # Enabled printers need whole-second scheduling for values such
                 # as 21 seconds; disabled detection can check settings less often.
@@ -573,7 +599,9 @@ class OctoEverywhereDetectionService:
                 )
                 payload = _response_payload(response)
                 interval = payload.get("NextProcessIntervalSec")
-                minimum_interval = interval.get("Minimum") if isinstance(interval, dict) else None
+                if not isinstance(interval, dict):
+                    raise _ApiError("OctoEverywhere returned an invalid processing interval.")
+                minimum_interval = interval.get("Minimum")
                 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.
@@ -725,21 +753,23 @@ class OctoEverywhereDetectionService:
             # 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)
+                await self._dispatch_action(printer_id, action, task_name, print_quality, frame)
             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)
+            await self._dispatch_action(printer_id, "notify", task_name, print_quality, frame)
             state.warning_fired = True
 
-    async def _dispatch_action(self, printer_id: int, action: str, task_name: str, print_quality: int):
+    async def _dispatch_action(
+        self, printer_id: int, action: str, task_name: str, print_quality: int, frame: bytes | None = None
+    ):
         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)
+            await execute_action(printer_id, action, task_name, print_quality, frame)
         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__)

+ 89 - 9
backend/tests/integration/test_octoeverywhere_api.py

@@ -1,5 +1,6 @@
 """Settings, permissions, and status routes for OctoEverywhere detection."""
 
+import json
 from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
@@ -22,7 +23,10 @@ def clear_detection_state():
 
 
 @pytest.mark.integration
-async def test_settings_round_trip_and_provider_switch(async_client):
+async def test_settings_round_trip_and_provider_switch(async_client, printer_factory):
+    first = await printer_factory()
+    second = await printer_factory()
+    printer_ids = [second.id, first.id]
     settings = (await async_client.get("/api/v1/settings/")).json()
     assert settings["octoeverywhere_enabled"] is False
     assert settings["octoeverywhere_api_key"] == ""
@@ -39,7 +43,7 @@ async def test_settings_round_trip_and_provider_switch(async_client):
             "octoeverywhere_api_key": "  test-key  ",
             "octoeverywhere_confidence": "high",
             "octoeverywhere_action": "pause",
-            "octoeverywhere_enabled_printers": "[3, 1]",
+            "octoeverywhere_enabled_printers": json.dumps(printer_ids),
             "octoeverywhere_poll_interval": 15,
         },
     )
@@ -56,7 +60,7 @@ async def test_settings_round_trip_and_provider_switch(async_client):
     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]
+    assert cards["monitored_printers"] == sorted(printer_ids)
     for key in ("api_key", "api_key_configured", "action", "history", "confidence"):
         assert key not in cards
 
@@ -218,14 +222,22 @@ async def test_printer_status_respects_error_permissions(can_see_error):
 
 
 @pytest.mark.integration
-async def test_printer_status_hides_diagnostics_from_api_keys(async_client):
+@pytest.mark.parametrize("can_see_error", [False, True])
+async def test_printer_status_checks_api_key_owner_permissions(async_client, can_see_error):
     from backend.app.core.auth import ApiKeyActor, get_request_actor
+    from backend.app.core.permissions import Permission
     from backend.app.main import app
     from backend.app.models.api_key import APIKey
+    from backend.app.models.group import Group
+    from backend.app.models.user import User
 
     # 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)
+    permissions = [Permission.PRINTERS_READ.value]
+    if can_see_error:
+        permissions.append(Permission.SETTINGS_READ.value)
+    owner = User(id=1, username="viewer", role="user", groups=[Group(name="Viewer", permissions=permissions)])
+    actor = ApiKeyActor(APIKey(can_read_status=True), owner=owner)
     service._last_error = "private diagnostic"
     service._last_error_code = "OE_FREE_USAGE_LIMIT_REACHED"
     entry = {
@@ -245,10 +257,10 @@ async def test_printer_status_hides_diagnostics_from_api_keys(async_client):
         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
+        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)
     finally:
         app.dependency_overrides.pop(get_request_actor, None)
 
@@ -278,6 +290,74 @@ async def test_printer_status_hides_printers_outside_caller_scope(async_client,
         app.dependency_overrides.pop(get_printer_scope_if_auth_enabled, None)
 
 
+@pytest.mark.integration
+@pytest.mark.parametrize("allowed_ids", [None, frozenset({1}), frozenset()])
+@pytest.mark.parametrize("provider_printer_id", [None, 1, 2])
+async def test_status_limits_history_and_notification_readiness_to_caller_scope(
+    async_client, db_session, allowed_ids, provider_printer_id
+):
+    from backend.app.core.auth import get_printer_scope_if_auth_enabled
+    from backend.app.main import app
+    from backend.app.models.notification import NotificationProvider
+    from backend.app.models.printer import Printer
+
+    for pid in (1, 2):
+        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=True,
+            )
+        )
+    await db_session.flush()
+    db_session.add(
+        NotificationProvider(
+            name="Alerts",
+            provider_type="ntfy",
+            config="{}",
+            enabled=True,
+            on_ai_failure_detection=True,
+            printer_id=provider_printer_id,
+        )
+    )
+    await db_session.commit()
+
+    entry = {"class": "safe", "print_quality": 95, "frame_count": 1, "error": None, "error_code": None}
+    status = {
+        "is_running": True,
+        "last_error": None,
+        "last_error_code": None,
+        "per_printer": {1: entry, 2: entry},
+        "history": [
+            {"printer_id": 2, "task_name": "Private print"},
+            {"printer_id": 1, "task_name": "Allowed print"},
+        ],
+    }
+    app.dependency_overrides[get_printer_scope_if_auth_enabled] = lambda: PrinterScope(allowed_ids)
+    try:
+        with patch.object(service, "get_status", return_value=status):
+            response = await async_client.get("/api/v1/octoeverywhere/status")
+        assert response.status_code == 200
+        result = response.json()
+        visible_ids = {1, 2} if allowed_ids is None else allowed_ids
+        assert set(result["per_printer"]) == {str(pid) for pid in visible_ids}
+        assert result["history"] == [entry for entry in status["history"] if entry["printer_id"] in visible_ids]
+        assert result["notifications"] == {
+            "configured": provider_printer_id is None or provider_printer_id in visible_ids,
+            "uncovered_printers": ([] if provider_printer_id is None else sorted(visible_ids - {provider_printer_id})),
+        }
+        if 2 not in visible_ids:
+            assert "Private print" not in response.text
+        # Filtering a response must not remove another caller's service state.
+        assert set(status["per_printer"]) == {1, 2}
+        assert len(status["history"]) == 2
+    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

+ 26 - 17
backend/tests/integration/test_printer_delete_scopes.py

@@ -180,7 +180,8 @@ async def test_a_virtual_printer_aimed_at_the_printer_goes_with_it(async_client:
     removed.assert_awaited_once()
 
 
-async def test_obico_detection_drops_the_deleted_printer(async_client: AsyncClient, db_session):
+@pytest.mark.parametrize("provider", ["obico", "octoeverywhere"])
+async def test_ai_detection_drops_the_deleted_printer(async_client: AsyncClient, db_session, provider):
     """SQLite would otherwise enrol the next printer given its id."""
     from backend.app.models.settings import Settings
 
@@ -188,26 +189,27 @@ async def test_obico_detection_drops_the_deleted_printer(async_client: AsyncClie
     c = Printer(name="C", serial_number="SCOPE-C", ip_address="192.0.2.23", access_code="x")
     db_session.add(c)
     await db_session.commit()
-    db_session.add(Settings(key="obico_enabled_printers", value=f"[{a},{b},{c.id}]"))
+    db_session.add(Settings(key=f"{provider}_enabled_printers", value=f"[{a},{b},{c.id}]"))
     await db_session.commit()
 
     response = await async_client.delete(f"/api/v1/printers/{b}")
 
     assert response.status_code == 200, response.text
-    statement = select(Settings.value).where(Settings.key == "obico_enabled_printers")
+    statement = select(Settings.value).where(Settings.key == f"{provider}_enabled_printers")
     # Written as the settings page writes it, or it reads as an unsaved change and saves again.
     assert (await db_session.execute(statement.execution_options(populate_existing=True))).scalar() == f"[{a},{c.id}]"
 
 
 @pytest.mark.sqlite_only  # stages stale ids with a raw delete, which foreign keys refuse or null out
-async def test_the_startup_repair_drops_deleted_printers_from_obico_and_virtual_printers(db_session):
+@pytest.mark.parametrize("provider", ["obico", "octoeverywhere"])
+async def test_the_startup_repair_drops_deleted_printers_from_detection_and_virtual_printers(db_session, provider):
     from backend.app.models.settings import Settings
     from backend.app.models.virtual_printer import VirtualPrinter
 
     a, b = await _printers(db_session)
     db_session.add_all(
         [
-            Settings(key="obico_enabled_printers", value=f"[{b}]"),
+            Settings(key=f"{provider}_enabled_printers", value=f"[{b}]"),
             VirtualPrinter(name="to B", mode="queue", target_printer_id=b, serial_suffix="391800013"),
             VirtualPrinter(name="any", mode="queue", target_printer_id=None, serial_suffix="391800014"),
         ]
@@ -220,8 +222,10 @@ async def test_the_startup_repair_drops_deleted_printers_from_obico_and_virtual_
     await _drop_deleted_printers_from_limits(await db_session.connection())
     await db_session.commit()
 
-    obico = (await db_session.execute(select(Settings.value).where(Settings.key == "obico_enabled_printers"))).scalar()
-    assert obico == "[]"  # none, not all ("" would be all)
+    selected = (
+        await db_session.execute(select(Settings.value).where(Settings.key == f"{provider}_enabled_printers"))
+    ).scalar()
+    assert selected == "[]"  # none, not all ("" would be all)
     left = (await db_session.execute(select(VirtualPrinter.name))).all()
     assert [name for (name,) in left] == ["any"]
 
@@ -289,11 +293,12 @@ async def test_a_provider_cant_be_limited_to_a_printer_that_doesnt_exist(async_c
     assert 999 not in (await _scopes(db_session))[0].values()
 
 
-async def test_obico_cant_be_given_a_printer_that_doesnt_exist(async_client: AsyncClient, db_session):
+@pytest.mark.parametrize("provider", ["obico", "octoeverywhere"])
+async def test_detection_cant_be_given_a_printer_that_doesnt_exist(async_client: AsyncClient, db_session, provider):
     """A settings page still open from before a delete would save the deleted id back."""
     a, _ = await _printers(db_session)
 
-    response = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": f"[{a}, 999]"})
+    response = await async_client.put("/api/v1/settings/", json={f"{provider}_enabled_printers": f"[{a}, 999]"})
 
     assert response.status_code == 400, response.text
 
@@ -331,19 +336,20 @@ async def test_a_slicer_pipeline_aimed_at_the_printer_loses_it(async_client: Asy
     assert (await db_session.execute(assigned)).scalar() is None
 
 
-async def test_obico_ids_already_stored_dont_block_a_save(async_client: AsyncClient, db_session):
+@pytest.mark.parametrize("provider", ["obico", "octoeverywhere"])
+async def test_detection_ids_already_stored_dont_block_a_save(async_client: AsyncClient, db_session, provider):
     """PostgreSQL kept the id of a printer deleted before deletes dropped it, and
     the settings page sends the whole list back: every save would be refused."""
     from backend.app.models.settings import Settings
 
     a, _ = await _printers(db_session)
-    db_session.add(Settings(key="obico_enabled_printers", value=f"[{a},999]"))
+    db_session.add(Settings(key=f"{provider}_enabled_printers", value=f"[{a},999]"))
     await db_session.commit()
 
-    response = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": f"[{a},999]"})
+    response = await async_client.put("/api/v1/settings/", json={f"{provider}_enabled_printers": f"[{a},999]"})
 
     assert response.status_code == 200, response.text
-    response = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": f"[{a},998]"})
+    response = await async_client.put("/api/v1/settings/", json={f"{provider}_enabled_printers": f"[{a},998]"})
     assert response.status_code == 400, response.text
 
 
@@ -428,19 +434,22 @@ async def test_a_pipeline_cant_be_aimed_at_a_printer_that_doesnt_exist(async_cli
     assert response.status_code == 200, response.text
 
 
-async def test_a_settings_restore_drops_printers_that_are_gone_from_obico(db_session):
+@pytest.mark.parametrize("provider", ["obico", "octoeverywhere"])
+async def test_a_settings_restore_drops_printers_that_are_gone_from_detection(db_session, provider):
     """A backup taken before a printer delete still lists it."""
     from backend.app.models.settings import Settings
     from backend.app.services.github_restore import _CategoryTally, github_restore_service
 
     a, _ = await _printers(db_session)
     await github_restore_service._restore_settings(
-        db_session, {"settings": {"obico_enabled_printers": f"[{a},999]"}}, overwrite=True, tally=_CategoryTally()
+        db_session, {"settings": {f"{provider}_enabled_printers": f"[{a},999]"}}, overwrite=True, tally=_CategoryTally()
     )
     await db_session.commit()
 
-    obico = (await db_session.execute(select(Settings.value).where(Settings.key == "obico_enabled_printers"))).scalar()
-    assert obico == f"[{a}]"
+    selected = (
+        await db_session.execute(select(Settings.value).where(Settings.key == f"{provider}_enabled_printers"))
+    ).scalar()
+    assert selected == f"[{a}]"
 
 
 @pytest.mark.parametrize("via", ["printer", "plug"])

+ 33 - 5
backend/tests/unit/services/test_octoeverywhere_actions.py

@@ -9,10 +9,11 @@ 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):
+@pytest.mark.parametrize("frame", [None, b"flagged-jpeg"])
+async def test_dispatches_action_with_quality_without_fake_confidence(action, frame):
     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)
+        await execute_action(1, action, "benchy", 2, frame)
+    run.assert_awaited_once_with(1, action, "benchy", None, frame, print_quality=2)
 
 
 async def test_pause_and_power_off_also_notify():
@@ -45,7 +46,9 @@ async def test_notification_shows_quality_and_preserves_event_subscription():
         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.args[2] == (
+        "Printer: benchy\nProvider: OctoEverywhere\nPrint quality: 2/10\nConfidence: N/A\nAction taken: notify"
+    )
     assert send.await_args.kwargs["variables"]["confidence"] == "N/A"
     assert template.body_template == original_body
 
@@ -88,7 +91,9 @@ async def test_obico_default_notification_keeps_confidence():
         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"
+    assert send.await_args.args[2] == (
+        "Printer: benchy\nProvider: Obico\nPrint quality: N/A\nConfidence: 0.85\nAction taken: pause"
+    )
 
 
 async def test_ai_notification_template_preview_includes_quality_variables():
@@ -107,3 +112,26 @@ async def test_ai_notification_template_preview_includes_quality_variables():
     )
     assert result.title == "OctoEverywhere: Bambu X1C"
     assert result.body == "Benchy.3mf: 2/10, N/A, pause"
+
+
+async def test_default_ai_template_preview_matches_delivered_message():
+    from backend.app.api.routes.notification_templates import preview_template
+    from backend.app.schemas.notification_template import EventType, TemplatePreviewRequest
+    from backend.app.services.notification_service import NotificationService
+
+    template = NotificationTemplate(**next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "ai_failure_detection"))
+    preview = await preview_template(
+        TemplatePreviewRequest(
+            event_type=EventType.AI_FAILURE_DETECTION,
+            title_template=template.title_template,
+            body_template=template.body_template,
+        )
+    )
+    service = NotificationService()
+    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, "Bambu X1C", "Benchy.3mf", None, "pause", MagicMock(), print_quality=2)
+    assert send.await_args.args[1:3] == (preview.title, preview.body)

+ 84 - 0
backend/tests/unit/test_ai_failure_template_migration_3236.py

@@ -0,0 +1,84 @@
+"""Upgrades and resets use the provider-neutral AI template without losing custom text."""
+
+from unittest.mock import patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+from backend.app.core.database import _migrate_ai_failure_detection_template, seed_notification_templates
+from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
+
+LEGACY_BODY = "{printer}: {task_name}\nConfidence: {confidence}\nAction taken: {action}"
+AI_DEFAULT = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "ai_failure_detection")
+
+
+@pytest.fixture
+async def engine():
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+    async with engine.begin() as conn:
+        await conn.run_sync(NotificationTemplate.__table__.create)
+    try:
+        yield engine
+    finally:
+        await engine.dispose()
+
+
+@pytest.mark.parametrize(
+    ("event_type", "body", "expected_body"),
+    [
+        ("ai_failure_detection", LEGACY_BODY, AI_DEFAULT["body_template"]),
+        ("ai_failure_detection", "Check {printer}: {confidence}", "Check {printer}: {confidence}"),
+        ("printer_error", LEGACY_BODY, LEGACY_BODY),
+    ],
+)
+async def test_migration_updates_only_legacy_ai_body_and_is_idempotent(engine, event_type, body, expected_body):
+    session_factory = async_sessionmaker(engine)
+    async with session_factory() as session:
+        session.add(
+            NotificationTemplate(
+                event_type=event_type,
+                name="My template",
+                title_template="Check {printer}",
+                body_template=body,
+            )
+        )
+        await session.commit()
+
+    for _ in range(2):
+        async with engine.begin() as conn:
+            await _migrate_ai_failure_detection_template(conn)
+
+    async with session_factory() as session:
+        template = (await session.execute(select(NotificationTemplate))).scalar_one()
+        assert template.body_template == expected_body
+        assert template.title_template == "Check {printer}"
+        assert template.name == "My template"
+
+
+async def test_seed_and_reset_share_the_provider_neutral_default(engine):
+    from backend.app.api.routes.notification_templates import reset_template
+
+    session_factory = async_sessionmaker(engine)
+    with patch("backend.app.core.database.async_session", session_factory):
+        await seed_notification_templates()
+
+    async with session_factory() as session:
+        template = (
+            await session.execute(
+                select(NotificationTemplate).where(NotificationTemplate.event_type == "ai_failure_detection")
+            )
+        ).scalar_one()
+        assert template.body_template == AI_DEFAULT["body_template"]
+        assert {"{provider}", "{print_quality}", "{confidence}"} <= set(template.body_template.split())
+        template_id = template.id
+        template.title_template = "Check {printer}"
+        template.body_template = "My custom body"
+        await session.commit()
+
+        with patch("backend.app.api.routes.notification_templates.notification_service.clear_template_cache") as clear:
+            reset = await reset_template(template_id, db=session)
+
+        assert reset.title_template == AI_DEFAULT["title_template"]
+        assert reset.body_template == AI_DEFAULT["body_template"]
+        clear.assert_called_once()

+ 26 - 0
backend/tests/unit/test_forget_printer.py

@@ -211,6 +211,32 @@ async def test_forget_printer_clears_the_services():
     assert not [key for key in printer_file_cache._entries if key[0] == GONE]
 
 
+async def test_forget_printer_cancels_octoeverywhere_detection(monkeypatch):
+    from backend.app.services.octoeverywhere_detection import OctoEverywhereDetectionService, _PrintState
+
+    detector = OctoEverywhereDetectionService()
+    detector._states = {GONE: _PrintState(), KEPT: _PrintState()}
+    detector._settings = {"enabled_printers": {GONE, KEPT}}
+    gone_check, kept_check = _sleeper(), _sleeper()
+    detector._checks = {GONE: gone_check, KEPT: kept_check}
+    monkeypatch.setattr(main, "octoeverywhere_detection_service", detector)
+    try:
+        await main.forget_printer(GONE)
+        await asyncio.sleep(0)
+
+        assert GONE not in detector._states
+        assert GONE not in detector._checks
+        assert gone_check.cancelled()
+        assert KEPT in detector._states
+        assert detector._checks[KEPT] is kept_check
+        assert not kept_check.done()
+        assert detector._settings is None
+    finally:
+        gone_check.cancel()
+        kept_check.cancel()
+        await asyncio.gather(gone_check, kept_check, return_exceptions=True)
+
+
 async def test_the_plate_references_of_a_printer_go(tmp_path, monkeypatch):
     from backend.app.services import plate_detection
 

+ 29 - 0
backend/tests/unit/test_github_backup_settings.py

@@ -0,0 +1,29 @@
+"""Git backups retain detection configuration without exporting credentials."""
+
+from backend.app.models.settings import Settings
+from backend.app.services.github_backup import GitHubBackupService
+from backend.app.services.secret_fields import SECRET_PLACEHOLDER
+
+
+async def test_settings_backup_excludes_credentials(db_session):
+    public_settings = {
+        "octoeverywhere_enabled": "true",
+        "octoeverywhere_confidence": "high",
+        "octoeverywhere_poll_interval": "20",
+    }
+    private_settings = {
+        "octoeverywhere_api_key": "private-gadget-key",
+        "bambu_cloud_token": "private-cloud-token",
+        "auth_secret_key": "private-auth-key",
+        "manyfold_client_secret": "private-manyfold-secret",
+    }
+    db_session.add_all([Settings(key=key, value=value) for key, value in (public_settings | private_settings).items()])
+    await db_session.commit()
+
+    files = {}
+    await GitHubBackupService()._collect_settings(db_session, files)
+
+    assert files["settings/app_settings.json"]["settings"] == public_settings | dict.fromkeys(
+        private_settings, SECRET_PLACEHOLDER
+    )
+    assert all(value not in str(files) for value in private_settings.values())

+ 142 - 4
backend/tests/unit/test_octoeverywhere_detection.py

@@ -124,6 +124,60 @@ def manager():
         yield mocked
 
 
+@pytest.mark.parametrize("ignore_cancellation", [False, True])
+async def test_forget_printer_discards_late_decisions_and_cached_limits(
+    detector, http_client, manager, clock, ignore_cancellation
+):
+    from backend.app.services.octoeverywhere_detection import _PrintState
+
+    configuration = settings(action="pause", enabled_printers={1, 2})
+    http_client.post.side_effect = [
+        httpx.Response(200, json=context_payload()),
+        httpx.Response(200, json=process_payload()),
+    ]
+    await poll_once(detector, configuration)
+    other_state = _PrintState(print_id="another-print")
+    detector._states[2] = other_state
+    detector._history.extend([{"printer_id": 1}, {"printer_id": 2}])
+    detector._cache_settings(configuration)
+    started = asyncio.Event()
+    release = asyncio.Event()
+
+    async def pending_response(url, **kwargs):
+        started.set()
+        try:
+            await release.wait()
+        except asyncio.CancelledError:
+            if not ignore_cancellation:
+                raise
+            await release.wait()
+        return httpx.Response(200, json=process_payload(PauseSuggested=True))
+
+    http_client.post.side_effect = pending_response
+    clock.return_value = 140
+    await detector._poll_once(configuration)
+    check = detector._checks[1]
+    try:
+        await asyncio.wait_for(started.wait(), timeout=1)
+        # Add another printer after scheduling: this deletion must keep its state.
+        detector._states[2] = other_state
+        detector.forget_printer(1)
+        release.set()
+        await asyncio.wait_for(asyncio.gather(check, return_exceptions=True), timeout=1)
+        assert 1 not in detector._states
+        assert 1 not in detector._checks
+        assert detector._states[2] is other_state
+        assert all(entry["printer_id"] != 1 for entry in detector._history)
+        assert any(entry["printer_id"] == 2 for entry in detector._history)
+        assert detector._settings is None
+        assert detector._settings_refresh_at == 0
+        detector._dispatch_action.assert_not_awaited()
+    finally:
+        release.set()
+        detector.stop()
+        await asyncio.gather(check, return_exceptions=True)
+
+
 class TestConnection:
     @pytest.mark.parametrize(
         "confidence, level", [("lowest", 1), ("low", 2), ("medium", 3), ("high", 4), ("highest", 5)]
@@ -452,12 +506,12 @@ class TestProcessing:
             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)
+        assert calls[0].args == (1, "notify", "test-print", 4, FAKE_JPEG)
         if action == "notify":
             assert len(calls) == 1
         else:
             assert len(calls) == 2
-            assert calls[1].args == (1, action, "test-print", 1)
+            assert calls[1].args == (1, action, "test-print", 1, FAKE_JPEG)
         status = detector.get_status()
         assert status["per_printer"][1]["class"] == "failure"
         assert len(status["history"]) == 4
@@ -470,13 +524,15 @@ class TestProcessing:
             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)
+        detector._dispatch_action.assert_awaited_once_with(1, "pause", "test-print", 2, FAKE_JPEG)
 
     @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)
+        frames = [FAKE_JPEG + b"warning", FAKE_JPEG + b"pause"]
+        detector._capture_frame.side_effect = frames
         http_client.post.side_effect = [
             httpx.Response(200, json=context_payload()),
             httpx.Response(200, json=process_payload(WarningSuggested=True, PrintQuality=4)),
@@ -493,6 +549,7 @@ class TestProcessing:
                 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]
+        assert [call.args[5] for call in notify.await_args_list] == frames
 
     @pytest.mark.parametrize(
         "fields",
@@ -947,7 +1004,7 @@ class TestFasterInspectionTiming:
         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)
+        detector._dispatch_action.assert_awaited_once_with(1, "notify", "test-print", 4, FAKE_JPEG)
         assert "private-recommendation" not in "\n".join(
             record.message for record in caplog.records if record.name == MODULE
         )
@@ -2346,6 +2403,87 @@ class TestLifecycle:
             await detector._loop()
         sleep.assert_awaited_once_with(expected_poll)
 
+    @pytest.mark.parametrize("poll_interval", [5, 20, 21, 30])
+    async def test_scheduler_caches_settings_until_configured_poll_interval(self, detector, clock, poll_interval):
+        original = settings(poll_interval=poll_interval)
+        updated = settings(poll_interval=poll_interval, action="pause")
+        detector._load_settings = AsyncMock(side_effect=[original, updated])
+        detector._poll_once = AsyncMock()
+
+        async def advance_time(seconds):
+            clock.return_value += seconds
+            if clock.return_value >= 100 + poll_interval + 2:
+                raise asyncio.CancelledError
+
+        with patch(f"{MODULE}.asyncio.sleep", side_effect=advance_time):
+            await detector._loop()
+
+        assert detector._load_settings.await_count == 2
+        polled_settings = [call.args[0] for call in detector._poll_once.await_args_list]
+        assert polled_settings == [original] * poll_interval + [updated] * 2
+
+    async def test_refresh_replaces_cached_settings_immediately_without_reloading_on_restart(self, detector, clock):
+        original = settings()
+        updated = settings(action="pause")
+        detector._load_settings = AsyncMock(side_effect=[original, updated])
+        polled = asyncio.Event()
+        detector._poll_once = AsyncMock(side_effect=lambda configuration: polled.set())
+        await detector.start()
+        try:
+            await asyncio.wait_for(polled.wait(), timeout=1)
+            detector._load_settings.assert_awaited_once()
+            polled.clear()
+            await detector.refresh_settings()
+            await asyncio.wait_for(polled.wait(), timeout=1)
+            assert detector._load_settings.await_count == 2
+            assert detector._poll_once.await_args.args == (updated,)
+            assert detector._settings is updated
+        finally:
+            task = detector._task
+            detector.stop()
+            await asyncio.gather(task, return_exceptions=True)
+        assert detector._settings is None
+
+    async def test_stale_settings_load_never_populates_cache(self, detector, clock):
+        original = settings()
+        updated = settings(action="pause")
+
+        async def load_settings():
+            if detector._load_settings.await_count == 1:
+                detector._generation += 1
+                return original
+            return updated
+
+        detector._load_settings = AsyncMock(side_effect=load_settings)
+        detector._poll_once = AsyncMock()
+        with patch(f"{MODULE}.asyncio.sleep", new=AsyncMock(side_effect=asyncio.CancelledError)):
+            await detector._loop()
+        assert detector._load_settings.await_count == 2
+        detector._poll_once.assert_awaited_once_with(updated)
+        assert detector._settings is updated
+
+    async def test_concurrent_refresh_keeps_the_latest_settings_in_cache(self, detector, clock):
+        loading = asyncio.Event()
+        release = asyncio.Event()
+        updated = settings(action="pause")
+
+        async def load_settings():
+            if detector._load_settings.await_count == 1:
+                loading.set()
+                await release.wait()
+                return settings()
+            return updated
+
+        detector._load_settings = AsyncMock(side_effect=load_settings)
+        refresh = asyncio.create_task(detector.refresh_settings())
+        try:
+            await asyncio.wait_for(loading.wait(), timeout=1)
+            await detector.refresh_settings()
+        finally:
+            release.set()
+            await asyncio.wait_for(refresh, timeout=1)
+        assert detector._settings is updated
+
     async def test_refresh_cancels_stale_settings_load_before_polling(self, detector, clock):
         loading = asyncio.Event()
         cancelled = asyncio.Event()

+ 0 - 89
docs/octoeverywhere.md

@@ -1,89 +0,0 @@
-# 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.

+ 56 - 4
frontend/src/__tests__/components/OctoEverywhereSettings.test.tsx

@@ -1,9 +1,9 @@
 /** 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 { act, screen, waitFor, fireEvent, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
-import { http, HttpResponse } from 'msw';
+import { delay, http, HttpResponse } from 'msw';
 import { render } from '../utils';
 import { server } from '../mocks/server';
 import { FailureDetectionSettings } from '../../components/FailureDetectionSettings';
@@ -371,10 +371,59 @@ describe('OctoEverywhere settings', () => {
     expect(apiKey).toHaveValue('replacement-key');
   });
 
-  it('does not report a successful connection when settings could not be saved', async () => {
+  it.each(['edit', 'test'] as const)('stops retrying failed autosaves until another %s', async (retry) => {
+    let failSave = true;
+    const attempts: Record<string, unknown>[] = [];
+    const calls: string[] = [];
+    server.use(
+      http.put('/api/v1/settings/', async ({ request }) => {
+        const update = await request.json() as Record<string, unknown>;
+        attempts.push(update);
+        calls.push('save');
+        await delay(50);
+        return failSave
+          ? HttpResponse.json({ detail: 'Cannot save settings' }, { status: 500 })
+          : HttpResponse.json(saveSettings(update));
+      }),
+      http.post('/api/v1/octoeverywhere/test-connection', () => {
+        calls.push('test');
+        return HttpResponse.json({ ok: true, status_code: 200, error: null });
+      }),
+    );
+    render(<FailureDetectionSettings />);
+    const user = userEvent.setup();
+    await waitForForm();
+    await user.selectOptions(screen.getByLabelText('Confidence'), 'high');
+
+    await screen.findByText('Cannot save settings');
+    await waitFor(() => expect(screen.getByLabelText('Confidence')).not.toBeDisabled());
+    await act(async () => { await new Promise((resolve) => setTimeout(resolve, 1200)); });
+    expect(attempts).toHaveLength(1);
+    expect(screen.getAllByText('Cannot save settings')).toHaveLength(1);
+    expect(screen.getByLabelText('Confidence')).toHaveValue('high');
+
+    failSave = false;
+    if (retry === 'edit') {
+      await user.selectOptions(screen.getByLabelText('Confidence'), 'highest');
+      await waitFor(() => expect(savedSettings.octoeverywhere_confidence).toBe('highest'));
+      expect(calls).toEqual(['save', 'save']);
+    } else {
+      await user.click(screen.getByRole('button', { name: 'Test' }));
+      await screen.findByText('OctoEverywhere Gadget API key verification successful!');
+      expect(savedSettings.octoeverywhere_confidence).toBe('high');
+      expect(calls).toEqual(['save', 'save', 'test']);
+    }
+    expect(attempts).toHaveLength(2);
+  });
+
+  it('does not retry or report a successful connection when settings could not be saved', async () => {
     let tested = false;
+    let attempts = 0;
     server.use(
-      http.put('/api/v1/settings/', () => HttpResponse.json({ detail: 'Cannot save settings' }, { status: 500 })),
+      http.put('/api/v1/settings/', () => {
+        attempts++;
+        return 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 });
@@ -387,6 +436,9 @@ describe('OctoEverywhere settings', () => {
     await user.click(screen.getByRole('button', { name: 'Test' }));
 
     expect((await screen.findAllByText('Cannot save settings')).length).toBeGreaterThan(0);
+    await waitFor(() => expect(screen.getByRole('button', { name: 'Test' })).not.toBeDisabled());
+    await act(async () => { await new Promise((resolve) => setTimeout(resolve, 1200)); });
+    expect(attempts).toBe(1);
     expect(tested).toBe(false);
     expect(screen.queryByText('OctoEverywhere Gadget API key verification successful!')).not.toBeInTheDocument();
   });

+ 71 - 2
frontend/src/__tests__/pages/PrintersPageAiDetection.test.tsx

@@ -6,8 +6,9 @@
  * print, class-colored (safe/warning/failure) during one.
  */
 
-import { describe, it, expect, beforeEach } from 'vitest';
-import { screen, waitFor } from '@testing-library/react';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { act, screen, waitFor } from '@testing-library/react';
+import { useQueryClient } from '@tanstack/react-query';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { PrintersPage } from '../../pages/PrintersPage';
@@ -63,6 +64,15 @@ const mockPrinterStatus = {
   vt_tray: [],
 };
 
+function RefreshOctoEverywhereStatus() {
+  const queryClient = useQueryClient();
+  return (
+    <button onClick={() => queryClient.invalidateQueries({ queryKey: ['octoeverywhere-printer-status'] })}>
+      Refresh detection status
+    </button>
+  );
+}
+
 describe('PrintersPage AI detection badge (#1546)', () => {
   beforeEach(() => {
     localStorage.removeItem('printerCardSize');
@@ -82,6 +92,65 @@ describe('PrintersPage AI detection badge (#1546)', () => {
     );
   });
 
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('discovers disabled OctoEverywhere once without continuing to poll', async () => {
+    vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
+    let requests = 0;
+    server.use(
+      http.get('/api/v1/octoeverywhere/printer-status', () => {
+        requests++;
+        return HttpResponse.json({ enabled: false, monitored_printers: null, per_printer: {}, last_error: null });
+      }),
+    );
+
+    render(<PrintersPage />);
+    await screen.findByText('X1 Carbon');
+    await waitFor(() => expect(requests).toBe(1));
+
+    await act(async () => { await vi.advanceTimersByTimeAsync(30000); });
+    expect(requests).toBe(1);
+  });
+
+  it('starts polling when settings invalidate enabled detection and stops again when disabled', async () => {
+    vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] });
+    let enabled = false;
+    let requests = 0;
+    server.use(
+      http.get('/api/v1/octoeverywhere/printer-status', () => {
+        requests++;
+        return HttpResponse.json({ enabled, monitored_printers: [1], per_printer: {}, last_error: null });
+      }),
+    );
+
+    render(<><PrintersPage /><RefreshOctoEverywhereStatus /></>);
+    await screen.findByText('X1 Carbon');
+    await waitFor(() => expect(requests).toBe(1));
+
+    enabled = true;
+    await userEvent.click(screen.getByRole('button', { name: 'Refresh detection status' }));
+    expect(await screen.findByText('Idle')).toBeInTheDocument();
+    expect(requests).toBe(2);
+    await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
+    await waitFor(() => expect(requests).toBe(3));
+
+    enabled = false;
+    await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
+    await waitFor(() => expect(screen.queryByText('Idle')).not.toBeInTheDocument());
+    expect(requests).toBe(4);
+    await act(async () => { await vi.advanceTimersByTimeAsync(30000); });
+    expect(requests).toBe(4);
+
+    enabled = true;
+    await userEvent.click(screen.getByRole('button', { name: 'Refresh detection status' }));
+    expect(await screen.findByText('Idle')).toBeInTheDocument();
+    expect(requests).toBe(5);
+    await act(async () => { await vi.advanceTimersByTimeAsync(10000); });
+    await waitFor(() => expect(requests).toBe(6));
+  });
+
   it('shows the live class for a monitored print and Idle for other monitored printers', async () => {
     server.use(
       http.get('/api/v1/obico/printer-status', () =>

+ 7 - 2
frontend/src/components/OctoEverywhereSettings.tsx

@@ -118,13 +118,18 @@ export function OctoEverywhereSettings() {
     );
   }, [settings, initialized, enabled, pendingApiKey, confidence, action, pollInterval, enabledPrinters]);
 
+  // A failed payload waits for another edit or an explicit Test. Compare the
+  // values so an equivalent payload recreated after a refetch cannot retry it.
+  const currentSaveFailed = saveMutation.isError
+    && JSON.stringify(saveMutation.variables) === JSON.stringify(settingsToSave);
+
   // Auto-save on change (debounced), matching the other detection provider.
   useEffect(() => {
-    if (!hasUnsavedChanges || testing || editingApiKey || saveMutation.isPending || !canUpdate) return;
+    if (!hasUnsavedChanges || currentSaveFailed || 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]);
+  }, [hasUnsavedChanges, currentSaveFailed, settingsToSave, testing, editingApiKey, saveMutation.isPending, canUpdate]);
 
   const handleTest = async () => {
     if (!canUpdate) return;

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

@@ -8024,7 +8024,7 @@ export default {
     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.',
+    apiKeyHint: 'Kamerabilder überwachter Drucke werden zur Analyse an OctoEverywhere gesendet. Laut OctoEverywhere werden die Bilder 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',

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

@@ -8077,7 +8077,7 @@ export default {
     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.',
+    apiKeyHint: 'Camera images from monitored prints are sent to OctoEverywhere for analysis. OctoEverywhere states that 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',

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

@@ -8032,7 +8032,7 @@ export default {
     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.',
+    apiKeyHint: 'Las imágenes de la cámara de las impresiones supervisadas se envían a OctoEverywhere para su análisis. OctoEverywhere afirma que 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',

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

@@ -8011,7 +8011,7 @@ export default {
     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.',
+    apiKeyHint: 'Pendant les impressions surveillées, les images de la caméra sont envoyées à OctoEverywhere pour analyse. OctoEverywhere indique qu\'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',

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

@@ -8010,7 +8010,7 @@ export default {
     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.',
+    apiKeyHint: 'Durante le stampe monitorate, le immagini della telecamera vengono inviate a OctoEverywhere per l\'analisi. OctoEverywhere dichiara che 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',

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

@@ -8024,7 +8024,7 @@ export default {
     learnMore: '詳細はこちら',
     apiKey: 'Gadget API キー',
     apiKeyPlaceholder: 'OctoEverywhere の Gadget API キーを貼り付け',
-    apiKeyHint: '監視中の印刷のカメラ画像は、分析のため OctoEverywhere に送信されます。画像は AI チェック後に削除され、AI の学習には使用されません。',
+    apiKeyHint: '監視中の印刷のカメラ画像は、分析のため OctoEverywhere に送信されます。OctoEverywhere によると、画像は AI チェック後に削除され、AI の学習には使用されません。',
     privacyPolicy: 'プライバシーポリシー',
     testSuccess: 'OctoEverywhere Gadget API キーの確認に成功しました!',
     confidence: '信頼度',

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

@@ -7466,7 +7466,7 @@ export default {
     learnMore: '자세히 알아보기',
     apiKey: 'Gadget API 키',
     apiKeyPlaceholder: 'OctoEverywhere Gadget API 키를 붙여넣으세요',
-    apiKeyHint: '모니터링 중인 인쇄의 카메라 이미지가 분석을 위해 OctoEverywhere로 전송됩니다. 이미지는 AI 검사 후 삭제되며 AI 학습에 사용되지 않습니다.',
+    apiKeyHint: '모니터링 중인 인쇄의 카메라 이미지가 분석을 위해 OctoEverywhere로 전송됩니다. OctoEverywhere는 이미지가 AI 검사 후 삭제되며 AI 학습에 사용되지 않는다고 설명합니다.',
     privacyPolicy: '개인정보 처리방침',
     testSuccess: 'OctoEverywhere Gadget API 키가 성공적으로 확인되었습니다!',
     confidence: '신뢰도',

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

@@ -8075,7 +8075,7 @@ export default {
     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.',
+    apiKeyHint: 'Camerabeelden van bewaakte afdrukken worden voor analyse naar OctoEverywhere verzonden. Volgens OctoEverywhere worden de beelden na de AI-controle verwijderd en niet gebruikt om AI-modellen te trainen.',
     privacyPolicy: 'Privacybeleid',
     testSuccess: 'OctoEverywhere-Gadget-API-sleutel succesvol geverifieerd!',
     confidence: 'Betrouwbaarheid',

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

@@ -8010,7 +8010,7 @@ export default {
     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.',
+    apiKeyHint: 'Durante as impressões monitoradas, as imagens da câmera são enviadas ao OctoEverywhere para análise. O OctoEverywhere afirma que 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',

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

@@ -7650,7 +7650,7 @@ export default {
     learnMore: "Подробнее",
     apiKey: "Ключ API Gadget",
     apiKeyPlaceholder: "Вставьте ключ API Gadget от OctoEverywhere",
-    apiKeyHint: "Снимки с камеры во время отслеживаемой печати отправляются в OctoEverywhere на анализ. После проверки снимки удаляются и не используются для обучения ИИ.",
+    apiKeyHint: "Снимки с камеры во время отслеживаемой печати отправляются в OctoEverywhere на анализ. По заявлению OctoEverywhere, после проверки снимки удаляются и не используются для обучения ИИ.",
     privacyPolicy: "Политика конфиденциальности",
     testSuccess: "Ключ API Gadget OctoEverywhere успешно проверен!",
     confidence: "Уровень уверенности",

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

@@ -8073,7 +8073,7 @@ errors: {
     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.',
+    apiKeyHint: 'Kamerabilder från övervakade utskrifter skickas till OctoEverywhere för analys. OctoEverywhere uppger att bilderna raderas efter AI-kontrollen och inte används för att träna AI.',
     privacyPolicy: 'Integritetspolicy',
     testSuccess: 'OctoEverywhere Gadget-API-nyckeln har verifierats!',
     confidence: 'Konfidens',

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

@@ -7963,7 +7963,7 @@ export default {
     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.',
+    apiKeyHint: 'İzlenen baskıların kamera görüntüleri analiz için OctoEverywhere hizmetine gönderilir. OctoEverywhere, görüntülerin yapay zekâ kontrolünden sonra silindiğini ve yapay zekâ eğitimi için kullanılmadığını belirtir.',
     privacyPolicy: 'Gizlilik politikası',
     testSuccess: 'OctoEverywhere Gadget API anahtarı başarıyla doğrulandı!',
     confidence: 'Güven düzeyi',

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

@@ -8065,7 +8065,7 @@ export default {
     learnMore: "Докладніше",
     apiKey: "Ключ API Gadget",
     apiKeyPlaceholder: "Вставте ключ API Gadget від OctoEverywhere",
-    apiKeyHint: "Знімки з камери під час відстежуваного друку надсилаються до OctoEverywhere на аналіз. Після перевірки знімки видаляються й не використовуються для навчання ШІ.",
+    apiKeyHint: "Знімки з камери під час відстежуваного друку надсилаються до OctoEverywhere на аналіз. За заявою OctoEverywhere, після перевірки знімки видаляються й не використовуються для навчання ШІ.",
     privacyPolicy: "Політика конфіденційності",
     testSuccess: "Ключ API Gadget OctoEverywhere успішно перевірено!",
     confidence: "Рівень впевненості",

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

@@ -8008,7 +8008,7 @@ export default {
     learnMore: '了解更多',
     apiKey: 'Gadget API 密钥',
     apiKeyPlaceholder: '粘贴您的 OctoEverywhere Gadget API 密钥',
-    apiKeyHint: '受监控打印任务的摄像头图像将发送至 OctoEverywhere 进行分析。图像在 AI 检查完成后即被删除,且不会用于 AI 训练。',
+    apiKeyHint: '受监控打印任务的摄像头图像将发送至 OctoEverywhere 进行分析。OctoEverywhere 表示,图像在 AI 检查完成后即被删除,且不会用于 AI 训练。',
     privacyPolicy: '隐私政策',
     testSuccess: 'OctoEverywhere Gadget API 密钥验证成功!',
     confidence: '置信度',

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

@@ -8008,7 +8008,7 @@ export default {
     learnMore: '瞭解更多',
     apiKey: 'Gadget API 金鑰',
     apiKeyPlaceholder: '貼上您的 OctoEverywhere Gadget API 金鑰',
-    apiKeyHint: '受監控列印工作的攝影機影像會傳送至 OctoEverywhere 進行分析。影像會在 AI 檢查完成後刪除,且不會用於 AI 訓練。',
+    apiKeyHint: '受監控列印工作的攝影機影像會傳送至 OctoEverywhere 進行分析。OctoEverywhere 表示,影像會在 AI 檢查完成後刪除,且不會用於 AI 訓練。',
     privacyPolicy: '隱私權政策',
     testSuccess: 'OctoEverywhere Gadget API 金鑰驗證成功!',
     confidence: '信賴度',

+ 3 - 1
frontend/src/pages/PrintersPage.tsx

@@ -9116,7 +9116,9 @@ export function PrintersPage() {
   const { data: octoEverywherePrinterStatus } = useQuery({
     queryKey: ['octoeverywhere-printer-status'],
     queryFn: api.getOctoEverywherePrinterStatus,
-    refetchInterval: 10000,
+    // Discover whether detection is enabled once, then poll only while active.
+    // Settings changes invalidate this query; stale data also refreshes on mount/focus.
+    refetchInterval: (query) => query.state.data?.enabled ? 10000 : false,
   });
   const aiPrinterStatus = octoEverywherePrinterStatus?.enabled ? octoEverywherePrinterStatus : obicoPrinterStatus;
   // Badge visibility: detection enabled AND this printer in the monitored set

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác