Ver código fonte

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 meses atrás
pai
commit
9d74f9281b

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
CHANGELOG.md


+ 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
 
 

+ 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 == []

+ 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)',

+ 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();
+  });
+});

+ 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');
+  });
+});

+ 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>
+  );
+}

+ 13 - 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',

+ 13 - 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',

+ 13 - 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',

+ 13 - 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',

+ 13 - 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',

+ 13 - 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: 'フィラメント',

+ 14 - 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: '카메라 오버레이 열기',

+ 13 - 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',

+ 13 - 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',

+ 13 - 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: '耗材',

+ 13 - 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: '耗材',

+ 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;
+  });
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 1 - 0
static/assets/index-Bp52mo4E.css


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
static/assets/index-CtWW9ce9.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 1
static/assets/index-DNavQjwR.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CksvU0PF.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DNavQjwR.css">
+    <script type="module" crossorigin src="/assets/index-CtWW9ce9.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Bp52mo4E.css">
   </head>
   <body>
     <div id="root"></div>

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff