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

feat(deficit): backup-aware filament deficit check, colour-strict (#1762)

      When the printer reports ams_filament_backup=True,
      compute_deficit_for_queue_item pools remaining_grams across spools
      matching (preset, colour) on the same printer (scoped per extruder on
      dual-nozzle) before declaring a per-slot shortfall. Identity is strict:
      same slicer_filament preset AND same colour (alpha-normalised). Two
      PETG HF spools in different colours are NOT pooled — the firmware would
      swap correctly but the print would change colour mid-run. Spoolman side
      mirrors the rule via filament.id + color_hex. Backup OFF falls back to
      the pre-PR per-slot accounting line-for-line.

      8 new test cases in TestFilamentDeficitBackupAware pin pool covers,
      pool insufficient, different presets, backup-OFF regression, dual-
      extruder side scoping, no-preset never pairs, colour-strict, and
      alpha-hex normalisation. The 8 pre-existing test_filament_deficit.py
      cases stay green.

      feat(printers): AMS Filament Backup modal with BS-style ring per pair

      Badge click on the Filaments section header (#1766) now opens a
      modal: filament-colour ring per backup pair, material name + rotation
      count in the centre, slot labels distributed around the colour band on
      contrast-aware pills. Closely modelled on Bambu Studio's Auto Refill
      widget. Lone slots are intentionally not listed. R / L badges per ring
      when the extruder map carries two distinct values; collapses to no-
      badge rendering for single-nozzle printers misflagged as dual.

      Esc keypress closes the modal. Theme-aware via CSS variables matching
      AMSHistoryModal. computeBackupGroups helper in utils/amsHelpers
      defensively dedupes duplicate ams.id entries observed on switch-VP
      aggregations.

      10 modal render cases pin: Esc closes / unmount nulls the listener /
      ring renders for pairs and omits lone slots / R-L badges only when
      extruder map has distinct values / empty state / toggle gating.
      13 frontend cases pin computeBackupGroups identity rules.

      feat(printers): active-print P-N pill on AMS slot tiles during RUNNING

      While the printer is mid-print, each AMS slot tile referenced by
      status.ams_mapping carries a small "P1 / P2 / P3" pill in the top-
      right corner, naming which print-slot is mapped to that AMS slot.
      Catches the #1762 comment-2 scenario: a queue job set for "any X1C"
      staged to a printer with mismatched filament, no way to verify mid-
      print. Same wire data (status.ams_mapping is already on the wire) —
      the addition is purely surface.

      The existing ring-bambu-green highlight for effectiveTrayNow keeps its
      meaning (currently extruding RIGHT NOW); the pill is the per-slot
      static assignment for the active print.

      chore(scheduler): log Print Anyway short-circuit at INFO

      _block_on_filament_deficit logs at INFO when it honours
      item.skip_filament_check, so a future "Print Anyway didn't work" report
      (third commenter on #1762 hit this shape) has actionable evidence in
      the standard support bundle without DEBUG. Bundled because the deficit
      fix makes the original symptom disappear for users with backup ON.
maziggy 2 месяцев назад
Родитель
Сommit
29a5abd986
38 измененных файлов с 2365 добавлено и 34 удалено
  1. 0 0
      CHANGELOG.md
  2. 2 0
      backend/app/api/routes/notifications.py
  3. 15 0
      backend/app/core/database.py
  4. 1 0
      backend/app/models/notification.py
  5. 6 0
      backend/app/models/notification_template.py
  6. 5 0
      backend/app/schemas/notification.py
  7. 281 9
      backend/app/services/filament_deficit.py
  8. 39 0
      backend/app/services/notification_service.py
  9. 10 7
      backend/app/services/obico_actions.py
  10. 8 0
      backend/app/services/print_scheduler.py
  11. 355 1
      backend/tests/unit/services/test_filament_deficit.py
  12. 143 0
      backend/tests/unit/services/test_notification_service.py
  13. 113 0
      backend/tests/unit/services/test_obico_actions.py
  14. 7 0
      frontend/scripts/check-i18n-parity.mjs
  15. 50 0
      frontend/src/__tests__/components/AddNotificationModal.test.tsx
  16. 247 0
      frontend/src/__tests__/components/AmsBackupModal.test.tsx
  17. 1 0
      frontend/src/__tests__/components/NotificationProviderCard.test.tsx
  18. 128 0
      frontend/src/__tests__/components/NotificationProviderCardAiFailureDetection.test.tsx
  19. 1 0
      frontend/src/__tests__/components/NotificationProviderCardStockAlerts.test.tsx
  20. 1 0
      frontend/src/__tests__/mocks/handlers.ts
  21. 223 0
      frontend/src/__tests__/pages/PrintersPageBackupGroups.test.ts
  22. 3 0
      frontend/src/api/client.ts
  23. 7 0
      frontend/src/components/AddNotificationModal.tsx
  24. 334 0
      frontend/src/components/AmsBackupModal.tsx
  25. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  26. 15 0
      frontend/src/i18n/locales/de.ts
  27. 15 0
      frontend/src/i18n/locales/en.ts
  28. 15 0
      frontend/src/i18n/locales/es.ts
  29. 15 0
      frontend/src/i18n/locales/fr.ts
  30. 15 0
      frontend/src/i18n/locales/it.ts
  31. 15 0
      frontend/src/i18n/locales/ja.ts
  32. 16 1
      frontend/src/i18n/locales/ko.ts
  33. 15 0
      frontend/src/i18n/locales/pt-BR.ts
  34. 15 0
      frontend/src/i18n/locales/tr.ts
  35. 15 0
      frontend/src/i18n/locales/zh-CN.ts
  36. 15 0
      frontend/src/i18n/locales/zh-TW.ts
  37. 66 16
      frontend/src/pages/PrintersPage.tsx
  38. 139 0
      frontend/src/utils/amsHelpers.ts

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


+ 2 - 0
backend/app/api/routes/notifications.py

@@ -47,6 +47,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         # Printer status events
         "on_printer_offline": provider.on_printer_offline,
         "on_printer_error": provider.on_printer_error,
+        "on_ai_failure_detection": provider.on_ai_failure_detection,
         "on_filament_low": provider.on_filament_low,
         "on_maintenance_due": provider.on_maintenance_due,
         # AMS environmental alarms (regular AMS)
@@ -127,6 +128,7 @@ async def create_notification_provider(
         # Printer status events
         on_printer_offline=provider_data.on_printer_offline,
         on_printer_error=provider_data.on_printer_error,
+        on_ai_failure_detection=provider_data.on_ai_failure_detection,
         on_filament_low=provider_data.on_filament_low,
         on_maintenance_due=provider_data.on_maintenance_due,
         # AMS environmental alarms (regular AMS)

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

@@ -3064,6 +3064,21 @@ async def run_migrations(conn):
             orphan_count,
         )
 
+    # Migration: Add on_ai_failure_detection column to notification_providers (#1794).
+    # Splits Obico AI failure detection out of the multiplexed on_printer_error
+    # event so users can subscribe to spaghetti alerts independently of HMS
+    # hardware-error alerts. Postgres rejects `DEFAULT 0` for BOOLEAN columns.
+    if is_sqlite():
+        await _safe_execute(
+            conn,
+            "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT 0",
+        )
+    else:
+        await _safe_execute(
+            conn,
+            "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT false",
+        )
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 1 - 0
backend/app/models/notification.py

@@ -70,6 +70,7 @@ class NotificationProvider(Base):
     # Event triggers - printer status
     on_printer_offline = Column(Boolean, default=False)
     on_printer_error = Column(Boolean, default=False)  # AMS issues, etc.
+    on_ai_failure_detection = Column(Boolean, default=False)  # Obico spaghetti / failure detection (#1794)
     on_filament_low = Column(Boolean, default=False)
     on_maintenance_due = Column(Boolean, default=False)  # Maintenance reminder
 

+ 6 - 0
backend/app/models/notification_template.py

@@ -73,6 +73,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Printer Error: {error_type}",
         "body_template": "{printer}\n{error_detail}",
     },
+    {
+        "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}",
+    },
     {
         "event_type": "plate_not_empty",
         "name": "Plate Not Empty",

+ 5 - 0
backend/app/schemas/notification.py

@@ -43,6 +43,10 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - printer status
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
     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)",
+    )
     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")
 
@@ -128,6 +132,7 @@ class NotificationProviderUpdate(BaseModel):
     # Event triggers - printer status
     on_printer_offline: bool | None = None
     on_printer_error: bool | None = None
+    on_ai_failure_detection: bool | None = None
     on_filament_low: bool | None = None
     on_maintenance_due: bool | None = None
 

+ 281 - 9
backend/app/services/filament_deficit.py

@@ -30,6 +30,7 @@ from __future__ import annotations
 
 import json
 import logging
+from collections import defaultdict
 from dataclasses import dataclass
 from pathlib import Path
 
@@ -148,6 +149,88 @@ async def _warnings_disabled(db: AsyncSession) -> bool:
         return False
 
 
+def _normalize_color_for_id(raw: str | None) -> str:
+    """Canonicalise a hex colour for identity comparison.
+
+    Strips the leading ``#``, uppercases, and drops the alpha channel when
+    the hex is 8 chars long (``RRGGBBAA``) so a fully-opaque 8-char hex
+    matches a 6-char hex of the same RGB. Empty / None → empty string.
+    """
+    s = (raw or "").strip().lstrip("#").upper()
+    if len(s) == 8:  # RRGGBBAA → strip alpha
+        s = s[:6]
+    return s
+
+
+def _material_identity_internal(spool) -> str:
+    """Strict same-material key for backup-peer matching in internal mode.
+
+    Requires a Bambu filament preset ID (``slicer_filament``, e.g. ``GFA00``)
+    AND a matching colour. The preset identifies the filament profile (PETG
+    HF, PLA Basic, etc.) — same hot-end behaviour — but the firmware's
+    switch logic also requires the spool to be the same colour (otherwise
+    every PETG HF spool would back every other PETG HF spool regardless of
+    colour, which would dye prints mid-run). Spools without a preset
+    (user-tagged / non-Bambu) get a per-spool unique key so they NEVER
+    pair with anything else; without the Bambu preset the firmware can't
+    trust the backup decision.
+    """
+    preset = (spool.slicer_filament or "").strip() if spool else ""
+    if preset:
+        color = _normalize_color_for_id(spool.rgba if spool else None)
+        return f"preset:{preset}|color:{color}"
+    # Unique-per-spool key prevents grouping. Use the spool's primary key so
+    # the same spool always resolves to the same key within a request.
+    spool_id = getattr(spool, "id", None) if spool else None
+    return f"unmatched:{spool_id}"
+
+
+def _material_identity_spoolman(spool: dict | None) -> str:
+    """Strict same-material key for backup-peer matching in Spoolman mode.
+
+    Two spools pair only when they reference the same Spoolman ``filament``
+    catalog entry (same ``filament.id``) AND share the same colour. The
+    catalog entry pins the profile (PETG HF / PLA Basic / ...); the colour
+    pins the variant. Spools without a resolvable filament id get a
+    per-spool unique key so they never pair.
+    """
+    if not spool:
+        return "unmatched:none"
+    filament = spool.get("filament") or {}
+    fil_id = filament.get("id")
+    if isinstance(fil_id, (int, str)) and str(fil_id).strip():
+        # Prefer the per-spool override colour when set (Spoolman lets the user
+        # tag a spool with a colour distinct from the filament catalog
+        # default); fall back to the filament catalog colour.
+        color = _normalize_color_for_id(
+            (spool.get("color_hex") if isinstance(spool.get("color_hex"), str) else None) or filament.get("color_hex")
+        )
+        return f"filament:{fil_id}|color:{color}"
+    spool_id = spool.get("id")
+    return f"unmatched:{spool_id}"
+
+
+def _ams_id_from_global(global_tray_id: int) -> int:
+    """Inverse of ``_global_to_ams_key`` returning ams_id only."""
+    return _global_to_ams_key(global_tray_id)[0]
+
+
+def _extruder_side_for_ams(
+    ams_id: int,
+    ams_extruder_map: dict[str, int],
+    is_dual_extruder: bool,
+) -> int:
+    """Resolve the extruder index (0=right, 1=left) for a given AMS unit.
+
+    Single-extruder printers collapse everything to 0. On dual-extruder
+    printers (H2D / H2C / X2D), the firmware can't cross extruders even with
+    AMS Filament Backup ON, so the pool must be scoped per-side.
+    """
+    if not is_dual_extruder:
+        return 0
+    return int(ams_extruder_map.get(str(ams_id), 0))
+
+
 def _parse_ams_mapping(raw: str | None) -> list[int] | None:
     if not raw:
         return None
@@ -160,6 +243,33 @@ def _parse_ams_mapping(raw: str | None) -> list[int] | None:
     return [v for v in parsed if isinstance(v, int)]
 
 
+async def _get_printer_backup_context(
+    printer_id: int,
+) -> tuple[bool, dict[str, int], bool]:
+    """Return ``(backup_on, ams_extruder_map, is_dual_extruder)`` for the printer.
+
+    Read from the live MQTT state via ``printer_manager`` (no DB round-trip).
+    Defaults conservatively to ``backup_on=False`` when the state is missing
+    or the printer is offline — same fallback as today (per-slot deficit
+    accounting), so an offline printer is never treated as backup-capable.
+    """
+    try:
+        from backend.app.services.printer_manager import printer_manager
+        from backend.app.utils.printer_models import is_dual_nozzle_model
+    except ImportError:
+        return False, {}, False
+
+    state = printer_manager.get_status(printer_id)
+    if state is None:
+        return False, {}, False
+
+    backup_on = state.ams_filament_backup is True
+    ams_extruder_map = dict(state.ams_extruder_map or {})
+    model = printer_manager.get_model(printer_id)
+    is_dual = bool(model and is_dual_nozzle_model(model))
+    return backup_on, ams_extruder_map, is_dual
+
+
 async def compute_deficit_for_queue_item(
     db: AsyncSession,
     item: PrintQueueItem,
@@ -178,6 +288,14 @@ async def compute_deficit_for_queue_item(
       before dispatch; until it does we cannot map slot → tray.
     * Spoolman mode is on but the Spoolman server is unreachable. We do not
       wedge the queue on a network blip.
+
+    #1762: when the printer reports ``ams_filament_backup=True`` in MQTT
+    status, available material is pooled across ALL same-material spools on
+    the printer (within the same extruder side for dual-nozzle models, since
+    firmware can't cross extruders even with the backup bit set). Per-slot
+    shortfalls are then only emitted if the POOL is too small for the
+    print's total required of that material — matching how the printer
+    actually behaves with Filament Backup ON.
     """
     if await _warnings_disabled(db):
         return []
@@ -210,8 +328,27 @@ async def compute_deficit_for_queue_item(
         return []
 
     spoolman_mode = await _is_spoolman_mode(db)
+    backup_on, ams_extruder_map, is_dual = await _get_printer_backup_context(item.printer_id)
+
+    # ------------------------------------------------------------------ phase 1
+    # Resolve each requirement to (ams_id, tray_id, identity, remaining_grams).
+    # Slot identity is the identity of the spool *assigned to that slot*. A
+    # ``None`` remaining means "couldn't determine" — treated as "no deficit"
+    # below (preserved from pre-#1762 behaviour for non-backup paths too).
+    @dataclass
+    class _ReqRow:
+        slot_id: int
+        ams_id: int
+        tray_id: int
+        global_tray_id: int
+        required: float
+        identity: str
+        remaining: float | None
+        filament_type: str
+        extruder: int
+
+    resolved: list[_ReqRow] = []
 
-    deficits: list[FilamentDeficit] = []
     for req in requirements:
         slot_id = req.get("slot_id")
         used_grams = req.get("used_grams")
@@ -227,6 +364,7 @@ async def compute_deficit_for_queue_item(
             continue
         ams_id, tray_id = _global_to_ams_key(global_tray_id)
 
+        identity = "attrs:|||"
         remaining: float | None = None
         if spoolman_mode:
             sm_result = await db.execute(
@@ -239,7 +377,32 @@ async def compute_deficit_for_queue_item(
             sm_assignment = sm_result.scalar_one_or_none()
             if sm_assignment is None:
                 continue
-            remaining = await _spoolman_remaining_grams(sm_assignment.spoolman_spool_id)
+            # Live remaining_weight from Spoolman. The fetch also resolves the
+            # filament identity for pooling (material + colour + name).
+            from backend.app.services.spoolman import (
+                SpoolmanClientError,
+                SpoolmanNotFoundError,
+                get_spoolman_client,
+            )
+
+            try:
+                client = await get_spoolman_client()
+                spool_dict = await client.get_spool(sm_assignment.spoolman_spool_id) if client else None
+            except (SpoolmanNotFoundError, SpoolmanClientError):
+                spool_dict = None
+            except Exception as e:
+                logger.debug("Spoolman fetch failed for spool %s: %s", sm_assignment.spoolman_spool_id, e)
+                spool_dict = None
+            if spool_dict:
+                identity = _material_identity_spoolman(spool_dict)
+                rw = spool_dict.get("remaining_weight")
+                if isinstance(rw, (int, float)) and rw >= 0:
+                    remaining = float(rw)
+                else:
+                    used = spool_dict.get("used_weight")
+                    total = (spool_dict.get("filament") or {}).get("weight")
+                    if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
+                        remaining = max(0.0, float(total) - float(used))
         else:
             internal_result = await db.execute(
                 select(SpoolAssignment)
@@ -254,6 +417,7 @@ async def compute_deficit_for_queue_item(
             if assignment is None or assignment.spool is None:
                 continue
             spool = assignment.spool
+            identity = _material_identity_internal(spool)
             label_weight = float(spool.label_weight or 0)
             weight_used = float(spool.weight_used or 0)
             if label_weight <= 0:
@@ -261,22 +425,130 @@ async def compute_deficit_for_queue_item(
             remaining = max(0.0, label_weight - weight_used)
 
         if remaining is None:
-            # Spoolman unreachable for this spool — skip rather than block.
-            continue
-        if remaining >= float(used_grams):
+            # Unable to determine remaining grams — preserve pre-#1762 behaviour
+            # (don't block on undetermined data).
             continue
 
-        deficits.append(
-            FilamentDeficit(
+        resolved.append(
+            _ReqRow(
                 slot_id=slot_id,
                 ams_id=ams_id,
                 tray_id=tray_id,
+                global_tray_id=global_tray_id,
+                required=float(used_grams),
+                identity=identity,
+                remaining=remaining,
                 filament_type=str(req.get("type", "")),
-                required_grams=float(used_grams),
-                remaining_grams=remaining,
+                extruder=_extruder_side_for_ams(ams_id, ams_extruder_map, is_dual),
             )
         )
 
+    # ------------------------------------------------------------------ phase 2
+    # When backup is OFF, fall back to today's per-slot accounting (one-line
+    # equivalence of the original loop), so this path is a strict no-op
+    # behaviour-wise vs. the pre-#1762 code.
+    if not backup_on:
+        return [
+            FilamentDeficit(
+                slot_id=row.slot_id,
+                ams_id=row.ams_id,
+                tray_id=row.tray_id,
+                filament_type=row.filament_type,
+                required_grams=row.required,
+                remaining_grams=row.remaining,
+            )
+            for row in resolved
+            if row.remaining is not None and row.remaining < row.required
+        ]
+
+    # ------------------------------------------------------------------ phase 3
+    # Backup ON: build (identity, extruder)-keyed pool and required-sum maps
+    # from EVERY assigned spool on the printer (not just the slots in the
+    # print's mapping). Then emit deficits only when the pool for a slot's
+    # material is too small for the print's total required of that material.
+    pool_by_key: dict[tuple[str, int], float] = defaultdict(float)
+    required_by_key: dict[tuple[str, int], float] = defaultdict(float)
+
+    if spoolman_mode:
+        sm_all = await db.execute(
+            select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == item.printer_id)
+        )
+        from backend.app.services.spoolman import (
+            SpoolmanClientError,
+            SpoolmanNotFoundError,
+            get_spoolman_client,
+        )
+
+        try:
+            client = await get_spoolman_client()
+        except Exception:
+            client = None
+        for sa in sm_all.scalars().all():
+            if client is None:
+                break
+            try:
+                spool_dict = await client.get_spool(sa.spoolman_spool_id)
+            except (SpoolmanNotFoundError, SpoolmanClientError):
+                continue
+            except Exception as e:
+                logger.debug("Spoolman pool fetch failed for spool %s: %s", sa.spoolman_spool_id, e)
+                continue
+            if not spool_dict:
+                continue
+            identity = _material_identity_spoolman(spool_dict)
+            rw = spool_dict.get("remaining_weight")
+            r: float | None = None
+            if isinstance(rw, (int, float)) and rw >= 0:
+                r = float(rw)
+            else:
+                used = spool_dict.get("used_weight")
+                total = (spool_dict.get("filament") or {}).get("weight")
+                if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
+                    r = max(0.0, float(total) - float(used))
+            if r is None:
+                continue
+            extruder = _extruder_side_for_ams(sa.ams_id, ams_extruder_map, is_dual)
+            pool_by_key[(identity, extruder)] += r
+    else:
+        internal_all = await db.execute(
+            select(SpoolAssignment)
+            .options(selectinload(SpoolAssignment.spool))
+            .where(SpoolAssignment.printer_id == item.printer_id)
+        )
+        for assignment in internal_all.scalars().all():
+            spool = assignment.spool
+            if spool is None:
+                continue
+            label_weight = float(spool.label_weight or 0)
+            weight_used = float(spool.weight_used or 0)
+            if label_weight <= 0:
+                continue
+            r = max(0.0, label_weight - weight_used)
+            identity = _material_identity_internal(spool)
+            extruder = _extruder_side_for_ams(assignment.ams_id, ams_extruder_map, is_dual)
+            pool_by_key[(identity, extruder)] += r
+
+    for row in resolved:
+        required_by_key[(row.identity, row.extruder)] += row.required
+
+    deficits: list[FilamentDeficit] = []
+    for row in resolved:
+        key = (row.identity, row.extruder)
+        # Pool insufficient for the print's TOTAL required of this material on
+        # this extruder side → real deficit. The per-slot remaining still gets
+        # surfaced so the UI can point at the slot the user assigned.
+        if pool_by_key[key] < required_by_key[key]:
+            deficits.append(
+                FilamentDeficit(
+                    slot_id=row.slot_id,
+                    ams_id=row.ams_id,
+                    tray_id=row.tray_id,
+                    filament_type=row.filament_type,
+                    required_grams=row.required,
+                    remaining_grams=row.remaining,
+                )
+            )
+
     return deficits
 
 

+ 39 - 0
backend/app/services/notification_service.py

@@ -1145,6 +1145,45 @@ class NotificationService:
             variables=variables,
         )
 
+    async def on_ai_failure_detection(
+        self,
+        printer_id: int,
+        printer_name: str,
+        task_name: str,
+        confidence: float,
+        action: str,
+        db: AsyncSession,
+        image_data: bytes | None = None,
+    ):
+        """Handle AI failure-detection event (Obico spaghetti / print-failure ML).
+
+        Split out of on_printer_error (#1794) so a user can subscribe to AI
+        alerts without also being paged for every HMS hardware code.
+        """
+        providers = await self._get_providers_for_event(db, "on_ai_failure_detection", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "task_name": task_name or "current job",
+            "confidence": f"{confidence:.2f}",
+            "action": action,
+        }
+
+        title, message = await self._build_message_from_template(db, "ai_failure_detection", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "ai_failure_detection",
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
+        )
+
     async def on_plate_not_empty(
         self,
         printer_id: int,

+ 10 - 7
backend/app/services/obico_actions.py

@@ -64,20 +64,23 @@ async def _turn_off_linked_plugs(printer_id: int) -> None:
 
 
 async def _notify(printer_id: int, printer_name: str, task_name: str, score: float, action: str) -> None:
+    """Fire the AI Failure Detection notification (#1794).
+
+    Routed to its own event in 0.2.5b1; previously rode the multiplexed
+    on_printer_error toggle, which made it indistinguishable from HMS
+    hardware errors in the UI.
+    """
     from backend.app.services.notification_service import notification_service
 
-    detail = (
-        f"Possible print failure detected on '{task_name or 'current job'}' "
-        f"(confidence {score:.2f}). Action taken: {action}."
-    )
     async with async_session() as db:
         try:
-            await notification_service.on_printer_error(
+            await notification_service.on_ai_failure_detection(
                 printer_id=printer_id,
                 printer_name=printer_name,
-                error_type="ai_failure_detection",
+                task_name=task_name,
+                confidence=score,
+                action=action,
                 db=db,
-                error_detail=detail,
             )
         except Exception as e:
             logger.error("Obico notify failed for printer %s: %s", printer_id, e)

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

@@ -1981,6 +1981,14 @@ class PrintScheduler:
         # manual_start) and "scheduler re-blocked" (this method re-flags it
         # on identical spool state) (#1698-followup).
         if item.skip_filament_check:
+            # #1762 diagnostic: surface the short-circuit at INFO so a
+            # future "Print Anyway didn't work" report (e.g. issue #1762
+            # comment 3) has actionable evidence in the support bundle
+            # without needing DEBUG enabled.
+            logger.info(
+                "Queue item %s honouring user's Print Anyway acknowledgement — skipping deficit check",
+                item.id,
+            )
             return False
 
         try:

+ 355 - 1
backend/tests/unit/services/test_filament_deficit.py

@@ -63,12 +63,20 @@ async def _setup_archive_3mf(db_session, tmp_path: Path, filaments: list[dict])
     return archive
 
 
-async def _spool(db_session, *, label_weight: int, weight_used: float, color: str = "#000000") -> Spool:
+async def _spool(
+    db_session,
+    *,
+    label_weight: int,
+    weight_used: float,
+    color: str = "#000000",
+    slicer_filament: str | None = None,
+) -> Spool:
     spool = Spool(
         material="PLA",
         label_weight=label_weight,
         weight_used=weight_used,
         rgba=color,
+        slicer_filament=slicer_filament,
     )
     db_session.add(spool)
     await db_session.commit()
@@ -265,3 +273,349 @@ class TestFilamentDeficit:
         assert [d.slot_id for d in deficit] == [2]
         assert deficit[0].remaining_grams == 50.0
         assert deficit[0].required_grams == 80.0
+
+
+class TestFilamentDeficitBackupAware:
+    """#1762 — when AMS Filament Backup is ON, pool remaining grams across
+    same-material spools on the printer (within the same extruder side on
+    dual-nozzle models) before declaring a slot deficit.
+
+    Reporter scenario: PLA Basic in AMS-1 slot 1 with 10 g left, same PLA
+    Basic in AMS-2 slot 1 with 500 g left. Today's per-slot accounting
+    blocks the print because slot 1 of AMS-1 is short. With backup ON,
+    firmware switches mid-print, so the deficit shouldn't fire.
+    """
+
+    @staticmethod
+    def _patch_status(
+        *,
+        printer_id: int,
+        backup_on: bool,
+        ams_extruder_map: dict | None = None,
+        model: str | None = None,
+    ):
+        """Patch ``printer_manager.get_status`` + ``get_model`` for the test."""
+        from types import SimpleNamespace
+        from unittest.mock import patch as _patch
+
+        fake_state = SimpleNamespace(
+            ams_filament_backup=backup_on if backup_on is not None else None,
+            ams_extruder_map=ams_extruder_map or {},
+        )
+
+        return [
+            _patch(
+                "backend.app.services.printer_manager.printer_manager.get_status",
+                lambda pid: fake_state if pid == printer_id else None,
+            ),
+            _patch(
+                "backend.app.services.printer_manager.printer_manager.get_model",
+                lambda pid: model if pid == printer_id else None,
+            ),
+        ]
+
+    @pytest.mark.asyncio
+    async def test_backup_on_pool_covers_short_slot(self, db_session, printer_factory, tmp_path):
+        """The reporter scenario: assigned slot is short, but the same
+        material on a peer slot covers the print. With backup ON, no deficit."""
+        printer = await printer_factory(model="X1C")
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
+        )
+        # Mapped slot: 10 g remaining, same Bambu preset as peer.
+        short = await _spool(db_session, label_weight=1000, weight_used=990.0, slicer_filament="GFA00")
+        # Peer slot on AMS-2: same preset, 500 g remaining.
+        peer = await _spool(db_session, label_weight=1000, weight_used=500.0, slicer_filament="GFA00")
+        await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+        await _assign(db_session, printer_id=printer.id, spool_id=peer.id, ams_id=1, tray_id=0)
+        item = await _queue_item(
+            db_session,
+            printer_id=printer.id,
+            archive=archive,
+            ams_mapping=[0],
+        )
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            for p in patches:
+                p.start()
+            try:
+                deficit = await compute_deficit_for_queue_item(db_session, item)
+            finally:
+                for p in patches:
+                    p.stop()
+
+        # Pool (10 + 500 = 510 g) covers the 200 g print → no deficit.
+        assert deficit == []
+
+    @pytest.mark.asyncio
+    async def test_backup_on_pool_insufficient_emits_deficit(self, db_session, printer_factory, tmp_path):
+        """Backup ON but the same-material pool across all slots is still
+        too small for the print → deficit emitted (real shortfall)."""
+        printer = await printer_factory(model="X1C")
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "1500.0"}],
+        )
+        a = await _spool(db_session, label_weight=1000, weight_used=900.0, slicer_filament="GFA00")  # 100g
+        b = await _spool(db_session, label_weight=1000, weight_used=700.0, slicer_filament="GFA00")  # 300g
+        await _assign(db_session, printer_id=printer.id, spool_id=a.id, ams_id=0, tray_id=0)
+        await _assign(db_session, printer_id=printer.id, spool_id=b.id, ams_id=1, tray_id=0)
+        item = await _queue_item(
+            db_session,
+            printer_id=printer.id,
+            archive=archive,
+            ams_mapping=[0],
+        )
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            for p in patches:
+                p.start()
+            try:
+                deficit = await compute_deficit_for_queue_item(db_session, item)
+            finally:
+                for p in patches:
+                    p.stop()
+
+        # Pool 400 g < required 1500 g → deficit fires.
+        assert len(deficit) == 1
+        assert deficit[0].slot_id == 1
+
+    @pytest.mark.asyncio
+    async def test_backup_on_different_materials_no_pool(self, db_session, printer_factory, tmp_path):
+        """Backup ON, but the peer slot holds a DIFFERENT material — pool
+        doesn't include it, deficit fires for the original short slot."""
+        printer = await printer_factory(model="X1C")
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "200.0"}],
+        )
+        # Assigned slot: PLA White preset GFA01, 10 g.
+        short = await _spool(db_session, label_weight=1000, weight_used=990.0, color="#FFFFFF", slicer_filament="GFA01")
+        # Peer: PLA Black, different preset (GFA00) — NOT a backup peer under the strict rule.
+        peer = await _spool(db_session, label_weight=1000, weight_used=500.0, color="#000000", slicer_filament="GFA00")
+        await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+        await _assign(db_session, printer_id=printer.id, spool_id=peer.id, ams_id=1, tray_id=0)
+        item = await _queue_item(
+            db_session,
+            printer_id=printer.id,
+            archive=archive,
+            ams_mapping=[0],
+        )
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            for p in patches:
+                p.start()
+            try:
+                deficit = await compute_deficit_for_queue_item(db_session, item)
+            finally:
+                for p in patches:
+                    p.stop()
+
+        # Pool for white = 10 g, required = 200 g → deficit.
+        assert len(deficit) == 1
+        assert deficit[0].slot_id == 1
+        assert deficit[0].remaining_grams == 10.0
+
+    @pytest.mark.asyncio
+    async def test_backup_off_falls_back_to_per_slot_accounting(self, db_session, printer_factory, tmp_path):
+        """When backup is OFF the new code path must be a strict no-op vs.
+        the pre-#1762 per-slot accounting. Identical inputs to the
+        ``pool_covers_short_slot`` case but with backup OFF — deficit fires."""
+        printer = await printer_factory(model="X1C")
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
+        )
+        short = await _spool(db_session, label_weight=1000, weight_used=990.0, slicer_filament="GFA00")
+        peer = await _spool(db_session, label_weight=1000, weight_used=500.0, slicer_filament="GFA00")
+        await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+        await _assign(db_session, printer_id=printer.id, spool_id=peer.id, ams_id=1, tray_id=0)
+        item = await _queue_item(
+            db_session,
+            printer_id=printer.id,
+            archive=archive,
+            ams_mapping=[0],
+        )
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=False, model="X1C")
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            for p in patches:
+                p.start()
+            try:
+                deficit = await compute_deficit_for_queue_item(db_session, item)
+            finally:
+                for p in patches:
+                    p.stop()
+
+        # Backup OFF → per-slot accounting → slot 1 has 10 g, needs 200 g.
+        assert len(deficit) == 1
+        assert deficit[0].remaining_grams == 10.0
+
+    @pytest.mark.asyncio
+    async def test_backup_on_dual_extruder_scopes_pool_per_side(self, db_session, printer_factory, tmp_path):
+        """Dual-extruder printer (H2D): peer slot on the OPPOSITE extruder
+        does NOT count toward the pool — firmware can't cross. Deficit fires."""
+        printer = await printer_factory(model="O1D")  # H2D internal code
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
+        )
+        short = await _spool(db_session, label_weight=1000, weight_used=990.0, slicer_filament="GFA00")
+        peer_other_side = await _spool(db_session, label_weight=1000, weight_used=500.0, slicer_filament="GFA00")
+        # AMS 0 is on extruder 0 (right). AMS 1 is on extruder 1 (left).
+        await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+        await _assign(db_session, printer_id=printer.id, spool_id=peer_other_side.id, ams_id=1, tray_id=0)
+        item = await _queue_item(
+            db_session,
+            printer_id=printer.id,
+            archive=archive,
+            ams_mapping=[0],
+        )
+
+        patches = TestFilamentDeficitBackupAware._patch_status(
+            printer_id=printer.id,
+            backup_on=True,
+            ams_extruder_map={"0": 0, "1": 1},
+            model="O1D",
+        )
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            for p in patches:
+                p.start()
+            try:
+                deficit = await compute_deficit_for_queue_item(db_session, item)
+            finally:
+                for p in patches:
+                    p.stop()
+
+        # Pool for extruder 0 = 10 g (peer on extruder 1 is unreachable) <
+        # required 200 g → deficit.
+        assert len(deficit) == 1
+        assert deficit[0].slot_id == 1
+
+    @pytest.mark.asyncio
+    async def test_backup_on_no_preset_never_pairs(self, db_session, printer_factory, tmp_path):
+        """Strict rule: two user-tagged spools with no slicer_filament preset
+        must NEVER pair, even when material + colour match. Mirrors Bambu
+        firmware: the backup decision relies on the Bambu Lab preset ID, so
+        generic spools without one can't be trusted to switch."""
+        printer = await printer_factory(model="X1C")
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
+        )
+        # Both spools: material PLA, colour black, NO preset → unique keys.
+        short = await _spool(db_session, label_weight=1000, weight_used=990.0)
+        peer_no_preset = await _spool(db_session, label_weight=1000, weight_used=500.0)
+        await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+        await _assign(db_session, printer_id=printer.id, spool_id=peer_no_preset.id, ams_id=1, tray_id=0)
+        item = await _queue_item(
+            db_session,
+            printer_id=printer.id,
+            archive=archive,
+            ams_mapping=[0],
+        )
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            for p in patches:
+                p.start()
+            try:
+                deficit = await compute_deficit_for_queue_item(db_session, item)
+            finally:
+                for p in patches:
+                    p.stop()
+
+        # No preset means no pool — slot 1's 10 g vs 200 g required → deficit.
+        assert len(deficit) == 1
+        assert deficit[0].slot_id == 1
+        assert deficit[0].remaining_grams == 10.0
+
+    @pytest.mark.asyncio
+    async def test_backup_on_same_preset_different_colors_does_not_pair(self, db_session, printer_factory, tmp_path):
+        """STRICT colour rule: two spools sharing the same Bambu preset ID
+        but DIFFERENT colours must NOT pool. Three PETG HF spools in
+        different colours can't back each other up — the firmware would
+        switch material correctly but the print would change colour
+        mid-run. Pool is per-(preset, colour)."""
+        printer = await printer_factory(model="X1C")
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
+        )
+        # Assigned slot: PLA Basic + GFA00 + BLACK, only 10 g left.
+        short = await _spool(db_session, label_weight=1000, weight_used=990.0, color="#000000", slicer_filament="GFA00")
+        # Peer slot: same GFA00 profile but WHITE — must not pool.
+        peer_diff_color = await _spool(
+            db_session, label_weight=1000, weight_used=500.0, color="#FFFFFF", slicer_filament="GFA00"
+        )
+        await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+        await _assign(db_session, printer_id=printer.id, spool_id=peer_diff_color.id, ams_id=1, tray_id=0)
+        item = await _queue_item(
+            db_session,
+            printer_id=printer.id,
+            archive=archive,
+            ams_mapping=[0],
+        )
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            for p in patches:
+                p.start()
+            try:
+                deficit = await compute_deficit_for_queue_item(db_session, item)
+            finally:
+                for p in patches:
+                    p.stop()
+
+        # Pool for (GFA00, black) = 10 g; required = 200 g → deficit.
+        assert len(deficit) == 1
+        assert deficit[0].slot_id == 1
+        assert deficit[0].remaining_grams == 10.0
+
+    @pytest.mark.asyncio
+    async def test_backup_on_color_alpha_normalized(self, db_session, printer_factory, tmp_path):
+        """Colour normalisation: 6-char hex matches 8-char hex of the same
+        RGB. ``000000`` and ``000000FF`` should both resolve to BLACK."""
+        printer = await printer_factory(model="X1C")
+        archive = await _setup_archive_3mf(
+            db_session,
+            tmp_path,
+            [{"id": "1", "type": "PLA", "color": "#000000", "used_g": "200.0"}],
+        )
+        short = await _spool(db_session, label_weight=1000, weight_used=990.0, color="#000000", slicer_filament="GFA00")
+        # Same colour but expressed with explicit alpha.
+        peer = await _spool(
+            db_session, label_weight=1000, weight_used=500.0, color="#000000FF", slicer_filament="GFA00"
+        )
+        await _assign(db_session, printer_id=printer.id, spool_id=short.id, ams_id=0, tray_id=0)
+        await _assign(db_session, printer_id=printer.id, spool_id=peer.id, ams_id=1, tray_id=0)
+        item = await _queue_item(
+            db_session,
+            printer_id=printer.id,
+            archive=archive,
+            ams_mapping=[0],
+        )
+
+        patches = TestFilamentDeficitBackupAware._patch_status(printer_id=printer.id, backup_on=True, model="X1C")
+        with patch("backend.app.services.filament_deficit.app_settings.base_dir", Path("/")):
+            for p in patches:
+                p.start()
+            try:
+                deficit = await compute_deficit_for_queue_item(db_session, item)
+            finally:
+                for p in patches:
+                    p.stop()
+
+        # Pool (10 + 500 = 510 g) covers 200 g → no deficit.
+        assert deficit == []

