Переглянути джерело

feat(ams-backup): add status badge + toggle, fix prefer-lowest (#1766)

      Two tightly-coupled deliverables in one drop -- a new AMS Filament Backup
      status/control surface, and the #1766 fix that depends on it.

      Added -- AMS Filament Backup status + control
      - Parse bit 18 of top-level print.cfg into PrinterState.ams_filament_backup
        on every push_status. Verified against OrcaSlicer source
        (DeviceManager.cpp:4961) and a live H2D ON/OFF capture. Tri-state
        (None = A1 family / pre-cfg push) preserves today's behaviour.
      - Hold-timer guard (3 s) prevents stale frames from flickering the badge
        back to the printer's old cfg after a user-initiated toggle.
      - POST /printers/{id}/ams-backup toggle, set_ams_filament_backup() client
        method calling _set_print_option("auto_switch_filament", enabled).
      - GET /printers/{id}/inventory-remain endpoint exposes the same map the
        dispatcher uses (internal and Spoolman modes both work uniformly).
      - Small icon badge in the printer card's "Filaments" section header
        (placement reads as printer-wide because the cfg bit is printer-wide,
        not per-AMS). Click to toggle, success toast.
      - 5 i18n keys x 11 locales for the badge UI.

      Fixed -- #1766: prefer_lowest didn't pick lowest, ignored backup state
      - Backend gate in _compute_ams_mapping_for_printer: coerce prefer_lowest
        to False when status.ams_filament_backup is False; log the skip.
      - New effectivePreferLowest(setting, backup) helper applied at every
        frontend sort entry point: single-printer PrintModal, multi-printer
        hook per-printer, PrinterSelector InlineMappingEditor, FilamentMapping
        standalone editor (the last had NO preferLowest awareness at all
        before this change).
      - New preferLowestSortKey(f, inventoryByTrayId) mirrors backend's two-tier
        key exactly, including the banding tie-break (regular AMS < AMS-HT <
        external) so the client-side pre-compute matches the dispatch-time pick.
        An earlier draft used a flat `amsId * 4 + trayId` priority which gave
        external slots (ams_id = -1) a NEGATIVE priority -- caught in code
        review before commit.
      - Settings -> Filament -> "Prefer lowest remaining filament" gets an
        explanatory note about the printer-side AMS Backup dependency, with
        i18n key in all 11 locales.
maziggy 2 місяців тому
батько
коміт
99c6949b5c
35 змінених файлів з 1012 додано та 51 видалено
  1. 0 0
      CHANGELOG.md
  2. 51 0
      backend/app/api/routes/printers.py
  3. 1 1
      backend/app/main.py
  4. 4 0
      backend/app/schemas/printer.py
  5. 59 0
      backend/app/services/bambu_mqtt.py
  6. 9 0
      backend/app/services/print_scheduler.py
  7. 4 0
      backend/app/services/printer_manager.py
  8. 56 0
      backend/tests/unit/services/test_bambu_mqtt.py
  9. 58 0
      backend/tests/unit/test_bambu_mqtt_cfg_parse.py
  10. 86 0
      backend/tests/unit/test_inventory_remain_endpoint.py
  11. 1 0
      backend/tests/unit/test_printer_manager_status_broadcast.py
  12. 1 0
      backend/tests/unit/test_printer_offline_notification.py
  13. 96 0
      backend/tests/unit/test_scheduler_backup_gate.py
  14. 37 0
      frontend/src/__tests__/components/PrinterSelector.test.ts
  15. 143 0
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  16. 19 0
      frontend/src/api/client.ts
  17. 29 2
      frontend/src/components/PrintModal/FilamentMapping.tsx
  18. 8 1
      frontend/src/components/PrintModal/PrinterSelector.tsx
  19. 46 3
      frontend/src/components/PrintModal/index.tsx
  20. 33 23
      frontend/src/hooks/useFilamentMapping.ts
  21. 37 15
      frontend/src/hooks/useMultiPrinterFilamentMapping.ts
  22. 8 0
      frontend/src/i18n/locales/de.ts
  23. 9 0
      frontend/src/i18n/locales/en.ts
  24. 8 0
      frontend/src/i18n/locales/es.ts
  25. 8 0
      frontend/src/i18n/locales/fr.ts
  26. 8 0
      frontend/src/i18n/locales/it.ts
  27. 8 0
      frontend/src/i18n/locales/ja.ts
  28. 8 0
      frontend/src/i18n/locales/ko.ts
  29. 8 0
      frontend/src/i18n/locales/pt-BR.ts
  30. 8 0
      frontend/src/i18n/locales/tr.ts
  31. 8 0
      frontend/src/i18n/locales/zh-CN.ts
  32. 8 0
      frontend/src/i18n/locales/zh-TW.ts
  33. 69 0
      frontend/src/pages/PrintersPage.tsx
  34. 3 0
      frontend/src/pages/SettingsPage.tsx
  35. 73 6
      frontend/src/utils/amsHelpers.ts

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
CHANGELOG.md


+ 51 - 0
backend/app/api/routes/printers.py

@@ -724,6 +724,7 @@ async def get_printer_status(
         heatbreak_fan_speed=state.heatbreak_fan_speed,
         firmware_version=state.firmware_version,
         developer_mode=state.developer_mode if state else None,
+        ams_filament_backup=state.ams_filament_backup if state else None,
         awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
         supports_drying=supports_drying(printer.model, state.firmware_version),
         supports_chamber_heater=supports_chamber_heater(printer.model),
@@ -1874,6 +1875,56 @@ async def set_print_option(
     }
 
 
+@router.post("/{printer_id}/ams-backup")
+async def set_ams_backup(
+    printer_id: int,
+    enabled: bool,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Toggle AMS Filament Backup (auto-switch to a backup spool when one runs out)."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client or not client.state.connected:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.set_ams_filament_backup(enabled)
+    if not success:
+        raise HTTPException(500, "Failed to send command to printer")
+
+    return {"success": True, "ams_filament_backup": enabled}
+
+
+@router.get("/{printer_id}/inventory-remain")
+async def get_inventory_remain(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Per-globalTrayId remaining grams for slots bound to an inventory spool.
+
+    Mirrors `_build_inventory_remain_overrides` server-side so the PrintModal
+    client can apply the same two-tier "Prefer Lowest Remaining Filament" sort
+    the dispatcher uses (#1766). Works for both internal inventory and
+    Spoolman; unbound slots are absent from the map (client falls back to the
+    printer's MQTT `remain` for those).
+    """
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    state = printer_manager.get_status(printer_id)
+    if not state:
+        return {"inventory_remain_g": {}}
+
+    scheduler = PrintScheduler()
+    loaded = scheduler._build_loaded_filaments(state)
+    overrides = await scheduler._build_inventory_remain_overrides(db, printer_id, loaded)
+    return {"inventory_remain_g": {str(k): v for k, v in overrides.items()}}
+
+
 # ============================================
 # Calibration
 # ============================================

+ 1 - 1
backend/app/main.py

@@ -1098,7 +1098,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
         f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:"
-        f"{ams_dry_key}:{ams_tray_key}:{state.door_open}"
+        f"{ams_dry_key}:{ams_tray_key}:{state.door_open}:{state.ams_filament_backup}"
     )
 
     # MQTT relay - publish status (before dedup check - always publish to MQTT)

+ 4 - 0
backend/app/schemas/printer.py

@@ -324,6 +324,10 @@ class PrinterStatus(BaseModel):
     firmware_version: str | None = None
     # Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown
     developer_mode: bool | None = None
+    # AMS Filament Backup ("auto-switch" to a second spool when one runs out).
+    # True = ON, False = OFF, None = unknown / unsupported (A1 family — protocol field
+    # not yet identified). UI treats None as "status unavailable", not as a hard disable.
+    ams_filament_backup: bool | None = None
     # Queue: printer is awaiting the user to acknowledge the build plate is cleared
     # after a finished/failed print. Persisted across restarts (#961).
     awaiting_plate_clear: bool = False

+ 59 - 0
backend/app/services/bambu_mqtt.py

@@ -31,6 +31,23 @@ logger = logging.getLogger(__name__)
 _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
 
 
+def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None:
+    """Extract AMS Filament Backup state from a Bambu push_status ``print.cfg`` value.
+
+    OrcaSlicer reads bit 18 of the hex string via
+    ``get_flag_bits(cfg, 18)`` (DeviceManager.cpp:4961). Old-protocol families
+    (A1 / A1 Mini) omit ``cfg`` entirely; this returns ``None`` for any input
+    that doesn't yield a clean integer so downstream consumers preserve today's
+    behaviour rather than treating "absent" as "OFF".
+    """
+    if not isinstance(cfg_raw, str) or not cfg_raw:
+        return None
+    try:
+        return bool((int(cfg_raw, 16) >> 18) & 1)
+    except ValueError:
+        return None
+
+
 def apply_tray_exist_bits(
     units: list,
     tray_exist_bits_str: str | int | None,
@@ -330,6 +347,11 @@ class PrinterState:
     # Developer LAN mode: parsed from MQTT "fun" field bit 0x20000000
     # True = dev mode ON (no encryption), False = dev mode OFF (encryption required), None = unknown
     developer_mode: bool | None = None
+    # AMS Filament Backup: bit 18 of top-level print.cfg hex on new-protocol Bambu
+    # printers (H/X/P/H2 families). True=ON, False=OFF, None=unknown (e.g. A1 family
+    # which uses the old protocol path; field not yet found). Consumers must treat
+    # None as "no opinion" — preserving today's behaviour, NOT as "disabled".
+    ams_filament_backup: bool | None = None
 
 
 # Stage name mapping from BambuStudio DeviceManager.cpp
@@ -1018,6 +1040,33 @@ class BambuMQTTClient:
                     f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
                 )
 
+            # AMS Filament Backup state lives in bit 18 of top-level print.cfg on
+            # new-protocol printers. Verified against OrcaSlicer's
+            # DeviceManager.cpp:4961 SetAutoRefillEnabled(get_flag_bits(cfg, 18))
+            # and live H2D ON/OFF capture 2026-06-20.
+            #
+            # Hold-timer guard: when the user just toggled via the badge, the
+            # next 1-2 push_status frames may still carry the printer's OLD cfg
+            # for ~3 s before the firmware reflects the change. Without this
+            # gate the UI would flicker ON→OFF→ON. Same pattern xcam uses.
+            new_backup = parse_ams_filament_backup_from_cfg(print_data.get("cfg"))
+            if new_backup is not None and new_backup != self.state.ams_filament_backup:
+                hold_start = self._xcam_hold_start.get("print_option_auto_switch_filament")
+                if hold_start is not None and (time.time() - hold_start) <= self._xcam_hold_time:
+                    logger.debug(
+                        "[%s] AMS Filament Backup push ignored (hold active for %.1fs)",
+                        self.serial_number,
+                        time.time() - hold_start,
+                    )
+                else:
+                    logger.info(
+                        "[%s] AMS Filament Backup: %s",
+                        self.serial_number,
+                        "ON" if new_backup else "OFF",
+                    )
+                    self.state.ams_filament_backup = new_backup
+                    self._xcam_hold_start.pop("print_option_auto_switch_filament", None)
+
             # Detect dual-nozzle BEFORE processing AMS data (tray_now disambiguation needs it)
             # device.extruder.info with >= 2 entries only exists on dual-nozzle printers (H2D, H2D Pro)
             if not self._is_dual_nozzle and "device" in print_data:
@@ -3822,9 +3871,19 @@ class BambuMQTTClient:
         # Update local state immediately
         if option_name == "auto_recovery":
             self.state.print_options.auto_recovery_step_loss = enabled
+        elif option_name == "auto_switch_filament":
+            self.state.ams_filament_backup = enabled
 
         return True
 
+    def set_ams_filament_backup(self, enabled: bool) -> bool:
+        """Toggle AMS Filament Backup (a.k.a. auto-switch / auto-refill).
+
+        Mirrors BambuStudio's "AMS Filament Backup" checkbox. Verified payload
+        shape from H2D capture 2026-06-20.
+        """
+        return self._set_print_option("auto_switch_filament", enabled)
+
     def start_calibration(
         self,
         bed_leveling: bool = False,

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

@@ -860,6 +860,15 @@ class PrintScheduler:
         # Check if user prefers lowest remaining filament when multiple spools match
         prefer_lowest = await self._get_bool_setting(db, "prefer_lowest_filament")
 
+        # Gate prefer_lowest on the printer's AMS Filament Backup state (#1766).
+        # Without backup, the printer will not switch to a second spool when the
+        # picked one runs out — so sorting toward the lowest leaves the print
+        # at risk of running dry mid-job. None (unknown / A1 family) preserves
+        # today's behaviour intentionally.
+        if prefer_lowest and status.ams_filament_backup is False:
+            logger.info("[prefer-lowest] skipped (AMS Backup OFF on printer %s)", printer_id)
+            prefer_lowest = False
+
         # When the preference is on, surface Bambuddy's inventory-side
         # remaining for each slot that's bound to a tracked spool, so the
         # sort beats the MQTT-only blind spot (#1508). Skip the lookup

+ 4 - 0
backend/app/services/printer_manager.py

@@ -1049,6 +1049,10 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         "wifi_signal": state.wifi_signal,
         "wired_network": state.wired_network,
         "door_open": state.door_open,
+        # AMS Filament Backup state (auto-switch to second spool). Tri-state:
+        # True / False / None. None = unknown or unsupported (A1 family). UI
+        # uses this to drive the small status icon next to the AMS drying icon.
+        "ams_filament_backup": state.ams_filament_backup,
         # Calibration stage tracking
         "stg_cur": state.stg_cur,
         "stg_cur_name": get_derived_status_name(state, model),

+ 56 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -5793,3 +5793,59 @@ class TestPrintRunningObservedCallback:
             "raw_data",
             "ams_mapping",
         }
+
+
+class TestAmsFilamentBackupHoldTimer:
+    """Regression: stale push_status arriving within the hold window after a
+    toggle command MUST NOT flip ams_filament_backup back to the printer's
+    old cfg. Same race-guard pattern xcam uses for spaghetti / first-layer
+    detector settings.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from unittest.mock import MagicMock
+
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+        # Pretend we're connected so _set_print_option actually publishes.
+        client.state.connected = True
+        client._client = MagicMock()
+        return client
+
+    def test_cfg_push_with_old_value_is_ignored_during_hold(self, mqtt_client):
+        # User toggled ON via badge → command sent → state optimistically set.
+        mqtt_client.set_ams_filament_backup(True)
+        assert mqtt_client.state.ams_filament_backup is True
+
+        # Within the 3 s hold window, a stale push_status arrives still showing
+        # the printer's old cfg (bit 18 cleared). The parser must NOT flip our
+        # optimistic state back to OFF — otherwise the badge flickers ON→OFF→ON.
+        mqtt_client._process_message({"print": {"cfg": "C0340BC219"}})  # bit18=0
+        assert mqtt_client.state.ams_filament_backup is True
+
+    def test_cfg_push_after_hold_expires_overrides_state(self, mqtt_client):
+        # After the hold window, the printer's real cfg becomes authoritative
+        # so a genuine slicer-side or display toggle that we did NOT initiate
+        # propagates correctly.
+        mqtt_client.set_ams_filament_backup(True)
+        mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] = time.time() - 10.0
+
+        mqtt_client._process_message({"print": {"cfg": "C0340BC219"}})  # bit18=0
+        assert mqtt_client.state.ams_filament_backup is False
+
+    def test_cfg_push_with_matching_value_during_hold_is_a_noop(self, mqtt_client):
+        # Same-value push during hold doesn't trigger the change branch at all
+        # (no state mutation, no log spam, hold timer stays armed).
+        mqtt_client.set_ams_filament_backup(True)
+        before_hold = mqtt_client._xcam_hold_start["print_option_auto_switch_filament"]
+
+        mqtt_client._process_message({"print": {"cfg": "C0340FC219"}})  # bit18=1
+        assert mqtt_client.state.ams_filament_backup is True
+        # Hold timer still armed — sub-second push didn't reset it.
+        assert mqtt_client._xcam_hold_start["print_option_auto_switch_filament"] == before_hold

+ 58 - 0
backend/tests/unit/test_bambu_mqtt_cfg_parse.py

@@ -0,0 +1,58 @@
+"""Tests for ``parse_ams_filament_backup_from_cfg`` (#1766 prefer_lowest gate).
+
+The function extracts bit 18 of Bambu's top-level ``print.cfg`` hex string,
+which OrcaSlicer's DeviceManager.cpp:4961 maps to AMS Filament Backup. These
+tests pin the bit position + cover the absent / malformed cases A1-family
+printers and pre-init pushes produce.
+"""
+
+import pytest
+
+from backend.app.services.bambu_mqtt import parse_ams_filament_backup_from_cfg
+
+
+class TestParseAmsFilamentBackupFromCfg:
+    def test_h2d_on_capture(self):
+        # Captured 2026-06-20 from H2D fw 01.03.00.00 with backup ON.
+        # Hex "C0340FC219" has bit 18 set (nibble 5 = F = 0b1111).
+        assert parse_ams_filament_backup_from_cfg("C0340FC219") is True
+
+    def test_h2d_off_capture(self):
+        # Same printer, backup toggled OFF — only bit 18 flips:
+        # "C0340BC219" — nibble 5 = B = 0b1011.
+        assert parse_ams_filament_backup_from_cfg("C0340BC219") is False
+
+    def test_x1c_short_hex_string_on(self):
+        # X1C cfg in the investigation snapshots is short ("FCA09").
+        # Bit 18 of 0xFCA09 = 0b1111110010100001001, bit18 set.
+        assert parse_ams_filament_backup_from_cfg("FCA09") is True
+
+    def test_lowercase_hex(self):
+        # Robustness: int(s, 16) accepts both cases; check we don't regress.
+        assert parse_ams_filament_backup_from_cfg("c0340fc219") is True
+
+    def test_only_bit_18_isolated(self):
+        # Sanity: a value with ONLY bit 18 set must parse as True.
+        assert parse_ams_filament_backup_from_cfg(hex(1 << 18)[2:]) is True
+
+    def test_bit_18_clear_but_others_set(self):
+        # Set every bit EXCEPT 18 — must parse as False.
+        mask = (~(1 << 18)) & 0xFFFFFFFF
+        assert parse_ams_filament_backup_from_cfg(hex(mask)[2:]) is False
+
+    @pytest.mark.parametrize(
+        "value",
+        [
+            None,  # field omitted (A1 family old protocol)
+            "",  # empty string
+            123,  # firmware-emitted int instead of hex string (defensive)
+            "not_hex",  # malformed
+            "0xZZ",  # invalid hex
+            ["FCA09"],  # wrong shape
+            {"cfg": "FCA09"},  # nested by mistake
+        ],
+    )
+    def test_invalid_returns_none(self, value):
+        # None preserves today's behaviour for callers gating on backup state —
+        # NOT False. Treating absent as OFF would regress A1-family scheduling.
+        assert parse_ams_filament_backup_from_cfg(value) is None

+ 86 - 0
backend/tests/unit/test_inventory_remain_endpoint.py

@@ -0,0 +1,86 @@
+"""Tests for GET /printers/{id}/inventory-remain (#1766).
+
+The endpoint exposes the same `_build_inventory_remain_overrides` map the
+dispatcher uses so PrintModal's client-side "Prefer Lowest Remaining Filament"
+sort agrees with what gets dispatched — closes the gap where Spoolman-mode
+users couldn't see inventory grams from the frontend.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.api.routes.printers import get_inventory_remain
+
+
+@pytest.fixture
+def db():
+    return MagicMock()
+
+
+async def _call_endpoint(db, printer_id=1):
+    return await get_inventory_remain(printer_id=printer_id, _=None, db=db)
+
+
+class TestGetInventoryRemain:
+    @pytest.mark.asyncio
+    async def test_returns_empty_when_printer_has_no_status(self, db):
+        # Printer disconnected / unknown — endpoint must not error, return {}.
+        with patch(
+            "backend.app.services.printer_manager.printer_manager.get_status",
+            return_value=None,
+        ):
+            result = await _call_endpoint(db)
+        assert result == {"inventory_remain_g": {}}
+
+    @pytest.mark.asyncio
+    async def test_serialises_globaltrayid_keys_as_strings(self, db):
+        # JSON requires string keys; client converts back to Number on receive.
+        # Asserts the key-shape contract the frontend depends on.
+        state = SimpleNamespace(raw_data={})
+        with (
+            patch(
+                "backend.app.services.printer_manager.printer_manager.get_status",
+                return_value=state,
+            ),
+            patch(
+                "backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments",
+                return_value=[
+                    {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
+                    {"ams_id": 0, "tray_id": 3, "global_tray_id": 3, "is_external": False},
+                ],
+            ),
+            patch(
+                "backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
+                new=AsyncMock(return_value={0: 950.0, 3: 50.0}),
+            ),
+        ):
+            result = await _call_endpoint(db)
+
+        assert result == {"inventory_remain_g": {"0": 950.0, "3": 50.0}}
+
+    @pytest.mark.asyncio
+    async def test_returns_empty_dict_when_no_bound_slots(self, db):
+        # Loaded filaments exist but none are bound to an inventory spool.
+        # Backend returns {}; route serialises it unchanged.
+        state = SimpleNamespace(raw_data={})
+        with (
+            patch(
+                "backend.app.services.printer_manager.printer_manager.get_status",
+                return_value=state,
+            ),
+            patch(
+                "backend.app.services.print_scheduler.PrintScheduler._build_loaded_filaments",
+                return_value=[
+                    {"ams_id": 0, "tray_id": 0, "global_tray_id": 0, "is_external": False},
+                ],
+            ),
+            patch(
+                "backend.app.services.print_scheduler.PrintScheduler._build_inventory_remain_overrides",
+                new=AsyncMock(return_value={}),
+            ),
+        ):
+            result = await _call_endpoint(db)
+
+        assert result == {"inventory_remain_g": {}}

+ 1 - 0
backend/tests/unit/test_printer_manager_status_broadcast.py

@@ -99,6 +99,7 @@ def _fake_state(**overrides):
         "tray_now": None,
         "wifi_signal": None,
         "wired_network": None,
+        "ams_filament_backup": None,
     }
     base.update(overrides)
     return SimpleNamespace(**base)

+ 1 - 0
backend/tests/unit/test_printer_offline_notification.py

@@ -44,6 +44,7 @@ def _state(connected: bool, state: str = "IDLE") -> SimpleNamespace:
         tray_now=0,
         door_open=False,
         subtask_name="",
+        ams_filament_backup=None,
     )
 
 

+ 96 - 0
backend/tests/unit/test_scheduler_backup_gate.py

@@ -0,0 +1,96 @@
+"""Tests for the AMS Filament Backup gate on prefer_lowest sort (#1766).
+
+The reporter set ``prefer_lowest_filament=True`` but the printer kept picking
+the first matching spool. Root cause: without the printer's AMS Filament
+Backup enabled, switching to the second spool is impossible — so sorting
+toward the lowest leaves the print at risk. The gate coerces prefer_lowest
+to False whenever the printer reports backup OFF, with None (unknown / A1
+family) preserving today's behaviour.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.print_scheduler import PrintScheduler
+
+
+@pytest.fixture
+def scheduler():
+    return PrintScheduler()
+
+
+def _patch_status(backup):
+    """Patch ``printer_manager.get_status`` to return a stub PrinterState whose
+    ``ams_filament_backup`` is the requested tri-state value."""
+    return patch(
+        "backend.app.services.print_scheduler.printer_manager.get_status",
+        return_value=SimpleNamespace(ams_filament_backup=backup, raw_data={}),
+    )
+
+
+async def _run_with_backup(scheduler, backup_state, prefer_lowest_setting):
+    """Drive ``_compute_ams_mapping_for_printer`` past the gate point and
+    return whatever ``prefer_lowest`` value gets handed to the matcher."""
+    db = MagicMock()
+    item = SimpleNamespace(filament_overrides=None)
+    filament_reqs = [{"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": ""}]
+    loaded = [
+        {
+            "ams_id": 0,
+            "tray_id": 0,
+            "global_tray_id": 0,
+            "is_external": False,
+            "type": "PLA",
+            "color": "#000000",
+            "tray_info_idx": "",
+        },
+    ]
+
+    captured: dict = {}
+
+    def _capture_match(reqs, loaded_, prefer, overrides):
+        captured["prefer_lowest"] = prefer
+        return [0]
+
+    with (
+        _patch_status(backup_state),
+        patch.object(scheduler, "_get_filament_requirements", new=AsyncMock(return_value=filament_reqs)),
+        patch.object(scheduler, "_build_loaded_filaments", return_value=loaded),
+        patch.object(scheduler, "_get_bool_setting", new=AsyncMock(return_value=prefer_lowest_setting)),
+        patch.object(scheduler, "_build_inventory_remain_overrides", new=AsyncMock(return_value={})),
+        patch.object(scheduler, "_match_filaments_to_slots", side_effect=_capture_match),
+    ):
+        await scheduler._compute_ams_mapping_for_printer(db, printer_id=1, item=item)
+
+    return captured.get("prefer_lowest")
+
+
+class TestPreferLowestBackupGate:
+    @pytest.mark.asyncio
+    async def test_backup_off_disables_prefer_lowest(self, scheduler):
+        # User setting ON but printer reports backup OFF — gate must coerce.
+        # This is the #1766 fix: previously the sort applied and picked the
+        # near-empty spool, leaving the print to fail mid-job.
+        out = await _run_with_backup(scheduler, backup_state=False, prefer_lowest_setting=True)
+        assert out is False
+
+    @pytest.mark.asyncio
+    async def test_backup_on_preserves_prefer_lowest(self, scheduler):
+        # Backup ON — sort applies as the user intended.
+        out = await _run_with_backup(scheduler, backup_state=True, prefer_lowest_setting=True)
+        assert out is True
+
+    @pytest.mark.asyncio
+    async def test_backup_unknown_preserves_prefer_lowest(self, scheduler):
+        # None = unknown / unsupported (A1 family). Must NOT be treated as OFF
+        # — that would regress A1 users who currently get the sort applied.
+        out = await _run_with_backup(scheduler, backup_state=None, prefer_lowest_setting=True)
+        assert out is True
+
+    @pytest.mark.asyncio
+    async def test_user_setting_off_short_circuits(self, scheduler):
+        # User setting OFF — backup state is irrelevant; sort never applies.
+        out = await _run_with_backup(scheduler, backup_state=True, prefer_lowest_setting=False)
+        assert out is False

+ 37 - 0
frontend/src/__tests__/components/PrinterSelector.test.ts

@@ -247,3 +247,40 @@ describe('autoMatchFilament preferLowest', () => {
     expect(result!.globalTrayId).toBe(1); // Only tray on correct nozzle
   });
 });
+
+// #1766: identical-material spools that only differ in inventory grams used to
+// tie at the printer's `remain%` and the first one always won. The map lets
+// the sort see the bound spool's `label_weight - weight_used` instead.
+describe('autoMatchFilament preferLowest with inventory map (#1766)', () => {
+  it('picks lower inventory grams when remain% ties', () => {
+    const filaments = [
+      makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 100 }),
+      makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 100 }),
+    ];
+    const req = makeReq({ type: 'PLA', color: '#FF0000' });
+    const inventory = new Map<number, number>([[0, 900], [1, 60]]);
+    const result = autoMatchFilament(req, filaments, new Set(), true, inventory);
+    expect(result!.globalTrayId).toBe(1); // 60 g < 900 g
+  });
+
+  it('inventory-bound spool beats MQTT-only one even when remain% would order them differently', () => {
+    const filaments = [
+      makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 10 }),
+      makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 90 }),
+    ];
+    const req = makeReq({ type: 'PLA', color: '#FF0000' });
+    const inventory = new Map<number, number>([[1, 200]]); // Only tray 1 bound.
+    const result = autoMatchFilament(req, filaments, new Set(), true, inventory);
+    expect(result!.globalTrayId).toBe(1); // Tier 0 always beats tier 1.
+  });
+
+  it('falls back to remain% sort when map is undefined', () => {
+    const filaments = [
+      makeFilament({ globalTrayId: 0, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 80 }),
+      makeFilament({ globalTrayId: 1, type: 'PLA', color: '#FF0000', colorName: 'Red', remain: 30 }),
+    ];
+    const req = makeReq({ type: 'PLA', color: '#FF0000' });
+    const result = autoMatchFilament(req, filaments, new Set(), true, undefined);
+    expect(result!.globalTrayId).toBe(1); // Same as the pre-#1766 path.
+  });
+});

