Преглед изворни кода

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 месеци
родитељ
комит
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;
+  });
+}

Неке датотеке нису приказане због велике количине промена