+ 143 - 0
backend/tests/unit/services/test_notification_service.py

@@ -1706,6 +1706,149 @@ class TestPrinterErrorNotifications:
             assert captured_variables["error_detail"] == "No details available"
 
 
+class TestAIFailureDetectionNotifications:
+    """Tests for the AI failure-detection event (#1794 — split out of on_printer_error).
+
+    Pins that Obico failure-detection dispatches go through the dedicated
+    on_ai_failure_detection event field, not the multiplexed printer-error
+    field. Mirrors the printer-error coverage above so a regression on either
+    surface fails its own case.
+    """
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.fixture
+    def mock_provider(self):
+        provider = MagicMock()
+        provider.id = 1
+        provider.name = "Test Provider"
+        provider.provider_type = "webhook"
+        provider.enabled = True
+        provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
+        provider.on_ai_failure_detection = True
+        provider.on_printer_error = False  # disabled — the regression guard
+        provider.quiet_hours_enabled = False
+        provider.daily_digest_enabled = False
+        provider.printer_id = None
+        return provider
+
+    @pytest.fixture
+    def mock_db(self):
+        db = AsyncMock()
+        db.commit = AsyncMock()
+        return db
+
+    @pytest.mark.asyncio
+    async def test_dispatch_uses_ai_failure_detection_event_not_printer_error(self, service, mock_provider, mock_db):
+        """Regression guard: provider subscribed only to AI alerts must receive
+        the Obico notification."""
+        captured_event = []
+
+        async def capture(db, event_field, printer_id):
+            captured_event.append(event_field)
+            return [mock_provider]
+
+        with (
+            patch.object(service, "_get_providers_for_event", side_effect=capture),
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
+            mock_build.return_value = ("Possible Print Failure Detected", "details")
+
+            await service.on_ai_failure_detection(
+                printer_id=1,
+                printer_name="X1 Carbon",
+                task_name="benchy.3mf",
+                confidence=0.87,
+                action="notify",
+                db=mock_db,
+            )
+
+            assert captured_event == ["on_ai_failure_detection"]
+            mock_send.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_skipped_when_only_printer_error_is_enabled(self, service, mock_provider, mock_db):
+        """Pre-#1794 behaviour MUST NOT survive: a provider with only the
+        legacy on_printer_error toggle should NOT receive AI notifications now."""
+        mock_provider.on_ai_failure_detection = False
+        mock_provider.on_printer_error = True
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+        ):
+            mock_get.return_value = []  # the event-field filter excludes the provider
+
+            await service.on_ai_failure_detection(
+                printer_id=1,
+                printer_name="X1 Carbon",
+                task_name="benchy.3mf",
+                confidence=0.87,
+                action="notify",
+                db=mock_db,
+            )
+
+            mock_send.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_variables_include_task_name_confidence_action(self, service, mock_provider, mock_db):
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_ai_failure_detection(
+                printer_id=1,
+                printer_name="X1 Carbon",
+                task_name="benchy.3mf",
+                confidence=0.873,
+                action="pause_and_off",
+                db=mock_db,
+            )
+
+            assert captured_variables["printer"] == "X1 Carbon"
+            assert captured_variables["task_name"] == "benchy.3mf"
+            assert captured_variables["confidence"] == "0.87"  # 2-decimal format
+            assert captured_variables["action"] == "pause_and_off"
+
+    @pytest.mark.asyncio
+    async def test_task_name_fallback_when_unknown(self, service, mock_provider, mock_db):
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_ai_failure_detection(
+                printer_id=1,
+                printer_name="Test",
+                task_name="",  # empty
+                confidence=0.5,
+                action="notify",
+                db=mock_db,
+            )
+
+            assert captured_variables["task_name"] == "current job"
+
+
 class TestPlateNotEmptyNotifications:
     """Tests for plate not empty (build plate detection) notifications."""
 