+ 143 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -10,6 +10,7 @@ import {
   buildLoadedFilaments,
   computeAmsMapping,
 } from '../../hooks/useFilamentMapping';
+import { effectivePreferLowest } from '../../utils/amsHelpers';
 import type { PrinterStatus } from '../../api/client';
 
 // Helper to create a minimal printer status with AMS data
@@ -1048,3 +1049,145 @@ describe('computeAmsMapping preferLowest', () => {
     expect(result).toEqual([1]); // Known 60% over unknown
   });
 });
+
+// #1766: the user reported that "Prefer lowest remaining filament" picked the
+// wrong spool when two identical-material/color spools differed only in the
+// inventory-tracked grams (not the printer's `remain%`). The pre-fix sort
+// looked at `remain%` only and ignored Bambuddy's bound inventory entirely;
+// now we pass a globalTrayId -> grams map and the sort lifts inventory-bound
+// spools to tier 0 (matching backend _prefer_lowest_sort_key).
+describe('computeAmsMapping preferLowest with inventory map (#1766)', () => {
+  it('picks spool with lower inventory grams when both spools report same remain%', () => {
+    // Reporter's scenario: two identical Bambu-branded spools, both report
+    // `remain=100` because they were freshly inserted, but inventory has them
+    // at 950 g vs 50 g remaining. Pre-fix sort ties and picks the first.
+    const reqs = {
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'GFA00' }],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 100 },
+          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 100 },
+        ],
+      },
+    ]);
+    const inventory = new Map<number, number>([[0, 950], [1, 50]]);
+
+    const result = computeAmsMapping(reqs, status, true, inventory);
+    expect(result).toEqual([1]); // Inventory says tray 1 is nearly empty — use it first.
+  });
+
+  it('prefers inventory-tracked spool over non-tracked one even when remain% would order them differently', () => {
+    // Two spools both match by type+color, both have the same tray_info_idx
+    // (identical SKU). Tray 0 has no inventory binding but reports remain=20.
+    // Tray 1 has an inventory binding with 100 g remaining. Tier 0 (bound)
+    // always beats tier 1 (MQTT-only) regardless of value — matches backend.
+    const reqs = {
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, tray_info_idx: 'GFA00' }],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 20 },
+          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', tray_info_idx: 'GFA00', remain: 80 },
+        ],
+      },
+    ]);
+    const inventory = new Map<number, number>([[1, 100]]); // Only tray 1 bound
+
+    const result = computeAmsMapping(reqs, status, true, inventory);
+    expect(result).toEqual([1]);
+  });
+
+  it('falls back to remain% sort when no inventory map provided (pre-#1766 behaviour)', () => {
+    // Regression guard: callers that haven't yet wired the map must get the
+    // same sort they always got. None of the existing tests in this file pass
+    // a map; this asserts the default path is unchanged.
+    const reqs = {
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
+          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 25 },
+        ],
+      },
+    ]);
+
+    const result = computeAmsMapping(reqs, status, true, undefined);
+    expect(result).toEqual([1]); // Same as the existing no-inventory case.
+  });
+});
+
+// #1766 safety gate: when the printer has AMS Filament Backup OFF, the sort
+// MUST NOT run, even with the user setting on. Otherwise the dispatch picks a
+// near-empty spool the printer can't switch off of when it runs out mid-print.
+// Mirrors backend `_compute_ams_mapping_for_printer` gate.
+describe('effectivePreferLowest gate (#1766)', () => {
+  it('coerces to false when backup is OFF', () => {
+    expect(effectivePreferLowest(true, false)).toBe(false);
+  });
+
+  it('passes through when backup is ON', () => {
+    expect(effectivePreferLowest(true, true)).toBe(true);
+  });
+
+  it('passes through when backup is unknown (null/undefined — A1 family)', () => {
+    expect(effectivePreferLowest(true, null)).toBe(true);
+    expect(effectivePreferLowest(true, undefined)).toBe(true);
+  });
+
+  it('stays false when the user setting is off, regardless of backup state', () => {
+    expect(effectivePreferLowest(false, true)).toBe(false);
+    expect(effectivePreferLowest(false, false)).toBe(false);
+    expect(effectivePreferLowest(undefined, true)).toBe(false);
+  });
+
+  it('slot-priority tie-break: external/VT spools sort AFTER regular AMS', () => {
+    // Mirrors backend `_slot_priority` banding. When tier and value tie, slot
+    // position decides — external (ams_id = -1) must clamp to 10_000 so it
+    // can't beat AMS slot 0 (priority 0).
+    const reqs = {
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
+    };
+    const status = createPrinterStatus(
+      [
+        {
+          id: 0,
+          tray: [
+            { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: -1 },  // priority 0
+          ],
+        },
+      ],
+      [{ id: 254, tray_type: 'PLA', tray_color: 'FF0000', remain: -1 }],  // priority 10_000
+    );
+    const result = computeAmsMapping(reqs, status, true);
+    expect(result).toEqual([0]); // AMS slot wins the tie; VT does not.
+  });
+
+  it('end-to-end: backup OFF prevents lowest-pick at dispatch (caller-coerced)', () => {
+    // PrintModal computes the effective flag and passes it to computeAmsMapping.
+    // This pins that flow: with backup=false the flag becomes false, the sort
+    // doesn't run, and the first matching tray wins (today's behaviour).
+    const reqs = {
+      filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 }],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000', remain: 80 },
+          { id: 1, tray_type: 'PLA', tray_color: 'FF0000', remain: 5 },  // near-empty
+        ],
+      },
+    ]);
+    const gated = effectivePreferLowest(true, false);
+    const result = computeAmsMapping(reqs, status, gated);
+    expect(result).toEqual([0]); // First match wins; the 5%-remain spool is NOT selected.
+  });
+});

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