+ 113 - 0
backend/tests/unit/services/test_obico_actions.py

@@ -0,0 +1,113 @@
+"""Regression tests for obico_actions (#1794).
+
+Before #1794, `obico_actions._notify` routed AI failure-detection events
+through `notification_service.on_printer_error`, multiplexing them with
+HMS hardware errors. Users couldn't subscribe to one without the other,
+and the reporter on #1794 found that turning OFF the "Printer Error"
+toggle on a Discord provider silently disabled spaghetti alerts too.
+
+This file pins the post-#1794 wiring: `execute_action` calls
+`on_ai_failure_detection`, not `on_printer_error`.
+"""
+
+from contextlib import asynccontextmanager
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.obico_actions import execute_action
+
+
+@asynccontextmanager
+async def _fake_session(printer):
+    result = SimpleNamespace(scalar_one_or_none=lambda: printer)
+    session = SimpleNamespace(execute=AsyncMock(return_value=result))
+    yield session
+
+
+@pytest.fixture
+def fake_printer():
+    return SimpleNamespace(id=7, name="X1 Carbon")
+
+
+@pytest.fixture(autouse=True)
+def _patch_session(fake_printer):
+    with patch("backend.app.services.obico_actions.async_session", lambda: _fake_session(fake_printer)):
+        yield
+
+
+async def test_notify_routes_to_on_ai_failure_detection(fake_printer):
+    """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"
+    is OFF and "AI Failure Detection" is ON) returns."""
+    with (
+        patch(
+            "backend.app.services.notification_service.notification_service.on_ai_failure_detection",
+            new_callable=AsyncMock,
+        ) as mock_ai,
+        patch(
+            "backend.app.services.notification_service.notification_service.on_printer_error",
+            new_callable=AsyncMock,
+        ) as mock_err,
+    ):
+        await execute_action(
+            printer_id=fake_printer.id,
+            action="notify",
+            task_name="benchy.3mf",
+            score=0.91,
+        )
+
+        mock_ai.assert_awaited_once()
+        mock_err.assert_not_awaited()  # the bug the user reported
+
+        call_kwargs = mock_ai.await_args.kwargs
+        assert call_kwargs["printer_id"] == fake_printer.id
+        assert call_kwargs["printer_name"] == fake_printer.name
+        assert call_kwargs["task_name"] == "benchy.3mf"
+        assert call_kwargs["confidence"] == 0.91
+        assert call_kwargs["action"] == "notify"
+
+
+async def test_pause_action_still_pauses_and_notifies(fake_printer):
+    """`pause` calls pause_print AND fires the AI notification — the
+    notification fan-out shape isn't different for the pause action."""
+    fake_client = SimpleNamespace(pause_print=lambda: True)
+
+    with (
+        patch(
+            "backend.app.services.printer_manager.printer_manager.get_client",
+            return_value=fake_client,
+        ),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_ai_failure_detection",
+            new_callable=AsyncMock,
+        ) as mock_ai,
+    ):
+        await execute_action(
+            printer_id=fake_printer.id,
+            action="pause",
+            task_name="benchy.3mf",
+            score=0.5,
+        )
+
+        mock_ai.assert_awaited_once()
+        assert mock_ai.await_args.kwargs["action"] == "pause"
+
+
+async def test_notify_swallows_notification_service_exceptions(fake_printer):
+    """Notification failure must not propagate — Obico's detection loop
+    keeps polling; one transient Discord blip shouldn't kill it."""
+    with patch(
+        "backend.app.services.notification_service.notification_service.on_ai_failure_detection",
+        new_callable=AsyncMock,
+        side_effect=RuntimeError("discord 502"),
+    ):
+        # Must not raise.
+        await execute_action(
+            printer_id=fake_printer.id,
+            action="notify",
+            task_name="benchy.3mf",
+            score=0.91,
+        )

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