@@ -502,6 +502,9 @@ export interface PrinterStatus {
   firmware_version: string | null;   // Firmware version from MQTT
   // Developer LAN mode: true = enabled, false = disabled, null = unknown
   developer_mode: boolean | null;
+  // AMS Filament Backup ("auto-switch" to a backup spool when one runs out).
+  // true = ON, false = OFF, null = unknown / unsupported (A1 family).
+  ams_filament_backup: boolean | null;
   // Queue: printer is awaiting user ack that the build plate was cleared after a
   // finished/failed print. Persisted across restarts (#961).
   awaiting_plate_clear: boolean;
@@ -3632,6 +3635,22 @@ export const api = {
       { method: 'POST' }
     ),
 
+  // AMS Filament Backup (auto-switch to a backup spool when one runs out)
+  setAmsFilamentBackup: (printerId: number, enabled: boolean) =>
+    request<{ success: boolean; ams_filament_backup: boolean }>(
+      `/printers/${printerId}/ams-backup?enabled=${enabled}`,
+      { method: 'POST' }
+    ),
+
+  // Per-globalTrayId remaining grams for this printer's inventory-bound slots
+  // (#1766). Drives the client-side "Prefer Lowest Remaining Filament" sort
+  // when computing the AMS mapping; mirrors backend `_build_inventory_remain_overrides`
+  // so internal and Spoolman modes both work uniformly.
+  getInventoryRemain: (printerId: number) =>
+    request<{ inventory_remain_g: Record<string, number> }>(
+      `/printers/${printerId}/inventory-remain`,
+    ),
+
   // Skip Objects
   getPrintableObjects: (printerId: number) =>
     request<{

+ 29 - 2
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
 import { api } from '../../api/client';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
-import { getGlobalTrayId } from '../../utils/amsHelpers';
+import { getGlobalTrayId, effectivePreferLowest } from '../../utils/amsHelpers';
 import { getColorName } from '../../utils/colors';
 import { useFilamentLabels } from './useFilamentLabels';
 import type { FilamentMappingProps } from './types';
@@ -42,8 +42,35 @@ export function FilamentMapping({
     enabled: !!printerId,
   });
 
+  // Settings + inventory map drive the same prefer-lowest + AMS-backup gate
+  // the dispatcher uses (#1766). Without this, the per-slot dropdown's
+  // auto-suggestion could disagree with what actually gets dispatched.
+  const { data: settings } = useQuery({
+    queryKey: ['settings'],
+    queryFn: api.getSettings,
+  });
+  const { data: inventoryRemain } = useQuery({
+    queryKey: ['printer-inventory-remain', printerId],
+    queryFn: () => api.getInventoryRemain(printerId),
+    enabled: !!printerId,
+    staleTime: 30 * 1000,
+  });
+  const inventoryByTrayId = useMemo(() => {
+    if (!inventoryRemain?.inventory_remain_g) return undefined;
+    const map = new Map<number, number>();
+    Object.entries(inventoryRemain.inventory_remain_g).forEach(([key, grams]) => {
+      const gtid = Number(key);
+      if (!Number.isNaN(gtid)) map.set(gtid, grams);
+    });
+    return map;
+  }, [inventoryRemain]);
+  const gatedPreferLowest = effectivePreferLowest(
+    settings?.prefer_lowest_filament,
+    printerStatus?.ams_filament_backup,
+  );
+
   const { loadedFilaments, filamentComparison, hasTypeMismatch, hasColorMismatch } =
-    useFilamentMapping(filamentReqs, printerStatus, manualMappings);
+    useFilamentMapping(filamentReqs, printerStatus, manualMappings, gatedPreferLowest, inventoryByTrayId);
 
   // Per-slot sub-brand + material-disambiguated colour labels (#1718). Same
   // shared hook the model-mode FilamentOverride uses so both panels render

+ 8 - 1
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -18,6 +18,7 @@ import {
   colorsAreSimilar,
   autoMatchFilament,
   filterFilamentsByNozzle,
+  effectivePreferLowest,
 } from '../../utils/amsHelpers';
 import type { PrinterSelectorProps, AssignmentMode } from './types';
 import type { PrinterMappingResult, PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
@@ -108,7 +109,13 @@ function InlineMappingEditor({
     } else {
       const usedTrayIds = new Set<number>(Object.values(printerResult.config.manualMappings));
       const cachedSettings = queryClient.getQueryData<{ prefer_lowest_filament?: boolean }>(['settings']);
-      loaded = autoMatchFilament(req, printerResult.loadedFilaments, usedTrayIds, cachedSettings?.prefer_lowest_filament) as LoadedFilament | undefined;
+      loaded = autoMatchFilament(
+        req,
+        printerResult.loadedFilaments,
+        usedTrayIds,
+        effectivePreferLowest(cachedSettings?.prefer_lowest_filament, printerResult.status?.ams_filament_backup),
+        printerResult.inventoryByTrayId,
+      ) as LoadedFilament | undefined;
     }
 
     // Determine status

+ 46 - 3
frontend/src/components/PrintModal/index.tsx

@@ -1,4 +1,4 @@
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
 import { AlertCircle, AlertTriangle, Calendar, Code, Layers, Loader2, Pencil, Printer, X } from 'lucide-react';
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
@@ -15,7 +15,7 @@ import { getColorName } from '../../utils/colors';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
-import { getGlobalTrayId, isPlaceholderDate } from '../../utils/amsHelpers';
+import { getGlobalTrayId, isPlaceholderDate, effectivePreferLowest } from '../../utils/amsHelpers';
 import { FilamentMapping } from './FilamentMapping';
 import { FilamentOverride } from './FilamentOverride';
 import { PlateSelector } from './PlateSelector';
@@ -270,6 +270,34 @@ export function PrintModal({
     enabled: ((mode === 'reprint' || mode === 'add-to-queue') && assignmentMode === 'printer') || (isLibraryFile && mode === 'reprint'),
   });
 
+  // Fetch per-printer Map<globalTrayId, gramsRemaining> via the dedicated
+  // backend endpoint (#1766). Server-side mirrors `_build_inventory_remain_overrides`
+  // so internal and Spoolman modes both work uniformly, VT/external slots are
+  // excluded, and negative grams are clamped — single source of truth between
+  // the client-side preview and dispatch-time picks.
+  const inventoryRemainQueries = useQueries({
+    queries: selectedPrinters.map((printerId) => ({
+      queryKey: ['printer-inventory-remain', printerId],
+      queryFn: () => api.getInventoryRemain(printerId),
+      staleTime: 30 * 1000,
+      enabled: selectedPrinters.length > 0,
+    })),
+  });
+  const inventoryByTrayIdPerPrinter = useMemo(() => {
+    const result = new Map<number, Map<number, number>>();
+    selectedPrinters.forEach((printerId, idx) => {
+      const data = inventoryRemainQueries[idx]?.data?.inventory_remain_g;
+      if (!data) return;
+      const printerMap = new Map<number, number>();
+      Object.entries(data).forEach(([key, grams]) => {
+        const gtid = Number(key);
+        if (!Number.isNaN(gtid)) printerMap.set(gtid, grams);
+      });
+      result.set(printerId, printerMap);
+    });
+    return result;
+  }, [selectedPrinters, inventoryRemainQueries]);
+
   // Fetch archive details to get sliced_for_model
   const { data: archiveDetails } = useQuery({
     queryKey: ['archive', archiveId],
@@ -346,8 +374,22 @@ export function PrintModal({
     enabled: !!effectivePrinterId,
   });
 
+  // Single-printer flow: gate prefer_lowest on this printer's backup state.
+  // Multi-printer flow gates per-printer inside the hook (different printers
+  // may have different backup states), so we pass the raw setting down.
+  const singlePrinterPreferLowest = effectivePreferLowest(
+    settings?.prefer_lowest_filament,
+    printerStatus?.ams_filament_backup,
+  );
+
   // Get AMS mapping from hook (only when single printer selected)
-  const { amsMapping } = useFilamentMapping(effectiveFilamentReqs, printerStatus, manualMappings, settings?.prefer_lowest_filament);
+  const { amsMapping } = useFilamentMapping(
+    effectiveFilamentReqs,
+    printerStatus,
+    manualMappings,
+    singlePrinterPreferLowest,
+    effectivePrinterId ? inventoryByTrayIdPerPrinter.get(effectivePrinterId) : undefined,
+  );
 
   // Multi-printer filament mapping (for per-printer configuration)
   const multiPrinterMapping = useMultiPrinterFilamentMapping(
@@ -358,6 +400,7 @@ export function PrintModal({
     perPrinterConfigs,
     setPerPrinterConfigs,
     settings?.prefer_lowest_filament,
+    inventoryByTrayIdPerPrinter,
   );
 
   // Auto-select first plate when plates load (single or multi-plate)

+ 33 - 23
frontend/src/hooks/useFilamentMapping.ts

@@ -6,6 +6,8 @@ import {
   colorsAreSimilar,
   formatSlotLabel,
   getGlobalTrayId,
+  preferLowestSortKey,
+  compareSortKeys,
 } from '../utils/amsHelpers';
 import type { PrinterStatus } from '../api/client';
 
@@ -102,6 +104,7 @@ export function computeAmsMapping(
   filamentReqs: { filaments: FilamentRequirement[] } | undefined,
   printerStatus: PrinterStatus | undefined,
   preferLowest?: boolean,
+  inventoryByTrayId?: Map<number, number>,
 ): number[] | undefined {
   if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
 
@@ -129,13 +132,15 @@ export function computeAmsMapping(
       available = available.filter((f) => f.extruderId === req.nozzle_id);
     }
 
-    // Sort by remaining filament (ascending) so .find() picks the lowest-remain spool first
+    // Sort lowest-first when the preference is on. Inventory-tracked spools
+    // sort before MQTT-only ones; see preferLowestSortKey for the rationale.
     if (preferLowest) {
-      available = [...available].sort((a, b) => {
-        const ra = a.remain >= 0 ? a.remain : 101;
-        const rb = b.remain >= 0 ? b.remain : 101;
-        return ra - rb;
-      });
+      available = [...available].sort((a, b) =>
+        compareSortKeys(
+          preferLowestSortKey(a, inventoryByTrayId),
+          preferLowestSortKey(b, inventoryByTrayId),
+        ),
+      );
     }
 
     let idxMatch: LoadedFilament | undefined;
@@ -152,11 +157,12 @@ export function computeAmsMapping(
       } else if (idxMatches.length > 1) {
         // Multiple trays with same tray_info_idx - use color matching among them
         if (preferLowest) {
-          idxMatches.sort((a, b) => {
-            const ra = a.remain >= 0 ? a.remain : 101;
-            const rb = b.remain >= 0 ? b.remain : 101;
-            return ra - rb;
-          });
+          idxMatches.sort((a, b) =>
+            compareSortKeys(
+              preferLowestSortKey(a, inventoryByTrayId),
+              preferLowestSortKey(b, inventoryByTrayId),
+            ),
+          );
         }
         exactMatch = idxMatches.find(
           (f) =>
@@ -325,6 +331,7 @@ export function useFilamentMapping(
   printerStatus: PrinterStatus | undefined,
   manualMappings: Record<number, number>,
   preferLowest?: boolean,
+  inventoryByTrayId?: Map<number, number>,
 ): UseFilamentMappingResult {
   const loadedFilaments = useLoadedFilaments(printerStatus);
 
@@ -389,13 +396,15 @@ export function useFilamentMapping(
         available = available.filter((f) => f.extruderId === req.nozzle_id);
       }
 
-      // Sort by remaining filament (ascending) so .find() picks the lowest-remain spool first
+      // Sort lowest-first when the preference is on. Inventory-tracked spools
+      // sort before MQTT-only ones; see preferLowestSortKey for the rationale.
       if (preferLowest) {
-        available = [...available].sort((a, b) => {
-          const ra = a.remain >= 0 ? a.remain : 101;
-          const rb = b.remain >= 0 ? b.remain : 101;
-          return ra - rb;
-        });
+        available = [...available].sort((a, b) =>
+          compareSortKeys(
+            preferLowestSortKey(a, inventoryByTrayId),
+            preferLowestSortKey(b, inventoryByTrayId),
+          ),
+        );
       }
 
       let idxMatch: LoadedFilament | undefined;
@@ -412,11 +421,12 @@ export function useFilamentMapping(
         } else if (idxMatches.length > 1) {
           // Multiple trays with same tray_info_idx - use color matching among them
           if (preferLowest) {
-            idxMatches.sort((a, b) => {
-              const ra = a.remain >= 0 ? a.remain : 101;
-              const rb = b.remain >= 0 ? b.remain : 101;
-              return ra - rb;
-            });
+            idxMatches.sort((a, b) =>
+              compareSortKeys(
+                preferLowestSortKey(a, inventoryByTrayId),
+                preferLowestSortKey(b, inventoryByTrayId),
+              ),
+            );
           }
           exactMatch = idxMatches.find(
             (f) =>
@@ -491,7 +501,7 @@ export function useFilamentMapping(
         isManual: false,
       };
     });
-  }, [filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive]);
+  }, [filamentReqs, loadedFilaments, manualMappings, preferLowest, ftsActive, inventoryByTrayId]);
 
   // Build AMS mapping from matched filaments
   // Format: array matching 3MF filament slot structure

+ 37 - 15
frontend/src/hooks/useMultiPrinterFilamentMapping.ts

@@ -11,6 +11,9 @@ import {
 import {
   normalizeColorForCompare,
   colorsAreSimilar,
+  preferLowestSortKey,
+  compareSortKeys,
+  effectivePreferLowest,
 } from '../utils/amsHelpers';
 
 /**
@@ -58,6 +61,8 @@ export interface PrinterMappingResult {
   totalSlots: number;
   /** Per-printer config */
   config: PerPrinterConfig;
+  /** Per-globalTrayId inventory grams remaining, for the lowest-remain sort (#1766) */
+  inventoryByTrayId?: Map<number, number>;
 }
 
 /**
@@ -90,6 +95,7 @@ function computeMatchDetails(
   loadedFilaments: LoadedFilament[],
   manualMappings: Record<number, number>,
   preferLowest?: boolean,
+  inventoryByTrayId?: Map<number, number>,
 ): { exactMatches: number; typeOnlyMatches: number; missingTypes: number; totalSlots: number; status: PrinterMatchStatus } {
   if (!filamentReqs || filamentReqs.length === 0) {
     return { exactMatches: 0, typeOnlyMatches: 0, missingTypes: 0, totalSlots: 0, status: 'full' };
@@ -135,11 +141,12 @@ function computeMatchDetails(
     }
 
     if (preferLowest) {
-      candidates = [...candidates].sort((a, b) => {
-        const ra = a.remain >= 0 ? a.remain : 101;
-        const rb = b.remain >= 0 ? b.remain : 101;
-        return ra - rb;
-      });
+      candidates = [...candidates].sort((a, b) =>
+        compareSortKeys(
+          preferLowestSortKey(a, inventoryByTrayId),
+          preferLowestSortKey(b, inventoryByTrayId),
+        ),
+      );
     }
 
     const exactMatch = candidates.find(
@@ -194,6 +201,7 @@ function computeMappingWithOverrides(
   printerStatus: PrinterStatus | undefined,
   manualMappings: Record<number, number>,
   preferLowest?: boolean,
+  inventoryByTrayId?: Map<number, number>,
 ): number[] | undefined {
   if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
 
@@ -222,11 +230,12 @@ function computeMappingWithOverrides(
     }
 
     if (preferLowest) {
-      candidates = [...candidates].sort((a, b) => {
-        const ra = a.remain >= 0 ? a.remain : 101;
-        const rb = b.remain >= 0 ? b.remain : 101;
-        return ra - rb;
-      });
+      candidates = [...candidates].sort((a, b) =>
+        compareSortKeys(
+          preferLowestSortKey(a, inventoryByTrayId),
+          preferLowestSortKey(b, inventoryByTrayId),
+        ),
+      );
     }
 
     const exactMatch = candidates.find(
@@ -290,6 +299,7 @@ export function useMultiPrinterFilamentMapping(
   perPrinterConfigs: Record<number, PerPrinterConfig>,
   setPerPrinterConfigs: React.Dispatch<React.SetStateAction<Record<number, PerPrinterConfig>>>,
   preferLowest?: boolean,
+  inventoryByTrayIdPerPrinter?: Map<number, Map<number, number>>,
 ): UseMultiPrinterFilamentMappingResult {
   // Fetch printer status for all selected printers in parallel
   const statusQueries = useQueries({
@@ -311,9 +321,14 @@ export function useMultiPrinterFilamentMapping(
 
       const loadedFilaments = buildLoadedFilaments(printerStatus);
       const config = perPrinterConfigs[printerId] || DEFAULT_PRINTER_CONFIG;
+      const inventoryByTrayId = inventoryByTrayIdPerPrinter?.get(printerId);
+      // Per-printer gate (#1766): two printers in the same dispatch can have
+      // different AMS Backup states; the sort must be skipped on the OFF ones
+      // and kept on the ON ones. Computing inside the loop captures both.
+      const printerPreferLowest = effectivePreferLowest(preferLowest, printerStatus?.ams_filament_backup);
 
       // Compute auto mapping for this printer
-      const autoMapping = computeAmsMapping(filamentReqs, printerStatus, preferLowest);
+      const autoMapping = computeAmsMapping(filamentReqs, printerStatus, printerPreferLowest, inventoryByTrayId);
 
       // Determine which mappings to use:
       // If printer has override (useDefault=false), use its custom mappings
@@ -323,14 +338,15 @@ export function useMultiPrinterFilamentMapping(
         : defaultMappings;
 
       // Compute final mapping with overrides
-      const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, preferLowest);
+      const finalMapping = computeMappingWithOverrides(filamentReqs, printerStatus, effectiveMappings, printerPreferLowest, inventoryByTrayId);
 
       // Compute match details
       const matchDetails = computeMatchDetails(
         filamentReqs?.filaments,
         loadedFilaments,
         effectiveMappings,
-        preferLowest,
+        printerPreferLowest,
+        inventoryByTrayId,
       );
 
       return {
@@ -347,9 +363,10 @@ export function useMultiPrinterFilamentMapping(
         missingTypes: matchDetails.missingTypes,
         totalSlots: matchDetails.totalSlots,
         config,
+        inventoryByTrayId,
       };
     });
-  }, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest]);
+  }, [selectedPrinterIds, statusQueries, printers, filamentReqs, perPrinterConfigs, defaultMappings, preferLowest, inventoryByTrayIdPerPrinter]);
 
   const isLoading = statusQueries.some((q) => q.isLoading);
 
@@ -370,7 +387,12 @@ export function useMultiPrinterFilamentMapping(
     if (!result || !result.status || !filamentReqs?.filaments) return;
 
     // Compute optimal mapping for this printer
-    const autoMapping = computeAmsMapping(filamentReqs, result.status, preferLowest);
+    const autoMapping = computeAmsMapping(
+      filamentReqs,
+      result.status,
+      effectivePreferLowest(preferLowest, result.status?.ams_filament_backup),
+      inventoryByTrayIdPerPrinter?.get(printerId),
+    );
     if (!autoMapping) return;
 
     // Convert autoMapping array to manualMappings record

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

@@ -516,6 +516,13 @@ export default {
       stoppingDrying: 'Trocknung wird gestoppt...',
       rotateTray: 'Spule während der Trocknung drehen',
     },
+    amsBackup: {
+      titleOn: 'AMS Filament Backup ist EIN. Zum Deaktivieren klicken.',
+      titleOff: 'AMS Filament Backup ist AUS. Zum Aktivieren klicken.',
+      titleUnknown: 'AMS-Filament-Backup-Status auf diesem Drucker nicht verfügbar.',
+      toastEnabled: 'AMS Filament Backup aktiviert',
+      toastDisabled: 'AMS Filament Backup deaktiviert',
+    },
     // Filaments section
     filaments: 'Filamente',
     // Camera
@@ -1718,6 +1725,7 @@ export default {
     disableFilamentWarningsDesc: 'Keine Warnungen über unzureichendes Filament beim Drucken oder Einreihen anzeigen',
     preferLowestFilament: 'Niedrigsten Filamentrest bevorzugen',
     preferLowestFilamentDesc: 'Bei mehreren passenden Spulen die mit dem geringsten Restfilament verwenden',
+    preferLowestFilamentBackupNote: 'Wirkt nur, wenn AMS Filament Backup am Drucker aktiviert ist — sonst kann der Drucker beim Aufbrauchen der ersten Spule nicht auf eine zweite Spule wechseln.',
     trackingModeBuiltIn: 'Integriertes Inventar',
     trackingModeBuiltInDesc: 'RFID-Erkennung und Verbrauchserfassung inklusive',
     trackingModeSpoolmanDesc: 'Externer Filament-Management-Server',

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

@@ -516,6 +516,14 @@ export default {
       stoppingDrying: 'Stopping drying...',
       rotateTray: 'Rotate spool during drying',
     },
+    // AMS Filament Backup status badge (printer-wide auto-switch to another spool)
+    amsBackup: {
+      titleOn: 'AMS Filament Backup is ON. Click to disable.',
+      titleOff: 'AMS Filament Backup is OFF. Click to enable.',
+      titleUnknown: 'AMS Filament Backup status unavailable on this printer.',
+      toastEnabled: 'AMS Filament Backup enabled',
+      toastDisabled: 'AMS Filament Backup disabled',
+    },
     // Filaments section
     filaments: 'Filaments',
     // Camera
@@ -1728,6 +1736,7 @@ export default {
     disableFilamentWarningsDesc: 'Don\'t show warnings about insufficient filament when printing or queueing',
     preferLowestFilament: 'Prefer lowest remaining filament',
     preferLowestFilamentDesc: 'When multiple spools match, use the one with the least filament remaining',
+    preferLowestFilamentBackupNote: 'Only takes effect when AMS Filament Backup is enabled on the printer — otherwise the printer cannot switch to a second spool when the picked one runs out.',
     trackingModeBuiltIn: 'Built-in Inventory',
     trackingModeBuiltInDesc: 'RFID auto-matching and usage tracking included',
     trackingModeSpoolmanDesc: 'External filament management server',

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

@@ -516,6 +516,13 @@ export default {
       stoppingDrying: 'Deteniendo el secado...',
       rotateTray: 'Girar la bobina durante el secado',
     },
+    amsBackup: {
+      titleOn: 'AMS Filament Backup está ACTIVADO. Haz clic para desactivar.',
+      titleOff: 'AMS Filament Backup está DESACTIVADO. Haz clic para activar.',
+      titleUnknown: 'Estado de AMS Filament Backup no disponible en esta impresora.',
+      toastEnabled: 'AMS Filament Backup activado',
+      toastDisabled: 'AMS Filament Backup desactivado',
+    },
     // Filaments section
     filaments: 'Filamentos',
     // Camera
@@ -1721,6 +1728,7 @@ export default {
     disableFilamentWarningsDesc: 'No mostrar advertencias sobre filamento insuficiente al imprimir o encolar',
     preferLowestFilament: 'Preferir el filamento con menos restante',
     preferLowestFilamentDesc: 'Cuando varias bobinas coincidan, usar la que tenga menos filamento restante',
+    preferLowestFilamentBackupNote: 'Solo surte efecto cuando AMS Filament Backup está activado en la impresora — de lo contrario, la impresora no puede cambiar a una segunda bobina cuando la elegida se acabe.',
     trackingModeBuiltIn: 'Inventario integrado',
     trackingModeBuiltInDesc: 'Incluye coincidencia automática por RFID y seguimiento del uso',
     trackingModeSpoolmanDesc: 'Servidor externo de gestión de filamento',

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

@@ -516,6 +516,13 @@ export default {
       stoppingDrying: 'Arrêt du séchage...',
       rotateTray: 'Tourner la bobine pendant le séchage',
     },
+    amsBackup: {
+      titleOn: "AMS Filament Backup est ACTIVÉ. Cliquez pour désactiver.",
+      titleOff: "AMS Filament Backup est DÉSACTIVÉ. Cliquez pour activer.",
+      titleUnknown: "État de l'AMS Filament Backup indisponible sur cette imprimante.",
+      toastEnabled: "AMS Filament Backup activé",
+      toastDisabled: "AMS Filament Backup désactivé",
+    },
     // Filaments section
     filaments: 'Filaments',
     // Camera
@@ -1674,6 +1681,7 @@ export default {
     disableFilamentWarningsDesc: 'Ne pas afficher les avertissements de filament insuffisant lors de l\'impression ou de la mise en file d\'attente',
     preferLowestFilament: 'Préférer le filament le plus bas',
     preferLowestFilamentDesc: 'Lorsque plusieurs bobines correspondent, utiliser celle avec le moins de filament restant',
+    preferLowestFilamentBackupNote: "Ne prend effet que si AMS Filament Backup est activé sur l'imprimante — sinon, l'imprimante ne peut pas basculer vers une seconde bobine quand celle choisie est vide.",
     trackingModeBuiltIn: 'Inventaire Intégré',
     trackingModeBuiltInDesc: 'Correspondance RFID et suivi de consommation inclus',
     trackingModeSpoolmanDesc: 'Serveur de gestion externe',

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

@@ -516,6 +516,13 @@ export default {
       stoppingDrying: 'Arresto essiccazione...',
       rotateTray: 'Ruota la bobina durante l\'essiccazione',
     },
+    amsBackup: {
+      titleOn: 'AMS Filament Backup è ATTIVO. Clicca per disabilitare.',
+      titleOff: 'AMS Filament Backup è DISATTIVATO. Clicca per abilitare.',
+      titleUnknown: 'Stato di AMS Filament Backup non disponibile su questa stampante.',
+      toastEnabled: 'AMS Filament Backup abilitato',
+      toastDisabled: 'AMS Filament Backup disabilitato',
+    },
     // Filaments section
     filaments: 'Filamenti',
     // Camera
@@ -1674,6 +1681,7 @@ export default {
     disableFilamentWarningsDesc: 'Non mostrare avvisi per filamento insufficiente durante la stampa o l\'accodamento',
     preferLowestFilament: 'Preferisci il filamento con meno residuo',
     preferLowestFilamentDesc: 'Quando più bobine corrispondono, usa quella con meno filamento rimanente',
+    preferLowestFilamentBackupNote: 'Ha effetto solo se AMS Filament Backup è abilitato sulla stampante — altrimenti la stampante non può passare a una seconda bobina quando quella scelta finisce.',
     trackingModeBuiltIn: 'Inventario integrato',
     trackingModeBuiltInDesc: 'Riconoscimento RFID automatico e tracciamento dell\'uso inclusi',
     trackingModeSpoolmanDesc: 'Server esterno per la gestione del filamento',

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

@@ -515,6 +515,13 @@ export default {
       stoppingDrying: '乾燥を停止しています...',
       rotateTray: '乾燥中にスプールを回転',
     },
+    amsBackup: {
+      titleOn: 'AMSフィラメントバックアップはONです。クリックして無効化します。',
+      titleOff: 'AMSフィラメントバックアップはOFFです。クリックして有効化します。',
+      titleUnknown: 'このプリンタではAMSフィラメントバックアップ状態を確認できません。',
+      toastEnabled: 'AMSフィラメントバックアップを有効化しました',
+      toastDisabled: 'AMSフィラメントバックアップを無効化しました',
+    },
     // Filaments section
     filaments: 'フィラメント',
     // Camera
@@ -1717,6 +1724,7 @@ export default {
     disableFilamentWarningsDesc: '印刷またはキュー追加時にフィラメント不足の警告を表示しない',
     preferLowestFilament: '残量が少ないフィラメントを優先',
     preferLowestFilamentDesc: '複数のスプールが一致する場合、残量が最も少ないものを使用します',
+    preferLowestFilamentBackupNote: 'プリンタ側でAMSフィラメントバックアップが有効な場合のみ動作します。無効の場合、選択したスプールが切れても予備スプールに切り替えできません。',
     trackingModeBuiltIn: '内蔵インベントリ',
     trackingModeBuiltInDesc: 'RFID自動検出と使用量追跡を含む',
     trackingModeSpoolmanDesc: '外部フィラメント管理サーバー',

+ 8 - 0
frontend/src/i18n/locales/ko.ts

@@ -479,6 +479,13 @@ export default {
       stoppingDrying: '건조 정지 중...',
       rotateTray: '건조 중 스풀 회전'
     },
+    amsBackup: {
+      titleOn: 'AMS 필라멘트 백업이 켜져 있습니다. 비활성화하려면 클릭하세요.',
+      titleOff: 'AMS 필라멘트 백업이 꺼져 있습니다. 활성화하려면 클릭하세요.',
+      titleUnknown: '이 프린터에서는 AMS 필라멘트 백업 상태를 확인할 수 없습니다.',
+      toastEnabled: 'AMS 필라멘트 백업이 활성화되었습니다',
+      toastDisabled: 'AMS 필라멘트 백업이 비활성화되었습니다'
+    },
     filaments: '필라멘트',
     openCameraOverlay: '카메라 오버레이 열기',
     openCameraWindow: '새 창에서 카메라 열기',
@@ -1625,6 +1632,7 @@ export default {
     disableFilamentWarningsDesc: '인쇄 또는 대기열 추가 시 필라멘트 부족 경고 표시 안 함',
     preferLowestFilament: '남은 필라멘트가 가장 적은 것 우선',
     preferLowestFilamentDesc: '여러 스풀이 일치할 때 남은 필라멘트가 가장 적은 것 사용',
+    preferLowestFilamentBackupNote: '프린터에서 AMS 필라멘트 백업이 활성화된 경우에만 적용됩니다. 그렇지 않으면 선택한 스풀이 다 떨어졌을 때 두 번째 스풀로 전환할 수 없습니다.',
     trackingModeBuiltIn: '내장 인벤토리',
     trackingModeBuiltInDesc: 'RFID 자동 매칭 및 사용량 추적 포함',
     trackingModeSpoolmanDesc: '외부 필라멘트 관리 서버',

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

@@ -516,6 +516,13 @@ export default {
       stoppingDrying: 'Parando secagem...',
       rotateTray: 'Girar o carretel durante a secagem',
     },
+    amsBackup: {
+      titleOn: 'AMS Filament Backup está LIGADO. Clique para desativar.',
+      titleOff: 'AMS Filament Backup está DESLIGADO. Clique para ativar.',
+      titleUnknown: 'Estado do AMS Filament Backup indisponível nesta impressora.',
+      toastEnabled: 'AMS Filament Backup ativado',
+      toastDisabled: 'AMS Filament Backup desativado',
+    },
     // Filaments section
     filaments: 'Filamentos',
     // Camera
@@ -1674,6 +1681,7 @@ export default {
     disableFilamentWarningsDesc: 'Não mostrar avisos sobre filamento insuficiente ao imprimir ou adicionar à fila',
     preferLowestFilament: 'Preferir filamento com menor resto',
     preferLowestFilamentDesc: 'Quando vários carretéis correspondem, usar o com menos filamento restante',
+    preferLowestFilamentBackupNote: 'Só tem efeito quando o AMS Filament Backup está habilitado na impressora — caso contrário, a impressora não pode trocar para um segundo carretel quando o escolhido acabar.',
     trackingModeBuiltIn: 'Inventário Interno',
     trackingModeBuiltInDesc: 'Correspondência automática de RFID e rastreamento de uso incluídos',
     trackingModeSpoolmanDesc: 'Servidor de gerenciamento de filamento externo',

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

@@ -516,6 +516,13 @@ export default {
       stoppingDrying: 'Kurutma durduruluyor...',
       rotateTray: 'Kurutma sırasında makarayı döndür',
     },
+    amsBackup: {
+      titleOn: 'AMS Filament Backup AÇIK. Devre dışı bırakmak için tıklayın.',
+      titleOff: 'AMS Filament Backup KAPALI. Etkinleştirmek için tıklayın.',
+      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ı',
+    },
     // Filamentler bölümü
     filaments: 'Filamentler',
     // Kamera
@@ -1721,6 +1728,7 @@ export default {
     disableFilamentWarningsDesc: 'Yazdırırken veya kuyruğa eklerken yetersiz filamentle ilgili uyarıları gösterme',
     preferLowestFilament: 'En az kalan filamenti tercih et',
     preferLowestFilamentDesc: 'Birden fazla makara eşleştiğinde, kalan filamenti en az olanı kullan',
+    preferLowestFilamentBackupNote: 'Yalnızca yazıcıda AMS Filament Backup etkinleştirildiğinde uygulanır — aksi takdirde seçilen makara bittiğinde yazıcı ikinci bir makaraya geçemez.',
     trackingModeBuiltIn: 'Yerleşik Envanter',
     trackingModeBuiltInDesc: 'RFID otomatik eşleştirme ve kullanım takibi dahil',
     trackingModeSpoolmanDesc: 'Harici filament yönetim sunucusu',

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

@@ -516,6 +516,13 @@ export default {
       stoppingDrying: '正在停止干燥...',
       rotateTray: '干燥时旋转料盘',
     },
+    amsBackup: {
+      titleOn: 'AMS 备用料盘已开启。点击以禁用。',
+      titleOff: 'AMS 备用料盘已关闭。点击以启用。',
+      titleUnknown: '本打印机不支持读取 AMS 备用料盘状态。',
+      toastEnabled: 'AMS 备用料盘已启用',
+      toastDisabled: 'AMS 备用料盘已禁用',
+    },
     // Filaments section
     filaments: '耗材',
     // Camera
@@ -1719,6 +1726,7 @@ export default {
     disableFilamentWarningsDesc: '在打印或加入队列时不显示耗材不足警告',
     preferLowestFilament: '优先使用剩余最少的耗材',
     preferLowestFilamentDesc: '当多个料盘匹配时,使用剩余耗材最少的那个',
+    preferLowestFilamentBackupNote: '仅当打印机端启用了 AMS 备用料盘时才生效——否则当选中的料盘耗尽时,打印机无法切换到备用料盘。',
     trackingModeBuiltIn: '内置库存',
     trackingModeBuiltInDesc: '包含 RFID 自动匹配和用量追踪',
     trackingModeSpoolmanDesc: '外部耗材管理服务器',

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

@@ -516,6 +516,13 @@ export default {
       stoppingDrying: '正在停止乾燥...',
       rotateTray: '乾燥時旋轉料盤',
     },
+    amsBackup: {
+      titleOn: 'AMS 備用料盤已開啟。點擊以停用。',
+      titleOff: 'AMS 備用料盤已關閉。點擊以啟用。',
+      titleUnknown: '本印表機不支援讀取 AMS 備用料盤狀態。',
+      toastEnabled: 'AMS 備用料盤已啟用',
+      toastDisabled: 'AMS 備用料盤已停用',
+    },
     // Filaments section
     filaments: '耗材',
     // Camera
@@ -1719,6 +1726,7 @@ export default {
     disableFilamentWarningsDesc: '在列印或加入佇列時不顯示耗材不足警告',
     preferLowestFilament: '優先使用剩餘最少的耗材',
     preferLowestFilamentDesc: '當多個料盤匹配時,使用剩餘耗材最少的那個',
+    preferLowestFilamentBackupNote: '僅當印表機端啟用了 AMS 備用料盤時才生效——否則當選中的料盤耗盡時,印表機無法切換到備用料盤。',
     trackingModeBuiltIn: '內建庫存',
     trackingModeBuiltInDesc: '包含 RFID 自動匹配和用量追蹤',
     trackingModeSpoolmanDesc: '外部耗材管理伺服器',

+ 69 - 0
frontend/src/pages/PrintersPage.tsx

@@ -70,6 +70,7 @@ import {
   Info,
   Cable,
   Flame,
+  Repeat,
   Snowflake,
   Gauge,
   DoorOpen,
@@ -691,6 +692,53 @@ 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
+interface AmsBackupBadgeProps {
+  state: boolean | null;
+  canToggle: boolean;
+  pending: boolean;
+  onToggle: (next: boolean) => void;
+}
+
+function AmsBackupBadge({ state, canToggle, pending, onToggle }: 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
+      ? '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
+      ? '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');
+  } else {
+    className += 'bg-bambu-dark text-bambu-gray/50 cursor-default';
+    title = t('printers.amsBackup.titleUnknown');
+  }
+
+  return (
+    <button
+      type="button"
+      disabled={!clickable}
+      onClick={() => clickable && onToggle(!state)}
+      className={className}
+      title={title}
+      aria-label={title}
+    >
+      {known ? <Repeat className="w-3 h-3" /> : <span>?</span>}
+    </button>
+  );
+}
+
 // Humidity indicator with water drop that fills based on level (Bambu Lab style)
 // Reference: https://github.com/theicedmango/bambu-humidity
 interface HumidityIndicatorProps {
@@ -2157,6 +2205,21 @@ function PrinterCard({
     onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
   });
 
+  // AMS Filament Backup toggle (auto-switch to a backup spool when one runs out).
+  // Invalidate BOTH printer-status cache keys — the codebase has two conventions
+  // ('printerStatus' camelCase + 'printer-status' kebab-case used by PrintModal /
+  // useMultiPrinterFilamentMapping). Hitting only one would leave PrintModal
+  // showing the old backup state until the user reopens it.
+  const setAmsBackupMutation = useMutation({
+    mutationFn: (enabled: boolean) => api.setAmsFilamentBackup(printer.id, enabled),
+    onSuccess: (_data, enabled) => {
+      queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
+      queryClient.invalidateQueries({ queryKey: ['printer-status', printer.id] });
+      showToast(t(enabled ? 'printers.amsBackup.toastEnabled' : 'printers.amsBackup.toastDisabled'), 'success');
+    },
+    onError: (error: Error) => showToast(error.message || t('printers.toast.failedToSendCommand'), 'error'),
+  });
+
   // Smart plug control mutations
   const powerControlMutation = useMutation({
     mutationFn: (action: 'on' | 'off') =>
@@ -4176,6 +4239,12 @@ function PrinterCard({
                     <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
                       {t('printers.filaments')}
                     </span>
+                    <AmsBackupBadge
+                      state={status.ams_filament_backup}
+                      canToggle={hasPermission('printers:control')}
+                      pending={setAmsBackupMutation.isPending}
+                      onToggle={(next) => setAmsBackupMutation.mutate(next)}
+                    />
                     <div className="flex-1 h-[2px] bg-bambu-dark-tertiary" />
                   </div>
 

+ 3 - 0
frontend/src/pages/SettingsPage.tsx

@@ -4657,6 +4657,9 @@ export function SettingsPage() {
                     <p className="text-sm text-bambu-gray">
                       {t('settings.preferLowestFilamentDesc')}
                     </p>
+                    <p className="text-xs text-bambu-gray/70 mt-1">
+                      {t('settings.preferLowestFilamentBackupNote')}
+                    </p>
                   </div>
                   <label className="relative inline-flex items-center cursor-pointer">
                     <input

+ 73 - 6
frontend/src/utils/amsHelpers.ts

@@ -203,24 +203,91 @@ export function isPlaceholderDate(scheduledTime: string | null | undefined): boo
   return (parseUTCDate(scheduledTime)?.getTime() ?? 0) > sixMonthsFromNow;
 }
 
+/**
+ * Banding tie-break for `preferLowestSortKey`, mirroring backend
+ * `PrintScheduler._slot_priority` so regular AMS < AMS-HT < external on ties
+ * regardless of the raw `ams_id`. In particular, `ams_id = -1` (VT / external
+ * in `buildLoadedFilaments`) MUST NOT sort to a negative number or it would
+ * beat AMS slot 0 — backend clamps to 10_000.
+ */
+function slotPriority(amsId: number | undefined, trayId: number | undefined): number {
+  if (amsId == null || amsId < 0) return 10_000;
+  if (amsId >= 128) return 1_000 + (amsId - 128) * 4 + (trayId ?? 0);
+  return amsId * 4 + (trayId ?? 0);
+}
+
+/**
+ * Two-tier sort key for the "Prefer Lowest Remaining Filament" preference (#1766).
+ *
+ * Mirrors backend `_prefer_lowest_sort_key` in `print_scheduler.py:1161` so the
+ * client-side sort that PrintModal pre-computes lines up with the dispatch-time
+ * sort. Inventory-bound spools sort before MQTT-only ones (tier 0 vs tier 1) so
+ * the user's tracked grams beat the printer's per-cent estimate; within each
+ * tier the lowest value wins, with the slot-position tie-break above so the
+ * order is deterministic across identical spools.
+ *
+ * `inventoryByTrayId` is the `globalTrayId -> grams_remaining` map derived from
+ * the user's spool assignments. Pass `undefined` to fall back to remain%-only
+ * sorting (preserves pre-#1766 behaviour for callers that don't yet wire it in).
+ */
+export function preferLowestSortKey(
+  f: { globalTrayId: number; amsId?: number; trayId?: number; remain?: number },
+  inventoryByTrayId: Map<number, number> | undefined,
+): [number, number, number] {
+  const slot = slotPriority(f.amsId, f.trayId);
+  if (inventoryByTrayId && inventoryByTrayId.has(f.globalTrayId)) {
+    return [0, inventoryByTrayId.get(f.globalTrayId) ?? 0, slot];
+  }
+  const remain = f.remain ?? -1;
+  return [1, remain >= 0 ? remain : 101, slot];
+}
+
+/** Tuple compare for `preferLowestSortKey` outputs. */
+export function compareSortKeys(
+  a: [number, number, number],
+  b: [number, number, number],
+): number {
+  return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
+}
+
+/**
+ * Effective "Prefer lowest remaining filament" preference for a given printer,
+ * gated on its AMS Filament Backup state (#1766).
+ *
+ * Without backup, the printer can't switch to a second spool when the picked
+ * one runs out — so even with the user setting on, sorting toward the lowest
+ * leaves the print at risk. Mirrors the backend gate in
+ * `print_scheduler.py::_compute_ams_mapping_for_printer`. `null`/`undefined`
+ * (unknown state, e.g. A1 family) preserves today's behaviour intentionally.
+ */
+export function effectivePreferLowest(
+  setting: boolean | undefined,
+  amsFilamentBackup: boolean | null | undefined,
+): boolean {
+  if (!setting) return false;
+  return amsFilamentBackup !== false;
+}
+
 /**
  * Auto-match a filament requirement to a loaded filament, respecting nozzle constraints.
  * Used by both single-printer (FilamentMapping) and multi-printer (InlineMappingEditor) paths.
  */
 export function autoMatchFilament(
   req: { type?: string; color?: string; nozzle_id?: number | null },
-  loadedFilaments: { globalTrayId: number; type?: string; color?: string; extruderId?: number; remain?: number }[],
+  loadedFilaments: { globalTrayId: number; amsId?: number; trayId?: number; type?: string; color?: string; extruderId?: number; remain?: number }[],
   usedTrayIds: Set<number>,
   preferLowest?: boolean,
+  inventoryByTrayId?: Map<number, number>,
 ): typeof loadedFilaments[number] | undefined {
   let nozzleFilaments = filterFilamentsByNozzle(loadedFilaments, req.nozzle_id);
 
   if (preferLowest) {
-    nozzleFilaments = [...nozzleFilaments].sort((a, b) => {
-      const ra = (a.remain ?? -1) >= 0 ? (a.remain ?? -1) : 101;
-      const rb = (b.remain ?? -1) >= 0 ? (b.remain ?? -1) : 101;
-      return ra - rb;
-    });
+    nozzleFilaments = [...nozzleFilaments].sort((a, b) =>
+      compareSortKeys(
+        preferLowestSortKey(a, inventoryByTrayId),
+        preferLowestSortKey(b, inventoryByTrayId),
+      ),
+    );
   }
 
   const exactMatch = nozzleFilaments.find(

Деякі файли не було показано, через те що забагато файлів було змінено