@@ -143,6 +143,8 @@ const DE_COGNATES = [
   'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus',
   'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
+  'AMS Filament Backup',  // Bambu Lab product/firmware feature name
+
   'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware',
   'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer',
   'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global',
@@ -171,6 +173,7 @@ const DE_COGNATES = [
 // French cognates — many UI labels overlap with English exactly.
 const FR_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
+  'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
   'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar',
@@ -209,6 +212,7 @@ const FR_COGNATES = [
 // Italian cognates.
 const IT_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
+  'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Email',  // common loanword in Italian, used verbatim in UI labels
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code',
@@ -249,6 +253,7 @@ const JA_COGNATES = [
 // Portuguese (BR) cognates.
 const PT_BR_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
+  'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament',
   'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server',
   'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache',
@@ -316,6 +321,7 @@ const KO_COGNATES = [
 // Spanish cognates — words/phrases that are genuinely identical in Spanish.
 const ES_COGNATES = [
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
+  'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Error', 'Firmware', 'General', 'Control', 'Total', 'total', 'Material',
   'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable',
   'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original',
@@ -335,6 +341,7 @@ const ES_COGNATES = [
 const TR_COGNATES = [
   'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal',
   'Bambu Cloud', 'Orca Cloud',  // brand names — same in every locale
+  'AMS Filament Backup',  // Bambu Lab product/firmware feature name
   'Min', 'Normal', 'Platform', 'Net', 'Trend', 'Commit', 'Global', 'Proxy',
   'N/A', 'email',
   'STARTTLS (Port 587)', 'SSL/TLS (Port 465)',

+ 50 - 0
frontend/src/__tests__/components/AddNotificationModal.test.tsx

@@ -40,6 +40,7 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
     on_print_missing_spool_assignment: false,
     on_printer_offline: false,
     on_printer_error: false,
+    on_ai_failure_detection: false,
     on_filament_low: false,
     on_maintenance_due: false,
     on_ams_humidity_high: false,
@@ -336,3 +337,52 @@ describe('AddNotificationModal — stock alert toggles', () => {
     void user; // referenced to avoid unused-var lint warning
   });
 });
+
+describe('AddNotificationModal — AI Failure Detection toggle (#1794)', () => {
+  it('renders the toggle in the Printer Status section', async () => {
+    render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
+
+    expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
+  });
+
+  it('persists on_ai_failure_detection on save (and does NOT touch on_printer_error)', async () => {
+    let captured: Record<string, unknown> | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
+
+    const label = await screen.findByText('AI Failure Detection');
+    const row = label.closest('div.flex')!;
+    const toggle = within(row).getByRole('switch');
+    await user.click(toggle);
+
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+
+    expect(captured).not.toBeNull();
+    expect(captured!.on_ai_failure_detection).toBe(true);
+    // Critical regression guard: don't accidentally flip the legacy multiplexed field.
+    expect(captured!.on_printer_error).toBe(false);
+  });
+
+  it('AI Failure Detection appears in ntfy priority section when enabled', async () => {
+    render(
+      <AddNotificationModal
+        provider={buildProvider({ on_ai_failure_detection: true })}
+        onClose={() => undefined}
+      />,
+    );
+
+    const priorityHeader = await screen.findByText(/ntfy priority/i);
+    const priorityRoot = priorityHeader.closest('div')!;
+
+    expect(within(priorityRoot).getByText('AI Failure Detection')).toBeInTheDocument();
+  });
+});

+ 247 - 0
frontend/src/__tests__/components/AmsBackupModal.test.tsx

@@ -0,0 +1,247 @@
+/**
+ * Render tests for the AMS Filament Backup modal (#1762).
+ *
+ * Modal now renders one SVG ring per backup pair (BambuStudio Auto Refill
+ * style); lone slots are intentionally suppressed.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { screen, fireEvent } from '@testing-library/react';
+
+import { render } from '../utils';
+import { AmsBackupModal } from '../../components/AmsBackupModal';
+
+function makeAmsUnits() {
+  return [
+    {
+      id: 0,
+      tray: [
+        { id: 0, tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#000000', tray_info_idx: 'GFA00' },
+        { id: 1, tray_type: 'PETG', tray_sub_brands: 'PETG HF', tray_color: '#0000FF', tray_info_idx: 'GFG99' },
+      ],
+    },
+    {
+      id: 1,
+      tray: [
+        { id: 0, tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#000000', tray_info_idx: 'GFA00' },
+      ],
+    },
+  ];
+}
+
+describe('AmsBackupModal', () => {
+  it('returns null when isOpen=false', () => {
+    const { container } = render(
+      <AmsBackupModal
+        isOpen={false}
+        state={true}
+        amsUnits={makeAmsUnits()}
+        amsExtruderMap={undefined}
+        isDualNozzle={false}
+        canToggle
+        pending={false}
+        onToggle={vi.fn()}
+        onClose={vi.fn()}
+      />,
+    );
+    expect(container.querySelector('[data-testid="ams-backup-modal"]')).toBeNull();
+  });
+
+  it('renders a backup ring for each pair and OMITS lone slots', async () => {
+    render(
+      <AmsBackupModal
+        isOpen
+        state={true}
+        amsUnits={makeAmsUnits()}
+        amsExtruderMap={undefined}
+        isDualNozzle={false}
+        canToggle
+        pending={false}
+        onToggle={vi.fn()}
+        onClose={vi.fn()}
+      />,
+    );
+
+    // PLA Basic — pair: rendered in centre of its ring
+    expect(await screen.findByText('PLA Basic')).toBeInTheDocument();
+    // PETG HF — lone, must NOT appear (no longer listed)
+    expect(screen.queryByText('PETG HF')).not.toBeInTheDocument();
+  });
+
+  it('closes on Escape keypress while open', async () => {
+    const onClose = vi.fn();
+    render(
+      <AmsBackupModal
+        isOpen
+        state={true}
+        amsUnits={makeAmsUnits()}
+        amsExtruderMap={undefined}
+        isDualNozzle={false}
+        canToggle
+        pending={false}
+        onToggle={vi.fn()}
+        onClose={onClose}
+      />,
+    );
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+
+  it('does NOT fire onClose on Escape when closed (listener unmounts)', () => {
+    const onClose = vi.fn();
+    const { rerender } = render(
+      <AmsBackupModal
+        isOpen
+        state={true}
+        amsUnits={makeAmsUnits()}
+        amsExtruderMap={undefined}
+        isDualNozzle={false}
+        canToggle
+        pending={false}
+        onToggle={vi.fn()}
+        onClose={onClose}
+      />,
+    );
+    rerender(
+      <AmsBackupModal
+        isOpen={false}
+        state={true}
+        amsUnits={makeAmsUnits()}
+        amsExtruderMap={undefined}
+        isDualNozzle={false}
+        canToggle
+        pending={false}
+        onToggle={vi.fn()}
+        onClose={onClose}
+      />,
+    );
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(onClose).not.toHaveBeenCalled();
+  });
+
+  it('toggle reflects the ON state and fires onToggle(false) when clicked', async () => {
+    const onToggle = vi.fn();
+    render(
+      <AmsBackupModal
+        isOpen
+        state={true}
+        amsUnits={makeAmsUnits()}
+        amsExtruderMap={undefined}
+        isDualNozzle={false}
+        canToggle
+        pending={false}
+        onToggle={onToggle}
+        onClose={vi.fn()}
+      />,
+    );
+
+    const toggle = await screen.findByRole('switch');
+    expect(toggle).toHaveAttribute('aria-checked', 'true');
+    fireEvent.click(toggle);
+    expect(onToggle).toHaveBeenCalledWith(false);
+  });
+
+  it('toggle is disabled when state is unknown (A1 family)', async () => {
+    render(
+      <AmsBackupModal
+        isOpen
+        state={null}
+        amsUnits={[]}
+        amsExtruderMap={undefined}
+        isDualNozzle={false}
+        canToggle
+        pending={false}
+        onToggle={vi.fn()}
+        onClose={vi.fn()}
+      />,
+    );
+    const toggle = await screen.findByRole('switch');
+    expect(toggle).toBeDisabled();
+    expect(screen.getByText(/Unsupported/i)).toBeInTheDocument();
+  });
+
+  it('toggle is disabled when the user lacks printers:control', async () => {
+    render(
+      <AmsBackupModal
+        isOpen
+        state={true}
+        amsUnits={makeAmsUnits()}
+        amsExtruderMap={undefined}
+        isDualNozzle={false}
+        canToggle={false}
+        pending={false}
+        onToggle={vi.fn()}
+        onClose={vi.fn()}
+      />,
+    );
+    const toggle = await screen.findByRole('switch');
+    expect(toggle).toBeDisabled();
+  });
+
+  it('shows a no-pairs empty state when AMS has no backup pair', async () => {
+    render(
+      <AmsBackupModal
+        isOpen
+        state={true}
+        // Just one slot, can't form any pair.
+        amsUnits={[
+          { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#000', tray_info_idx: 'GFA00' }] },
+        ]}
+        amsExtruderMap={undefined}
+        isDualNozzle={false}
+        canToggle
+        pending={false}
+        onToggle={vi.fn()}
+        onClose={vi.fn()}
+      />,
+    );
+    expect(await screen.findByText(/No backup pairs/i)).toBeInTheDocument();
+  });
+
+  it('on dual-extruder with distinct map values, renders R / L badges on each ring', async () => {
+    render(
+      <AmsBackupModal
+        isOpen
+        state={true}
+        amsUnits={[
+          { id: 0, tray: [{ id: 0, tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#000', tray_info_idx: 'GFA00' }] },
+          { id: 1, tray: [{ id: 0, tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#000', tray_info_idx: 'GFA00' }] },
+          { id: 2, tray: [{ id: 0, tray_type: 'PETG', tray_sub_brands: 'PETG HF', tray_color: '#0FF', tray_info_idx: 'GFG99' }] },
+          { id: 3, tray: [{ id: 0, tray_type: 'PETG', tray_sub_brands: 'PETG HF', tray_color: '#0FF', tray_info_idx: 'GFG99' }] },
+        ]}
+        // AMS 0+1 on right (ex 0), AMS 2+3 on left (ex 1) → two pairs, one per side.
+        amsExtruderMap={{ '0': 0, '1': 0, '2': 1, '3': 1 }}
+        isDualNozzle
+        canToggle
+        pending={false}
+        onToggle={vi.fn()}
+        onClose={vi.fn()}
+      />,
+    );
+
+    // Both rings should be present (PLA Basic and PETG HF in the centres).
+    expect(await screen.findByText('PLA Basic')).toBeInTheDocument();
+    expect(screen.getByText('PETG HF')).toBeInTheDocument();
+    // Both extruder badges visible.
+    expect(screen.getByText('R')).toBeInTheDocument();
+    expect(screen.getByText('L')).toBeInTheDocument();
+  });
+
+  it('collapses to single section (no R/L badges) when isDualNozzle=true but map has one distinct value', async () => {
+    render(
+      <AmsBackupModal
+        isOpen
+        state={true}
+        amsUnits={makeAmsUnits()}
+        amsExtruderMap={{ '0': 0, '1': 0 }}
+        isDualNozzle
+        canToggle
+        pending={false}
+        onToggle={vi.fn()}
+        onClose={vi.fn()}
+      />,
+    );
+
+    expect(screen.queryByText('R')).not.toBeInTheDocument();
+    expect(screen.queryByText('L')).not.toBeInTheDocument();
+  });
+});

+ 1 - 0
frontend/src/__tests__/components/NotificationProviderCard.test.tsx

@@ -57,6 +57,7 @@ const createMockProvider = (
   on_print_progress: false,
   on_printer_offline: false,
   on_printer_error: false,
+  on_ai_failure_detection: false,
   on_filament_low: false,
   on_maintenance_due: false,
   on_ams_humidity_high: false,

+ 128 - 0
frontend/src/__tests__/components/NotificationProviderCardAiFailureDetection.test.tsx

@@ -0,0 +1,128 @@
+/**
+ * Tests for the AI Failure Detection toggle on NotificationProviderCard (#1794).
+ *
+ * Before #1794, Obico failure detection rode the multiplexed
+ * on_printer_error toggle so users couldn't subscribe to one without the
+ * other. These tests pin the standalone toggle:
+ *  - Summary badge renders when enabled.
+ *  - The toggle row appears in the expanded settings panel.
+ *  - Flipping the toggle PATCHes the correct field.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { screen, waitFor, 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 { NotificationProviderCard } from '../../components/NotificationProviderCard';
+import type { NotificationProvider } from '../../api/client';
+
+afterEach(() => {
+  server.resetHandlers();
+  vi.restoreAllMocks();
+});
+
+function buildProvider(overrides: Partial<NotificationProvider> = {}): NotificationProvider {
+  return {
+    id: 1,
+    name: 'Test Provider',
+    provider_type: 'ntfy',
+    enabled: true,
+    config: { server: 'https://ntfy.sh', topic: 'bambuddy' },
+    on_print_start: false,
+    on_print_complete: false,
+    on_print_failed: false,
+    on_print_stopped: false,
+    on_print_progress: false,
+    on_print_missing_spool_assignment: false,
+    on_printer_offline: false,
+    on_printer_error: false,
+    on_ai_failure_detection: false,
+    on_filament_low: false,
+    on_maintenance_due: false,
+    on_ams_humidity_high: false,
+    on_ams_temperature_high: false,
+    on_ams_ht_humidity_high: false,
+    on_ams_ht_temperature_high: false,
+    on_plate_not_empty: false,
+    on_bed_cooled: false,
+    on_first_layer_complete: false,
+    on_queue_job_added: false,
+    on_queue_job_assigned: false,
+    on_queue_job_started: false,
+    on_queue_job_waiting: false,
+    on_queue_job_skipped: false,
+    on_queue_job_failed: false,
+    on_queue_completed: false,
+    on_stock_reorder_alert: false,
+    on_stock_break_alert: false,
+    quiet_hours_enabled: false,
+    quiet_hours_start: null,
+    quiet_hours_end: null,
+    daily_digest_enabled: false,
+    daily_digest_time: null,
+    printer_id: null,
+    last_success: null,
+    last_error: null,
+    last_error_at: null,
+    created_at: '2026-06-22T00:00:00Z',
+    updated_at: '2026-06-22T00:00:00Z',
+    ...overrides,
+  };
+}
+
+describe('NotificationProviderCard — AI Failure Detection badge', () => {
+  it('renders the badge when on_ai_failure_detection is true', async () => {
+    render(
+      <NotificationProviderCard
+        provider={buildProvider({ on_ai_failure_detection: true })}
+        onEdit={vi.fn()}
+      />,
+    );
+    expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
+  });
+
+  it('omits the badge when on_ai_failure_detection is false', async () => {
+    render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
+    await screen.findByText('Test Provider');
+    expect(screen.queryByText('AI Failure Detection')).not.toBeInTheDocument();
+  });
+});
+
+describe('NotificationProviderCard — AI Failure Detection toggle', () => {
+  it('renders the toggle in the expanded settings panel', async () => {
+    const user = userEvent.setup();
+    render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
+
+    await user.click(await screen.findByText(/event settings/i));
+
+    expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
+  });
+
+  it('PATCHes on_ai_failure_detection (NOT on_printer_error) when toggled on — #1794 regression guard', async () => {
+    let captured: Record<string, unknown> | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json(buildProvider({ on_ai_failure_detection: true }));
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
+
+    await user.click(await screen.findByText(/event settings/i));
+
+    // The toggle label "AI Failure Detection" is unique to this row.
+    const label = await screen.findByText('AI Failure Detection');
+    const row = label.closest('div.flex')!;
+    const toggle = within(row).getByRole('switch');
+    await user.click(toggle);
+
+    await waitFor(() => expect(captured).not.toBeNull());
+    expect(captured).toMatchObject({ on_ai_failure_detection: true });
+    // Critical: must NOT also flip the legacy multiplexed field.
+    expect(captured).not.toHaveProperty('on_printer_error');
+  });
+});

+ 1 - 0
frontend/src/__tests__/components/NotificationProviderCardStockAlerts.test.tsx

@@ -37,6 +37,7 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
     on_print_missing_spool_assignment: false,
     on_printer_offline: false,
     on_printer_error: false,
+    on_ai_failure_detection: false,
     on_filament_low: false,
     on_maintenance_due: false,
     on_ams_humidity_high: false,

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

@@ -50,6 +50,7 @@ const mockNotificationProviders = [
     on_print_progress: false,
     on_printer_offline: false,
     on_printer_error: false,
+    on_ai_failure_detection: false,
     on_filament_low: false,
     on_maintenance_due: false,
     on_ams_humidity_high: false,

+ 223 - 0
frontend/src/__tests__/pages/PrintersPageBackupGroups.test.ts

@@ -0,0 +1,223 @@
+/**
+ * Tests for #1762 — `computeBackupGroups` strict identity rule.
+ *
+ * Slots pair ONLY when they share the same Bambu preset ID
+ * (`tray_info_idx`). User-tagged spools without a preset never pair.
+ * Empty slots are skipped; non-empty slots without a peer come back as
+ * 1-member entries so the modal can list them as "Slots without a peer".
+ */
+import { describe, it, expect } from 'vitest';
+
+import { computeBackupGroups } from '../../utils/amsHelpers';
+
+function ams(id: number, tray: Array<{
+  tray_type?: string | null;
+  tray_sub_brands?: string | null;
+  tray_color?: string | null;
+  tray_info_idx?: string | null;
+}>) {
+  return {
+    id,
+    tray: tray.map((t, i) => ({
+      id: i,
+      tray_type: t.tray_type ?? null,
+      tray_sub_brands: t.tray_sub_brands ?? null,
+      tray_color: t.tray_color ?? null,
+      tray_info_idx: t.tray_info_idx ?? null,
+    })),
+  };
+}
+
+describe('computeBackupGroups', () => {
+  it('returns empty list for missing/empty AMS input', () => {
+    expect(computeBackupGroups(undefined, {}, false)).toEqual([]);
+    expect(computeBackupGroups([], {}, false)).toEqual([]);
+  });
+
+  it('skips empty slots entirely', () => {
+    const groups = computeBackupGroups(
+      [ams(0, [
+        { tray_type: null, tray_color: null, tray_info_idx: null },
+        { tray_type: null, tray_color: null, tray_info_idx: null },
+      ])],
+      {},
+      false,
+    );
+    expect(groups).toEqual([]);
+  });
+
+  it('groups two slots in different AMS units holding the same preset', () => {
+    const groups = computeBackupGroups(
+      [
+        ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+        ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+      ],
+      {},
+      false,
+    );
+
+    expect(groups).toHaveLength(1);
+    expect(groups[0].presetId).toBe('GFA00');
+    expect(groups[0].members.map((m) => m.globalTrayId)).toEqual([0, 4]);
+  });
+
+  it('STRICT rule: two slots without a preset never pair, even with matching material+colour', () => {
+    const groups = computeBackupGroups(
+      [
+        ams(0, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#FF0000' }]),
+        ams(1, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#FF0000' }]),
+      ],
+      {},
+      false,
+    );
+
+    // Two lone slots — no pair.
+    expect(groups).toHaveLength(2);
+    expect(groups.every((g) => g.members.length === 1)).toBe(true);
+    expect(groups.every((g) => g.presetId === null)).toBe(true);
+  });
+
+  it('does NOT group slots with different presets even if same material', () => {
+    const groups = computeBackupGroups(
+      [ams(0, [
+        { tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' },
+        { tray_type: 'PLA', tray_color: '#FFFFFF', tray_info_idx: 'GFA01' },
+      ])],
+      {},
+      false,
+    );
+    expect(groups).toHaveLength(2);
+    expect(groups.every((g) => g.members.length === 1)).toBe(true);
+  });
+
+  it('returns lone slots alongside pairs in the same list', () => {
+    const groups = computeBackupGroups(
+      [
+        ams(0, [
+          { tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' },
+          { tray_type: 'PETG', tray_color: '#0000FF', tray_info_idx: 'GFG99' },
+        ]),
+        ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+      ],
+      {},
+      false,
+    );
+    // 1 pair + 1 lone, pair first by sort order.
+    expect(groups).toHaveLength(2);
+    expect(groups[0].members).toHaveLength(2);
+    expect(groups[1].members).toHaveLength(1);
+    expect(groups[1].displayName).toContain('PETG');
+  });
+
+  it('on dual-extruder printers, scopes pairs per extruder side', () => {
+    // ams 0 = right (0), ams 1 = left (1). Same preset on different sides:
+    // each comes back as a 1-member entry.
+    const groups = computeBackupGroups(
+      [
+        ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+        ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+      ],
+      { '0': 0, '1': 1 },
+      true,
+    );
+    expect(groups).toHaveLength(2);
+    expect(groups.every((g) => g.members.length === 1)).toBe(true);
+    expect(groups[0].extruder).toBe(0);
+    expect(groups[1].extruder).toBe(1);
+  });
+
+  it('on dual-extruder printers, pairs slots on the same extruder side', () => {
+    const groups = computeBackupGroups(
+      [
+        ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+        ams(1, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+        ams(2, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+      ],
+      { '0': 0, '1': 1, '2': 0 },
+      true,
+    );
+
+    // Right-side pair (AMS 0 + 2), left-side lone (AMS 1).
+    const rightPair = groups.find((g) => g.extruder === 0 && g.members.length === 2);
+    expect(rightPair).toBeDefined();
+    expect(rightPair!.members.map((m) => m.globalTrayId).sort((a, b) => a - b)).toEqual([0, 8]);
+    const leftLone = groups.find((g) => g.extruder === 1);
+    expect(leftLone).toBeDefined();
+    expect(leftLone!.members).toHaveLength(1);
+  });
+
+  it('handles AMS-HT (single-tray, id >= 128) via getGlobalTrayId — pairs with regular AMS slot', () => {
+    const groups = computeBackupGroups(
+      [
+        ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+        ams(128, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+      ],
+      {},
+      false,
+    );
+    expect(groups).toHaveLength(1);
+    expect(groups[0].members.map((m) => m.globalTrayId).sort((a, b) => a - b)).toEqual([0, 128]);
+  });
+
+  it('STRICT colour rule: same preset, different colours do NOT pair', () => {
+    // Reporter screenshot scenario — three PETG HF slots all sharing the
+    // same Bambu profile ID (e.g. GFG99) but in three different colours
+    // cannot back each other up; the firmware would correctly swap PETG HF
+    // but the print would change colour mid-run.
+    const groups = computeBackupGroups(
+      [
+        ams(0, [{ tray_type: 'PETG', tray_color: '#000000', tray_info_idx: 'GFG99' }]),
+        ams(1, [{ tray_type: 'PETG', tray_color: '#FF0000', tray_info_idx: 'GFG99' }]),
+        ams(2, [{ tray_type: 'PETG', tray_color: '#00FF00', tray_info_idx: 'GFG99' }]),
+      ],
+      {},
+      false,
+    );
+    // Three lone slots — no pair.
+    expect(groups).toHaveLength(3);
+    expect(groups.every((g) => g.members.length === 1)).toBe(true);
+  });
+
+  it('colour normalisation: 6-char and 8-char hex of the same RGB pair correctly', () => {
+    const groups = computeBackupGroups(
+      [
+        ams(0, [{ tray_type: 'PLA', tray_color: '000000', tray_info_idx: 'GFA00' }]),
+        ams(1, [{ tray_type: 'PLA', tray_color: '000000FF', tray_info_idx: 'GFA00' }]),
+      ],
+      {},
+      false,
+    );
+    expect(groups).toHaveLength(1);
+    expect(groups[0].members).toHaveLength(2);
+  });
+
+  it('defensively dedupes duplicate ams.id entries (first wins)', () => {
+    // Observed in the wild: status.ams sometimes contains the same ams.id
+    // twice (VP-aggregated switch printers, MQTT partial-update edge cases).
+    // The modal must NOT render the same slot label with conflicting
+    // materials — first occurrence wins, second is dropped.
+    const groups = computeBackupGroups(
+      [
+        ams(0, [{ tray_type: 'PETG', tray_color: '#000000', tray_info_idx: 'GFG99' }]),
+        ams(0, [{ tray_type: 'PLA', tray_color: '#000000', tray_info_idx: 'GFA00' }]),
+      ],
+      {},
+      false,
+    );
+    expect(groups).toHaveLength(1);
+    expect(groups[0].displayName).toContain('PETG');
+  });
+
+  it('preserves display name + tray colour from the first slot for the modal swatch', () => {
+    const groups = computeBackupGroups(
+      [
+        ams(0, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#1A1A1A', tray_info_idx: 'GFA00' }]),
+        ams(1, [{ tray_type: 'PLA', tray_sub_brands: 'PLA Basic', tray_color: '#1A1A1A', tray_info_idx: 'GFA00' }]),
+      ],
+      {},
+      false,
+    );
+    expect(groups[0].displayName).toBe('PLA Basic');
+    expect(groups[0].trayColor).toBe('#1A1A1A');
+  });
+});

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

@@ -2172,6 +2172,7 @@ export interface NotificationProvider {
   // Printer status events
   on_printer_offline: boolean;
   on_printer_error: boolean;
+  on_ai_failure_detection: boolean;
   on_filament_low: boolean;
   on_maintenance_due: boolean;
   // AMS environmental alarms (regular AMS)
@@ -2230,6 +2231,7 @@ export interface NotificationProviderCreate {
   // Printer status events
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
+  on_ai_failure_detection?: boolean;
   on_filament_low?: boolean;
   on_maintenance_due?: boolean;
   // AMS environmental alarms (regular AMS)
@@ -2281,6 +2283,7 @@ export interface NotificationProviderUpdate {
   // Printer status events
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
+  on_ai_failure_detection?: boolean;
   on_filament_low?: boolean;
   on_maintenance_due?: boolean;
   // AMS environmental alarms (regular AMS)

+ 7 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -38,6 +38,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
   const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
   const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
+  const [onAiFailureDetection, setOnAiFailureDetection] = useState(provider?.on_ai_failure_detection ?? false);
   const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
   const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
   const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
@@ -167,6 +168,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_print_progress: onPrintProgress,
       on_printer_offline: onPrinterOffline,
       on_printer_error: onPrinterError,
+      on_ai_failure_detection: onAiFailureDetection,
       on_filament_low: onFilamentLow,
       on_maintenance_due: onMaintenanceDue,
       on_stock_reorder_alert: onStockReorderAlert,
@@ -547,6 +549,10 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   <span className="text-sm text-white">{t('notifications.error')}</span>
                   <Toggle checked={onPrinterError} onChange={setOnPrinterError} />
                 </div>
+                <div className="flex items-center justify-between">
+                  <span className="text-sm text-white">{t('notifications.aiFailureDetection')}</span>
+                  <Toggle checked={onAiFailureDetection} onChange={setOnAiFailureDetection} />
+                </div>
                 <div className="flex items-center justify-between">
                   <span className="text-sm text-white">{t('notifications.lowFilament')}</span>
                   <Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />
@@ -591,6 +597,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
               if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
               if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
+              if (onAiFailureDetection) enabledEvents.push({ key: 'on_ai_failure_detection', label: t('notifications.aiFailureDetection') });
               if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
               if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });
               if (onStockReorderAlert) enabledEvents.push({ key: 'on_stock_reorder_alert', label: t('notifications.stockReorderAlert') });

+ 334 - 0
frontend/src/components/AmsBackupModal.tsx

@@ -0,0 +1,334 @@
+/**
+ * #1762 — AMS Filament Backup status modal.
+ *
+ * Opens from the AmsBackupBadge click. Shows the global toggle and a
+ * BambuStudio-style ring graphic per backup pair — each ring represents
+ * the rotation order the firmware will follow when the active slot runs
+ * out.
+ *
+ * On dual-extruder printers, each ring carries a small "R" / "L" badge
+ * because the firmware can't cross extruders even with the global backup
+ * bit set.
+ *
+ * Theme-aware via CSS variables, matching AMSHistoryModal — adapts to
+ * every background variant the user has picked.
+ */
+import { useEffect } from 'react';
+import { X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+import { Toggle } from './Toggle';
+import {
+  computeBackupGroups,
+  normalizeColor,
+  type AmsUnitLike,
+  type BackupGroup,
+} from '../utils/amsHelpers';
+
+interface AmsBackupModalProps {
+  isOpen: boolean;
+  state: boolean | null;
+  amsUnits: AmsUnitLike[] | undefined;
+  amsExtruderMap: Record<string, number> | undefined;
+  isDualNozzle: boolean;
+  canToggle: boolean;
+  pending: boolean;
+  onToggle: (next: boolean) => void;
+  onClose: () => void;
+}
+
+/**
+ * Compact slot label like "A·3" / "HT·1" — the ring is small, every char
+ * counts toward readability.
+ */
+function formatSlotLabel(amsId: number, slotIdx: number, totalTraysOnUnit: number): string {
+  const isHt = totalTraysOnUnit === 1 || amsId >= 128;
+  const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
+  const letter = String.fromCharCode(65 + normalizedId);
+  return isHt ? `HT·${slotIdx + 1}` : `${letter}·${slotIdx + 1}`;
+}
+
+/** Pick a readable text colour for a given filament hex. */
+function pickContrastTextColor(rgbaHex: string | null | undefined): string {
+  const s = (rgbaHex || '').replace('#', '').slice(0, 6);
+  if (s.length !== 6) return '#FFFFFF';
+  const r = parseInt(s.slice(0, 2), 16);
+  const g = parseInt(s.slice(2, 4), 16);
+  const b = parseInt(s.slice(4, 6), 16);
+  if ([r, g, b].some(Number.isNaN)) return '#FFFFFF';
+  const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
+  return luma > 0.55 ? '#1A1A1A' : '#FFFFFF';
+}
+
+function BackupRing({
+  group,
+  trayCountByAms,
+  innerBg,
+  textPrimary,
+  textSecondary,
+  showExtruderBadge,
+  extruderLabel,
+}: {
+  group: BackupGroup;
+  trayCountByAms: Map<number, number>;
+  innerBg: string;
+  textPrimary: string;
+  textSecondary: string;
+  showExtruderBadge: boolean;
+  extruderLabel: string;
+}) {
+  const filamentHex = normalizeColor(group.trayColor || undefined);
+  const ringTextColor = pickContrastTextColor(group.trayColor);
+  // The pill background that sits behind each slot label — keeps text legible
+  // regardless of the filament fill colour.
+  const labelPillBg = ringTextColor === '#FFFFFF' ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.7)';
+  const n = group.members.length;
+
+  // Geometry: -100..100 viewport. Outer ring 92, inner cutout 56.
+  // Slot labels sit on the colour band at radius 76.
+  const labelRadius = 76;
+
+  return (
+    <div className="relative flex flex-col items-center">
+      {showExtruderBadge && (
+        <span
+          className="absolute -top-1 -left-1 z-10 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shadow"
+          style={{
+            backgroundColor: textPrimary,
+            color: innerBg,
+          }}
+          aria-label={extruderLabel}
+          title={extruderLabel}
+        >
+          {extruderLabel}
+        </span>
+      )}
+      <svg viewBox="-100 -100 200 200" className="w-44 h-44">
+        {/* Subtle outer ring — gives a crisp edge on light AND dark themes. */}
+        <circle cx="0" cy="0" r="95" fill="none" stroke={textSecondary} strokeOpacity="0.25" strokeWidth="1" />
+        {/* Colour band */}
+        <circle cx="0" cy="0" r="92" fill={filamentHex} />
+        {/* Inner cutout */}
+        <circle cx="0" cy="0" r="56" fill={innerBg} />
+        {/* Inner ring border for definition between centre and colour band */}
+        <circle cx="0" cy="0" r="56" fill="none" stroke={textSecondary} strokeOpacity="0.3" strokeWidth="1" />
+        {/* Centre: material name */}
+        <text
+          x="0"
+          y="-4"
+          textAnchor="middle"
+          dominantBaseline="middle"
+          fontSize="14"
+          fontWeight="700"
+          fill={textPrimary}
+        >
+          {group.displayName || '—'}
+        </text>
+        {/* Centre: rotation count */}
+        <text
+          x="0"
+          y="16"
+          textAnchor="middle"
+          dominantBaseline="middle"
+          fontSize="11"
+          fontWeight="500"
+          fill={textSecondary}
+        >
+          {`${n}× ↻`}
+        </text>
+        {/* Slot labels around the ring, each on a pill for legibility */}
+        {group.members.map((m, i) => {
+          const angleDeg = (i * 360) / n - 90;
+          const rad = (angleDeg * Math.PI) / 180;
+          const x = labelRadius * Math.cos(rad);
+          const y = labelRadius * Math.sin(rad);
+          const label = formatSlotLabel(m.amsId, m.slotIdx, trayCountByAms.get(m.amsId) ?? 4);
+          // Approximate pill width based on char count (each digit ≈ 6.5 px @ 12 px font).
+          const pillWidth = Math.max(22, label.length * 7 + 8);
+          return (
+            <g key={`${m.amsId}-${m.slotIdx}`}>
+              <rect
+                x={x - pillWidth / 2}
+                y={y - 9}
+                width={pillWidth}
+                height={18}
+                rx={9}
+                ry={9}
+                fill={labelPillBg}
+              />
+              <text
+                x={x}
+                y={y}
+                textAnchor="middle"
+                dominantBaseline="middle"
+                fontSize="12"
+                fontWeight="700"
+                fill={ringTextColor}
+              >
+                {label}
+              </text>
+            </g>
+          );
+        })}
+      </svg>
+    </div>
+  );
+}
+
+export function AmsBackupModal({
+  isOpen,
+  state,
+  amsUnits,
+  amsExtruderMap,
+  isDualNozzle,
+  canToggle,
+  pending,
+  onToggle,
+  onClose,
+}: AmsBackupModalProps) {
+  const { t } = useTranslation();
+
+  // Close on Escape key while the modal is open. Captures at the window
+  // level so it works even when focus isn't inside the modal subtree
+  // (e.g. after the Toggle is clicked).
+  useEffect(() => {
+    if (!isOpen) return;
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        e.stopPropagation();
+        onClose();
+      }
+    };
+    window.addEventListener('keydown', onKey);
+    return () => window.removeEventListener('keydown', onKey);
+  }, [isOpen, onClose]);
+
+  if (!isOpen) return null;
+
+  // Theme-aware tokens, matching AMSHistoryModal.
+  const modalBg = 'var(--bg-secondary)';
+  const sectionBg = 'var(--bg-primary)';
+  const borderColor = 'var(--border-color)';
+  const textPrimary = 'var(--text-primary)';
+  const textSecondary = 'var(--text-secondary)';
+
+  // Effective dual-nozzle detection: only split per extruder if the map
+  // actually carries 2 distinct values across the AMS units we have data
+  // for. Empty / single-value maps collapse to a single section to avoid
+  // misleading badges.
+  const effectiveDualNozzle = (() => {
+    if (!isDualNozzle) return false;
+    if (!amsExtruderMap) return false;
+    const distinctValues = new Set<number>();
+    for (const ams of amsUnits || []) {
+      const raw = amsExtruderMap[String(ams.id)];
+      if (raw === undefined) continue;
+      distinctValues.add(Number(raw));
+      if (distinctValues.size > 1) return true;
+    }
+    return false;
+  })();
+
+  const groups = computeBackupGroups(amsUnits, amsExtruderMap, effectiveDualNozzle);
+  const trayCountByAms = new Map<number, number>(
+    (amsUnits || []).map((u) => [u.id, u.tray.length]),
+  );
+
+  // Only pairs are rendered — lone slots are deliberately suppressed.
+  const pairs = groups.filter((g) => g.members.length >= 2);
+
+  const isOn = state === true;
+  const isUnknown = state === null;
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+      data-testid="ams-backup-modal"
+    >
+      <div
+        className="rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden shadow-xl flex flex-col"
+        style={{ backgroundColor: modalBg }}
+        onClick={(e) => e.stopPropagation()}
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="ams-backup-modal-title"
+      >
+        <div
+          className="flex items-center justify-between px-5 py-3 border-b"
+          style={{ borderColor }}
+        >
+          <h2
+            id="ams-backup-modal-title"
+            className="text-base font-semibold"
+            style={{ color: textPrimary }}
+          >
+            {t('printers.amsBackup.modalTitle')}
+          </h2>
+          <button
+            type="button"
+            onClick={onClose}
+            className="p-1 rounded-md transition-colors hover:bg-black/10"
+            style={{ color: textSecondary }}
+            aria-label={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <div
+          className="flex items-center justify-between px-5 py-3 border-b"
+          style={{ borderColor, backgroundColor: sectionBg }}
+        >
+          <div className="min-w-0 mr-3">
+            <div className="text-sm font-medium" style={{ color: textPrimary }}>
+              {isUnknown
+                ? t('printers.amsBackup.stateUnknown')
+                : isOn
+                  ? t('printers.amsBackup.stateOn')
+                  : t('printers.amsBackup.stateOff')}
+            </div>
+            <p className="text-xs mt-0.5" style={{ color: textSecondary }}>
+              {t('printers.amsBackup.modalHelp')}
+            </p>
+          </div>
+          <Toggle
+            checked={isOn}
+            onChange={onToggle}
+            disabled={!canToggle || isUnknown || pending}
+          />
+        </div>
+
+        <div className="flex-1 overflow-y-auto px-5 py-6">
+          {pairs.length === 0 ? (
+            <p
+              className="text-sm text-center py-8"
+              style={{ color: textSecondary }}
+            >
+              {t('printers.amsBackup.modalNoPairs')}
+            </p>
+          ) : (
+            <div className="grid grid-cols-1 sm:grid-cols-2 gap-6 justify-items-center">
+              {pairs.map((g) => (
+                <BackupRing
+                  key={g.key}
+                  group={g}
+                  trayCountByAms={trayCountByAms}
+                  innerBg={modalBg}
+                  textPrimary={textPrimary}
+                  textSecondary={textSecondary}
+                  showExtruderBadge={effectiveDualNozzle}
+                  extruderLabel={
+                    g.extruder === 0
+                      ? t('printers.amsBackup.extruderRightShort')
+                      : t('printers.amsBackup.extruderLeftShort')
+                  }
+                />
+              ))}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 14 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -138,6 +138,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_printer_error && (
               <span className="px-2 py-0.5 bg-rose-500/20 text-rose-400 text-xs rounded">{t('notifications.error')}</span>
             )}
+            {provider.on_ai_failure_detection && (
+              <span className="px-2 py-0.5 bg-fuchsia-500/20 text-fuchsia-300 text-xs rounded">{t('notifications.aiFailureDetection')}</span>
+            )}
             {provider.on_filament_low && (
               <span className="px-2 py-0.5 bg-cyan-500/20 text-cyan-400 text-xs rounded">{t('notifications.lowFilament')}</span>
             )}
@@ -366,6 +369,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                 </div>
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.aiFailureDetection')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.aiFailureDetectionDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_ai_failure_detection ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_ai_failure_detection: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
 <Toggle

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

@@ -547,6 +547,19 @@ export default {
       titleUnknown: 'AMS-Filament-Backup-Status auf diesem Drucker nicht verfügbar.',
       toastEnabled: 'AMS Filament Backup aktiviert',
       toastDisabled: 'AMS Filament Backup deaktiviert',
+      modalTitle: 'AMS Filament Backup',
+      modalHelp: 'Wenn der aktive Slot leer wird, wechselt der Drucker in dieser Reihenfolge zu Slots mit demselben Preset und derselben Farbe.',
+      modalNoSlots: 'Kein Filament geladen.',
+      modalNoPairs: 'Keine Backup-Paare — keine zwei Slots teilen sich Filament-Preset und Farbe.',
+      extruderRightShort: 'R',
+      extruderLeftShort: 'L',
+      stateOn: 'Aktiviert',
+      stateOff: 'Deaktiviert',
+      stateUnknown: 'Auf diesem Drucker nicht unterstützt',
+    },
+    activeJobSlot: {
+      title: 'Dieser Slot ist Filament {{n}} im aktiven Druck',
+      ariaLabel: 'Aktiver Druck-Slot {{n}}',
     },
     // Filaments section
     filaments: 'Filamente',
@@ -5018,6 +5031,8 @@ export default {
     progressMilestonesDescription: 'Benachrichtigung bei 25%, 50%, 75%',
     printerOffline: 'Drucker offline',
     printerError: 'Druckerfehler',
+    aiFailureDetection: 'KI-Fehlererkennung',
+    aiFailureDetectionDescription: 'Benachrichtigen, wenn die Obico-KI einen möglichen Druckfehler erkennt',
     lowFilamentLabel: 'Filament niedrig',
     maintenanceDue: 'Wartung fällig',
     maintenanceDueDescription: 'Benachrichtigen, wenn Wartung erforderlich ist',

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

@@ -551,6 +551,19 @@ export default {
       titleUnknown: 'AMS Filament Backup status unavailable on this printer.',
       toastEnabled: 'AMS Filament Backup enabled',
       toastDisabled: 'AMS Filament Backup disabled',
+      modalTitle: 'AMS Filament Backup',
+      modalHelp: 'When the active slot runs out, the printer cycles through any matching same-preset, same-colour slots in this order.',
+      modalNoSlots: 'No filament loaded.',
+      modalNoPairs: 'No backup pairs — no two slots share the same filament profile and colour.',
+      extruderRightShort: 'R',
+      extruderLeftShort: 'L',
+      stateOn: 'Enabled',
+      stateOff: 'Disabled',
+      stateUnknown: 'Unsupported on this printer',
+    },
+    activeJobSlot: {
+      title: 'This slot is filament {{n}} in the active print',
+      ariaLabel: 'Active print slot {{n}}',
     },
     // Filaments section
     filaments: 'Filaments',
@@ -5043,6 +5056,8 @@ export default {
     progressMilestonesDescription: 'Notify at 25%, 50%, 75%',
     printerOffline: 'Printer Offline',
     printerError: 'Printer Error',
+    aiFailureDetection: 'AI Failure Detection',
+    aiFailureDetectionDescription: 'Notify when Obico AI detects a possible print failure',
     lowFilamentLabel: 'Low Filament',
     maintenanceDue: 'Maintenance Due',
     maintenanceDueDescription: 'Notify when maintenance is needed',

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

@@ -547,6 +547,19 @@ export default {
       titleUnknown: 'Estado de AMS Filament Backup no disponible en esta impresora.',
       toastEnabled: 'AMS Filament Backup activado',
       toastDisabled: 'AMS Filament Backup desactivado',
+      modalTitle: 'AMS Filament Backup',
+      modalHelp: 'Cuando el slot activo se agota, la impresora rota a slots con el mismo preset y color en este orden.',
+      modalNoSlots: 'Sin filamento cargado.',
+      modalNoPairs: 'No hay pares de respaldo — no hay dos slots que compartan preset y color.',
+      extruderRightShort: 'D',
+      extruderLeftShort: 'I',
+      stateOn: 'Activado',
+      stateOff: 'Desactivado',
+      stateUnknown: 'No compatible con esta impresora',
+    },
+    activeJobSlot: {
+      title: 'Este slot es el filamento {{n}} en la impresión activa',
+      ariaLabel: 'Slot de impresión activa {{n}}',
     },
     // Filaments section
     filaments: 'Filamentos',
@@ -5027,6 +5040,8 @@ export default {
     progressMilestonesDescription: 'Notificar al 25%, 50% y 75%',
     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',
     lowFilamentLabel: 'Filamento bajo',
     maintenanceDue: 'Mantenimiento pendiente',
     maintenanceDueDescription: 'Notificar cuando se necesite mantenimiento',

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

@@ -547,6 +547,19 @@ export default {
       titleUnknown: "État de l'AMS Filament Backup indisponible sur cette imprimante.",
       toastEnabled: "AMS Filament Backup activé",
       toastDisabled: "AMS Filament Backup désactivé",
+      modalTitle: 'AMS Filament Backup',
+      modalHelp: "Lorsque l'emplacement actif s'épuise, l'imprimante passe aux emplacements ayant le même preset et la même couleur dans cet ordre.",
+      modalNoSlots: "Aucun filament chargé.",
+      modalNoPairs: "Aucune paire de secours — aucun emplacement ne partage à la fois preset et couleur.",
+      extruderRightShort: "D",
+      extruderLeftShort: "G",
+      stateOn: "Activé",
+      stateOff: "Désactivé",
+      stateUnknown: "Non pris en charge par cette imprimante",
+    },
+    activeJobSlot: {
+      title: 'Cet emplacement est le filament {{n}} dans l\'impression active',
+      ariaLabel: 'Emplacement d\'impression active {{n}}',
     },
     // Filaments section
     filaments: 'Filaments',
@@ -5008,6 +5021,8 @@ export default {
     progressMilestonesDescription: 'Notifier à 25 %, 50 %, 75 %',
     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',
     lowFilamentLabel: 'Filament bas',
     maintenanceDue: 'Maintenance requise',
     maintenanceDueDescription: 'Notifier lorsqu\'une maintenance est nécessaire',

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

@@ -547,6 +547,19 @@ export default {
       titleUnknown: 'Stato di AMS Filament Backup non disponibile su questa stampante.',
       toastEnabled: 'AMS Filament Backup abilitato',
       toastDisabled: 'AMS Filament Backup disabilitato',
+      modalTitle: 'AMS Filament Backup',
+      modalHelp: "Quando lo slot attivo si esaurisce, la stampante passa agli slot con lo stesso preset e colore in quest'ordine.",
+      modalNoSlots: 'Nessun filamento caricato.',
+      modalNoPairs: 'Nessuna coppia di backup — nessuno slot condivide preset e colore.',
+      extruderRightShort: 'D',
+      extruderLeftShort: 'S',
+      stateOn: 'Abilitato',
+      stateOff: 'Disabilitato',
+      stateUnknown: 'Non supportato su questa stampante',
+    },
+    activeJobSlot: {
+      title: 'Questo slot è il filamento {{n}} nella stampa attiva',
+      ariaLabel: 'Slot stampa attiva {{n}}',
     },
     // Filaments section
     filaments: 'Filamenti',
@@ -5007,6 +5020,8 @@ export default {
     progressMilestonesDescription: 'Notifica al 25%, 50%, 75%',
     printerOffline: 'Stampante offline',
     printerError: 'Errore stampante',
+    aiFailureDetection: 'Rilevamento guasti IA',
+    aiFailureDetectionDescription: 'Notifica quando l\'IA di Obico rileva un possibile guasto di stampa',
     lowFilamentLabel: 'Filamento scarso',
     maintenanceDue: 'Manutenzione necessaria',
     maintenanceDueDescription: 'Notifica quando è necessaria la manutenzione',

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

@@ -546,6 +546,19 @@ export default {
       titleUnknown: 'このプリンタではAMSフィラメントバックアップ状態を確認できません。',
       toastEnabled: 'AMSフィラメントバックアップを有効化しました',
       toastDisabled: 'AMSフィラメントバックアップを無効化しました',
+      modalTitle: 'AMS フィラメントバックアップ',
+      modalHelp: 'アクティブなスロットが空になると、プリンターは同じプリセット・同じ色のスロットをこの順序で循環します。',
+      modalNoSlots: 'フィラメントが読み込まれていません。',
+      modalNoPairs: 'バックアップペアがありません — プリセットと色の両方が一致するスロットがありません。',
+      extruderRightShort: '右',
+      extruderLeftShort: '左',
+      stateOn: '有効',
+      stateOff: '無効',
+      stateUnknown: 'このプリンターでは未対応',
+    },
+    activeJobSlot: {
+      title: 'このスロットはアクティブな印刷のフィラメント {{n}} です',
+      ariaLabel: 'アクティブ印刷スロット {{n}}',
     },
     // Filaments section
     filaments: 'フィラメント',
@@ -5019,6 +5032,8 @@ export default {
     progressMilestonesDescription: '25%、50%、75%で通知',
     printerOffline: 'プリンターオフライン',
     printerError: 'プリンターエラー',
+    aiFailureDetection: 'AI 故障検出',
+    aiFailureDetectionDescription: 'Obico AI が印刷の不具合の可能性を検出したときに通知',
     lowFilamentLabel: 'フィラメント残量低下',
     maintenanceDue: 'メンテナンス期限',
     maintenanceDueDescription: 'メンテナンスが必要な場合に通知',

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

@@ -509,7 +509,20 @@ export default {
       titleOff: 'AMS 필라멘트 백업이 꺼져 있습니다. 활성화하려면 클릭하세요.',
       titleUnknown: '이 프린터에서는 AMS 필라멘트 백업 상태를 확인할 수 없습니다.',
       toastEnabled: 'AMS 필라멘트 백업이 활성화되었습니다',
-      toastDisabled: 'AMS 필라멘트 백업이 비활성화되었습니다'
+      toastDisabled: 'AMS 필라멘트 백업이 비활성화되었습니다',
+      modalTitle: 'AMS 필라멘트 백업',
+      modalHelp: '활성 슬롯이 소진되면 프린터는 같은 프리셋과 색상의 슬롯을 이 순서로 순환합니다.',
+      modalNoSlots: '필라멘트가 로드되지 않았습니다.',
+      modalNoPairs: '백업 쌍이 없습니다 — 프리셋과 색상이 모두 일치하는 슬롯이 없습니다.',
+      extruderRightShort: '우',
+      extruderLeftShort: '좌',
+      stateOn: '활성화됨',
+      stateOff: '비활성화됨',
+      stateUnknown: '이 프린터에서 지원되지 않음'
+    },
+    activeJobSlot: {
+      title: '이 슬롯은 활성 인쇄의 필라멘트 {{n}}입니다',
+      ariaLabel: '활성 인쇄 슬롯 {{n}}'
     },
     filaments: '필라멘트',
     openCameraOverlay: '카메라 오버레이 열기',
@@ -4740,6 +4753,8 @@ export default {
     progressMilestonesDescription: '25%, 50%, 75%에서 알림',
     printerOffline: '프린터 오프라인',
     printerError: '프린터 오류',
+    aiFailureDetection: 'AI 실패 감지',
+    aiFailureDetectionDescription: 'Obico AI가 인쇄 실패 가능성을 감지하면 알림',
     lowFilamentLabel: '필라멘트 부족',
     maintenanceDue: '유지 관리 필요',
     maintenanceDueDescription: '유지 관리가 필요할 때 알림',

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

@@ -547,6 +547,19 @@ export default {
       titleUnknown: 'Estado do AMS Filament Backup indisponível nesta impressora.',
       toastEnabled: 'AMS Filament Backup ativado',
       toastDisabled: 'AMS Filament Backup desativado',
+      modalTitle: 'AMS Filament Backup',
+      modalHelp: 'Quando o slot ativo termina, a impressora alterna para slots com o mesmo preset e cor nesta ordem.',
+      modalNoSlots: 'Nenhum filamento carregado.',
+      modalNoPairs: 'Sem pares de backup — nenhum par de slots compartilha preset e cor.',
+      extruderRightShort: 'D',
+      extruderLeftShort: 'E',
+      stateOn: 'Ativado',
+      stateOff: 'Desativado',
+      stateUnknown: 'Não suportado nesta impressora',
+    },
+    activeJobSlot: {
+      title: 'Este slot é o filamento {{n}} na impressão ativa',
+      ariaLabel: 'Slot de impressão ativa {{n}}',
     },
     // Filaments section
     filaments: 'Filamentos',
@@ -5007,6 +5020,8 @@ export default {
     progressMilestonesDescription: 'Notificar em 25%, 50%, 75%',
     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',
     lowFilamentLabel: 'Filamento Baixo',
     maintenanceDue: 'Manutenção Necessária',
     maintenanceDueDescription: 'Notificar quando manutenção for necessária',

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

@@ -547,6 +547,19 @@ export default {
       titleUnknown: 'Bu yazıcıda AMS Filament Backup durumu kullanılamıyor.',
       toastEnabled: 'AMS Filament Backup etkinleştirildi',
       toastDisabled: 'AMS Filament Backup devre dışı bırakıldı',
+      modalTitle: 'AMS Filament Backup',
+      modalHelp: 'Aktif slot tükendiğinde, yazıcı aynı ön ayar ve renkteki slotları bu sırayla dolaşır.',
+      modalNoSlots: 'Yüklenmiş filament yok.',
+      modalNoPairs: 'Yedek çifti yok — hiçbir slot çifti aynı ön ayar ve rengi paylaşmıyor.',
+      extruderRightShort: 'S',
+      extruderLeftShort: 'L',
+      stateOn: 'Etkin',
+      stateOff: 'Devre dışı',
+      stateUnknown: 'Bu yazıcıda desteklenmiyor',
+    },
+    activeJobSlot: {
+      title: 'Bu slot, aktif baskıdaki {{n}} numaralı filament',
+      ariaLabel: 'Aktif baskı slotu {{n}}',
     },
     // Filamentler bölümü
     filaments: 'Filamentler',
@@ -4974,6 +4987,8 @@ export default {
     progressMilestonesDescription: '%25, %50, %75\'te bildir',
     printerOffline: 'Yazıcı Çevrimdışı',
     printerError: 'Yazıcı Hatası',
+    aiFailureDetection: 'AI Hata Tespiti',
+    aiFailureDetectionDescription: 'Obico AI olası bir baskı hatası tespit ettiğinde bildir',
     lowFilamentLabel: 'Az Filament',
     maintenanceDue: 'Bakım Zamanı',
     maintenanceDueDescription: 'Bakım gerektiğinde bildir',

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

@@ -547,6 +547,19 @@ export default {
       titleUnknown: '本打印机不支持读取 AMS 备用料盘状态。',
       toastEnabled: 'AMS 备用料盘已启用',
       toastDisabled: 'AMS 备用料盘已禁用',
+      modalTitle: 'AMS 备用料盘',
+      modalHelp: '当前料槽用尽时,打印机会按此顺序在使用相同预设和颜色的料槽之间循环。',
+      modalNoSlots: '未加载耗材。',
+      modalNoPairs: '没有备用料盘对 — 没有两个料槽同时匹配预设和颜色。',
+      extruderRightShort: '右',
+      extruderLeftShort: '左',
+      stateOn: '已启用',
+      stateOff: '已禁用',
+      stateUnknown: '此打印机不支持',
+    },
+    activeJobSlot: {
+      title: '此料槽在当前打印中是耗材 {{n}}',
+      ariaLabel: '当前打印料槽 {{n}}',
     },
     // Filaments section
     filaments: '耗材',
@@ -5007,6 +5020,8 @@ export default {
     progressMilestonesDescription: '在 25%、50%、75% 时通知',
     printerOffline: '打印机离线',
     printerError: '打印机错误',
+    aiFailureDetection: 'AI 失败检测',
+    aiFailureDetectionDescription: '当 Obico AI 检测到可能的打印失败时通知',
     lowFilamentLabel: '耗材不足',
     maintenanceDue: '需要维护',
     maintenanceDueDescription: '需要维护时通知',

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

@@ -547,6 +547,19 @@ export default {
       titleUnknown: '本印表機不支援讀取 AMS 備用料盤狀態。',
       toastEnabled: 'AMS 備用料盤已啟用',
       toastDisabled: 'AMS 備用料盤已停用',
+      modalTitle: 'AMS 備用料盤',
+      modalHelp: '目前料槽用盡時,印表機會按此順序在使用相同預設和顏色的料槽之間循環。',
+      modalNoSlots: '未載入耗材。',
+      modalNoPairs: '沒有備用料盤對 — 沒有兩個料槽同時匹配預設和顏色。',
+      extruderRightShort: '右',
+      extruderLeftShort: '左',
+      stateOn: '已啟用',
+      stateOff: '已停用',
+      stateUnknown: '此印表機不支援',
+    },
+    activeJobSlot: {
+      title: '此料槽在目前列印中是耗材 {{n}}',
+      ariaLabel: '目前列印料槽 {{n}}',
     },
     // Filaments section
     filaments: '耗材',
@@ -5007,6 +5020,8 @@ export default {
     progressMilestonesDescription: '在 25%、50%、75% 時通知',
     printerOffline: '印表機離線',
     printerError: '印表機錯誤',
+    aiFailureDetection: 'AI 失敗偵測',
+    aiFailureDetectionDescription: '當 Obico AI 偵測到可能的列印失敗時通知',
     lowFilamentLabel: '耗材不足',
     maintenanceDue: '需要維護',
     maintenanceDueDescription: '需要維護時通知',

+ 66 - 16
frontend/src/pages/PrintersPage.tsx

@@ -98,6 +98,7 @@ import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
+import { AmsBackupModal } from '../components/AmsBackupModal';
 import { HeaterHistoryModal } from '../components/HeaterHistoryModal';
 import type { HeaterSensorKind } from '../api/client';
 import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
@@ -697,29 +698,26 @@ function HeaterThermometer({ className, color, isHeating }: HeaterThermometerPro
 
 // AMS Filament Backup tri-state indicator + toggle.
 // state=true  → ON, click to disable
-// state=false → OFF, click to enable
-// state=null  → unknown/unsupported (e.g. A1 family), no click action
+// state=false → OFF, click opens modal
+// state=null  → unknown/unsupported (e.g. A1 family), click disabled
 interface AmsBackupBadgeProps {
   state: boolean | null;
-  canToggle: boolean;
-  pending: boolean;
-  onToggle: (next: boolean) => void;
+  onClick: () => void;
 }
 
-function AmsBackupBadge({ state, canToggle, pending, onToggle }: AmsBackupBadgeProps) {
+function AmsBackupBadge({ state, onClick }: AmsBackupBadgeProps) {
   const { t } = useTranslation();
   const known = state !== null;
-  const clickable = canToggle && known && !pending;
 
   let className = 'flex items-center justify-center w-[18px] h-[18px] rounded text-[10px] transition-colors ';
   let title: string;
   if (state === true) {
-    className += clickable
+    className += known
       ? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 cursor-pointer'
       : 'bg-blue-500/20 text-blue-400 cursor-default';
     title = t('printers.amsBackup.titleOn');
   } else if (state === false) {
-    className += clickable
+    className += known
       ? 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80 cursor-pointer'
       : 'bg-bambu-dark text-bambu-gray cursor-default';
     title = t('printers.amsBackup.titleOff');
@@ -731,8 +729,8 @@ function AmsBackupBadge({ state, canToggle, pending, onToggle }: AmsBackupBadgeP
   return (
     <button
       type="button"
-      disabled={!clickable}
-      onClick={() => clickable && onToggle(!state)}
+      disabled={!known}
+      onClick={() => known && onClick()}
       className={className}
       title={title}
       aria-label={title}
@@ -1820,6 +1818,8 @@ function PrinterCard({
   const [showPowerOffConfirm, setShowPowerOffConfirm] = useState(false);
   const [haToggleConfirm, setHaToggleConfirm] = useState<SmartPlug | null>(null);
   const [showHMSModal, setShowHMSModal] = useState(false);
+  // #1762: AMS Filament Backup status / control modal — opens from the badge.
+  const [amsBackupModalOpen, setAmsBackupModalOpen] = useState(false);
   const [showStopConfirm, setShowStopConfirm] = useState(false);
   const [showPauseConfirm, setShowPauseConfirm] = useState(false);
   const [showSpeedMenu, setShowSpeedMenu] = useState<number | null>(null);
@@ -4412,6 +4412,15 @@ function PrinterCard({
               const htAms = amsData.filter(ams => ams.tray.length === 1);
               const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
               const filamentSlotClass = 'min-w-14';
+              // #1762 (comment 2): while a print is running/paused, overlay a small
+              // "P1 / P2 / P3" pill on each slot referenced by the active print's
+              // mapping. Catches the reporter's scenario — "any X1C" queue job
+              // staged to a printer with mismatched filament: the wrong-slot pill
+              // is visible the instant printing starts.
+              const isPrintingForMapping = status.state === 'RUNNING' || status.state === 'PAUSE';
+              const activeMapping: number[] = isPrintingForMapping && Array.isArray(status.ams_mapping)
+                ? status.ams_mapping
+                : [];
               const getAmsCardStyle = (slotCount: number): React.CSSProperties => {
                 const boundedSlotCount = Math.max(1, slotCount);
                 const gapCount = Math.max(0, boundedSlotCount - 1);
@@ -4431,9 +4440,7 @@ function PrinterCard({
                     </span>
                     <AmsBackupBadge
                       state={status.ams_filament_backup}
-                      canToggle={hasPermission('printers:control')}
-                      pending={setAmsBackupMutation.isPending}
-                      onToggle={(next) => setAmsBackupMutation.mutate(next)}
+                      onClick={() => setAmsBackupModalOpen(true)}
                     />
                     <div className="flex-1 h-[2px] bg-bambu-dark-tertiary" />
                   </div>
@@ -4637,11 +4644,25 @@ function PrinterCard({
                                 const isRefreshing = refreshingSlot?.amsId === ams.id &&
                                   refreshingSlot?.slotId === slotIdx;
 
+                                // #1762 (comment 2): which print-slot is mapped to THIS AMS slot.
+                                const activePrintSlotIdx = activeMapping.indexOf(globalTrayId);
+                                const activePrintSlotLabel = activePrintSlotIdx >= 0
+                                  ? `P${activePrintSlotIdx + 1}`
+                                  : null;
                                 // Slot visual content (goes inside hover card)
                                 const slotVisual = (
                                   <div
-                                    className={`w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
+                                    className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
                                   >
+                                    {activePrintSlotLabel && (
+                                      <span
+                                        aria-label={t('printers.activeJobSlot.ariaLabel', { n: activePrintSlotIdx + 1 })}
+                                        title={t('printers.activeJobSlot.title', { n: activePrintSlotIdx + 1 })}
+                                        className="absolute top-0.5 right-0.5 px-1 py-px text-[8px] font-bold text-bambu-dark bg-bambu-green rounded pointer-events-none leading-none"
+                                      >
+                                        {activePrintSlotLabel}
+                                      </span>
+                                    )}
                                     {/* Filament color circle with 1-based slot number centered inside */}
                                     <FilamentSlotCircle
                                       trayColor={tray?.tray_color}
@@ -4895,11 +4916,25 @@ function PrinterCard({
                         const isHtRefreshing = refreshingSlot?.amsId === ams.id &&
                           refreshingSlot?.slotId === htSlotId;
 
+                        // #1762 (comment 2): active print-slot index for this HT slot.
+                        const htActivePrintSlotIdx = activeMapping.indexOf(globalTrayId);
+                        const htActivePrintSlotLabel = htActivePrintSlotIdx >= 0
+                          ? `P${htActivePrintSlotIdx + 1}`
+                          : null;
                         // Slot visual content (goes inside hover card)
                         const slotVisual = (
                           <div
-                            className={`w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
+                            className={`relative w-full bg-bambu-dark-secondary rounded-lg p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
                           >
+                            {htActivePrintSlotLabel && (
+                              <span
+                                aria-label={t('printers.activeJobSlot.ariaLabel', { n: htActivePrintSlotIdx + 1 })}
+                                title={t('printers.activeJobSlot.title', { n: htActivePrintSlotIdx + 1 })}
+                                className="absolute top-0.5 right-0.5 px-1 py-px text-[8px] font-bold text-bambu-dark bg-bambu-green rounded pointer-events-none leading-none"
+                              >
+                                {htActivePrintSlotLabel}
+                              </span>
+                            )}
                             {/* Filament color circle with 1-based slot number centered inside */}
                             <FilamentSlotCircle
                               trayColor={tray?.tray_color}
@@ -6134,6 +6169,21 @@ function PrinterCard({
         />
       )}
 
+      {/* AMS Filament Backup status / control modal (#1762) */}
+      {amsBackupModalOpen && status && (
+        <AmsBackupModal
+          isOpen={amsBackupModalOpen}
+          state={status.ams_filament_backup}
+          amsUnits={status.ams}
+          amsExtruderMap={status.ams_extruder_map}
+          isDualNozzle={printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined}
+          canToggle={hasPermission('printers:control')}
+          pending={setAmsBackupMutation.isPending}
+          onToggle={(next) => setAmsBackupMutation.mutate(next)}
+          onClose={() => setAmsBackupModalOpen(false)}
+        />
+      )}
+
       {/* AMS History Modal */}
       {amsHistoryModal && (
         <AMSHistoryModal

+ 139 - 0
frontend/src/utils/amsHelpers.ts

@@ -343,3 +343,142 @@ export function isBambuLabSpool(tray: {
   if (tray.tag_uid && tray.tag_uid !== '0000000000000000') return true;
   return false;
 }
+
+export interface AmsTrayLike {
+  id: number;
+  tray_type: string | null | undefined;
+  tray_sub_brands: string | null | undefined;
+  tray_color: string | null | undefined;
+  tray_info_idx: string | null | undefined;
+}
+
+export interface AmsUnitLike {
+  id: number;
+  tray: AmsTrayLike[];
+}
+
+/**
+ * One row in the AMS Backup modal: a group of slots that back each other up
+ * (length >= 2), or a single non-empty slot with no peer (length === 1).
+ */
+export interface BackupGroup {
+  /** Stable key — same across renders for the same material+extruder. */
+  key: string;
+  /** Bambu preset ID (tray_info_idx) when matched on preset; null otherwise. */
+  presetId: string | null;
+  /** 0 = right / single, 1 = left. Scoping field for dual-nozzle. */
+  extruder: number;
+  /** Display name from the first slot's tray_sub_brands (or tray_type). */
+  displayName: string;
+  /** Tray colour from the first slot, for the swatch in the modal. */
+  trayColor: string | null;
+  /** Member slots, in (ams_id, slot_idx) order. */
+  members: Array<{ amsId: number; slotIdx: number; globalTrayId: number }>;
+}
+
+/**
+ * Canonicalise a hex colour for identity comparison. Mirrors the backend
+ * `_normalize_color_for_id`. Strips the leading `#`, uppercases, and drops
+ * the alpha channel when 8 chars long so `1A1A1AFF` matches `1A1A1A`.
+ */
+function normalizeColorForId(raw: string | null | undefined): string {
+  let s = (raw || '').trim().replace(/^#/, '').toUpperCase();
+  if (s.length === 8) s = s.slice(0, 6);
+  return s;
+}
+
+/**
+ * Compute backup pairs for the AMS Backup modal (#1762).
+ *
+ * Strict identity rule (mirrors backend `_material_identity_internal` /
+ * `_material_identity_spoolman`): slots pair ONLY when they share the same
+ * Bambu preset ID (`tray_info_idx`, e.g. "GFA00") AND the same colour. The
+ * preset identifies the filament profile (PETG HF, PLA Basic, etc.); the
+ * colour pins the variant — three PETG HF spools in different colours
+ * absolutely don't back each other up. User-tagged spools without a preset
+ * never pair — Bambu's firmware backup logic relies on the preset, and
+ * pairing on cosmetic name/colour match alone would let two visually-
+ * identical but materially-different spools be treated as backups.
+ *
+ * Empty slots are skipped entirely. Every non-empty slot is returned — slots
+ * without a peer come back as 1-member entries so the modal can list them as
+ * "Slots without a backup peer".
+ *
+ * On dual-extruder printers (H2D / H2C / X2D), pairs are scoped per extruder
+ * side — the firmware can't cross extruders even with the global backup bit
+ * set.
+ */
+export function computeBackupGroups(
+  amsUnits: AmsUnitLike[] | undefined,
+  amsExtruderMap: Record<string, number> | undefined,
+  isDualNozzle: boolean,
+): BackupGroup[] {
+  if (!amsUnits || amsUnits.length === 0) return [];
+
+  // Defensive dedup: ``status.ams`` is expected to be unique by `ams.id`, but
+  // observed in the wild to occasionally contain duplicate entries (e.g. on
+  // VP-aggregated switch printers or during MQTT partial-update merges). A
+  // duplicate would surface as "AMS-A slot 1" rendered twice with different
+  // materials, which is impossible physically and visually broken. First
+  // occurrence per `ams.id` wins.
+  const seenIds = new Set<number>();
+  const uniqueAms: AmsUnitLike[] = [];
+  for (const ams of amsUnits) {
+    if (seenIds.has(ams.id)) continue;
+    seenIds.add(ams.id);
+    uniqueAms.push(ams);
+  }
+
+  const byKey = new Map<string, BackupGroup>();
+
+  for (const ams of uniqueAms) {
+    const extruder = isDualNozzle ? Number(amsExtruderMap?.[String(ams.id)] ?? 0) : 0;
+    ams.tray.forEach((tray, slotIdx) => {
+      if (!tray?.tray_type) return; // empty slot
+      const preset = (tray.tray_info_idx || '').trim();
+      const globalTrayId = getGlobalTrayId(ams.id, slotIdx, false);
+      const member = { amsId: ams.id, slotIdx, globalTrayId };
+
+      let key: string;
+      let presetId: string | null;
+      if (preset) {
+        // Same Bambu profile is necessary but NOT sufficient — different colours
+        // of the same PETG HF profile can't back each other up. Bake the colour
+        // into the identity key, normalised to strip alpha and case.
+        const color = normalizeColorForId(tray.tray_color);
+        key = `preset:${preset}|color:${color}#${extruder}`;
+        presetId = preset;
+      } else {
+        // No preset → never group with anything else. Unique-per-slot key.
+        key = `unmatched:${ams.id}:${slotIdx}#${extruder}`;
+        presetId = null;
+      }
+
+      const existing = byKey.get(key);
+      if (existing) {
+        existing.members.push(member);
+      } else {
+        byKey.set(key, {
+          key,
+          presetId,
+          extruder,
+          displayName: tray.tray_sub_brands || tray.tray_type || '',
+          trayColor: tray.tray_color ?? null,
+          members: [member],
+        });
+      }
+    });
+  }
+
+  // Stable sort: extruder first (so the modal can section per side on
+  // dual-nozzle), then pairs before lone slots, then by name, then by first
+  // member's global tray id for deterministic rendering.
+  return Array.from(byKey.values()).sort((a, b) => {
+    if (a.extruder !== b.extruder) return a.extruder - b.extruder;
+    const aLone = a.members.length === 1 ? 1 : 0;
+    const bLone = b.members.length === 1 ? 1 : 0;
+    if (aLone !== bLone) return aLone - bLone;
+    if (a.displayName !== b.displayName) return a.displayName.localeCompare(b.displayName);
+    return a.members[0].globalTrayId - b.members[0].globalTrayId;
+  });
+}

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