Browse Source

feat: dual external spool support, AMS slot model filtering & pre-population

Backend:
- Add dual external spool support for H2D (vt_tray as list: Ext-L/Ext-R)
- Add cloud filament ID map endpoint (/cloud/filament-id-map)
- Fix RFID spool data erased by periodic AMS updates (skip tag matcher
  for RFID-tagged trays)
- Fix AMS slot config overwrites RFID spool state
- Fix K-profile selection corrupts existing profiles on X1C/P1S
- Resolve K-profiles filament name via cloud filament ID map
- Update print scheduler and usage tracker for dual external spools

Frontend:
- Add printer model filtering to ConfigureAmsSlotModal (cloud/local/builtin
  presets filtered by @BBL model suffix and compatible_printers)
- Add pre-population for configured slots (preset, color, K-profile)
- Add K-Profiles view with accurate filament name resolution
- Internationalize all ConfigureAmsSlotModal strings (en/de/fr/it/ja — 21 keys)
- Add 5 new ConfigureAmsSlotModal tests (model filtering, pre-selection,
  color pre-population, i18n)
- Update PrintersPage for dual external spool rendering

Docs:
- Update CHANGELOG, README, website features, and wiki AMS docs
maziggy 3 months ago
parent
commit
a37dfaf7fb
40 changed files with 1327 additions and 495 deletions
  1. 6 0
      CHANGELOG.md
  2. 2 1
      README.md
  3. 61 0
      backend/app/api/routes/cloud.py
  4. 38 14
      backend/app/api/routes/inventory.py
  5. 3 0
      backend/app/api/routes/kprofiles.py
  6. 216 56
      backend/app/api/routes/printers.py
  7. 1 1
      backend/app/api/routes/support.py
  8. 4 1
      backend/app/main.py
  9. 1 1
      backend/app/schemas/printer.py
  10. 151 57
      backend/app/services/bambu_mqtt.py
  11. 21 21
      backend/app/services/print_scheduler.py
  12. 34 31
      backend/app/services/printer_manager.py
  13. 34 34
      backend/app/services/spool_tag_matcher.py
  14. 9 8
      backend/app/services/spoolman_tracking.py
  15. 11 2
      backend/app/services/usage_tracker.py
  16. 15 12
      backend/tests/unit/services/test_printer_manager.py
  17. 2 2
      backend/tests/unit/services/test_spoolman_tracking.py
  18. 4 4
      backend/tests/unit/test_scheduler_ams_mapping.py
  19. 1 0
      frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx
  20. 77 0
      frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
  21. 1 1
      frontend/src/__tests__/components/PrintModal.test.tsx
  22. 3 3
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  23. 1 0
      frontend/src/__tests__/pages/PrintersPage.test.tsx
  24. 5 1
      frontend/src/api/client.ts
  25. 5 3
      frontend/src/components/AssignSpoolModal.tsx
  26. 180 39
      frontend/src/components/ConfigureAmsSlotModal.tsx
  27. 66 5
      frontend/src/components/KProfilesView.tsx
  28. 20 16
      frontend/src/hooks/useFilamentMapping.ts
  29. 24 0
      frontend/src/i18n/locales/de.ts
  30. 24 0
      frontend/src/i18n/locales/en.ts
  31. 24 0
      frontend/src/i18n/locales/fr.ts
  32. 37 0
      frontend/src/i18n/locales/it.ts
  33. 24 0
      frontend/src/i18n/locales/ja.ts
  34. 219 179
      frontend/src/pages/PrintersPage.tsx
  35. 1 1
      frontend/src/utils/amsHelpers.ts
  36. 0 0
      static/assets/index-BOd5pCVD.js
  37. 0 0
      static/assets/index-CS3Lw7Ok.js
  38. 0 0
      static/assets/index-D--TAtCz.css
  39. 0 0
      static/assets/index-DZOdnZuT.css
  40. 2 2
      static/index.html

+ 6 - 0
CHANGELOG.md

@@ -11,6 +11,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **Notification Templates — Filament Usage Variables** ([#336](https://github.com/maziggy/bambuddy/issues/336)) — `print_complete`, `print_failed`, and `print_stopped` notification events now expose `{filament_grams}` (total grams, scaled by progress for partial prints), `{filament_details}` (per-filament breakdown with AMS slot info, e.g. "AMS-A T1 PLA: 12.4g | AMS-A T3 PETG: 2.8g"), and `{progress}` (completion percentage for failed/stopped prints). The `{filament_details}` variable includes the AMS unit and tray position for each filament used, with "Ext" shown for external spool holders. Falls back to type-only format (e.g. "PLA: 10.0g") when usage tracking data is unavailable. Webhook payloads include `filament_used`, `filament_details`, and `progress` fields. Per-slot filament data is stored in archive `extra_data` for downstream use.
 - **Printer Status Summary Bar — Next Available & Availability Count** ([#354](https://github.com/maziggy/bambuddy/issues/354)) — The status bar on the Printers page now shows an availability count ("X available") alongside the printing/offline counts, and a "Next available" indicator showing which printing printer will finish soonest — with printer name, mini progress bar, completion percentage, and remaining time. Useful for print farms to quickly identify the next free printer. Updates in real-time via WebSocket. Translated in all 4 locales (en, de, ja, it).
 - **Nozzle-Aware AMS Filament Mapping for Dual-Nozzle Printers** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — On dual-nozzle printers (H2D, H2D Pro), each AMS unit is physically connected to either the left or right nozzle. Bambuddy now reads nozzle assignments from the 3MF file (`filament_nozzle_map` + `physical_extruder_map` in `project_settings.config`) and constrains filament matching to only AMS trays connected to the correct nozzle via `ams_extruder_map`. Applies to the print scheduler, reprint modal, queue modal, and multi-printer selection. Falls back gracefully to unfiltered matching when no trays exist on the target nozzle. The filament mapping UI shows L/R nozzle badges for dual-nozzle prints. Translated in all 4 locales (en, de, ja, it).
+- **Dual External Spool Support for H2D** — H2-series printers with two external spool holders (Ext-L and Ext-R) are now fully supported. The external spool section renders as a grid with both slots, each showing filament type, color, fill level, and hover card details. Previously only a single external spool was displayed. Applies to the printer card, filament mapping, print scheduler, usage tracking, and inventory assignment. The `vt_tray` field is now an array across the entire stack (MQTT, API, WebSocket, frontend).
+- **AMS Slot Configuration — Model Filtering & Pre-Population** — The Configure AMS Slot modal now filters filament presets by the connected printer model. Only presets matching the printer (e.g., "@BBL X1C" presets for X1C printers) and generic presets without a model suffix are shown. Local presets are filtered by their `compatible_printers` field. When re-configuring an already-configured slot, the modal pre-selects the saved preset, pre-populates the color, and auto-selects the active K-profile. The preset list auto-scrolls to the selected item. All modal strings are now fully translated in 5 locales (en, de, fr, it, ja).
+- **K-Profiles View — Accurate Filament Name Resolution** — K-profile filament names are now resolved from builtin filament tables and user cloud presets (via new `/cloud/filament-id-map` endpoint) instead of showing raw IDs like "GFU99" or "P4d64437". Falls back to extracting names from the profile name field.
 
 ### Fixed
 - **Bulk Archive Delete Leaves Orphaned Database Records** — When bulk-deleting archives, the files were removed from disk before the database commit. If concurrent SQLite writes caused a lock timeout, the commit failed and rolled back — leaving database records pointing to deleted files (broken thumbnails, 404 errors). Fixed by deleting the database record first and only removing files after a successful commit.
@@ -18,6 +21,9 @@ All notable changes to Bambuddy will be documented in this file.
 - **AMS Slot Configuration Overwritten on Startup** — Bambuddy was resetting AMS slot filament presets on every startup and reconnection. The `on_ams_change` callback unconditionally unlinked Bambu Lab spool assignments on each MQTT push-all response, then re-assigned them by sending `ams_filament_setting` without a `setting_id`, which cleared the printer's filament preset. Now compares spool RFID identifiers (`tray_uuid` / `tag_uid`) before unlinking — if the same spool is still in the slot, the assignment is preserved and no `ams_filament_setting` command is sent.
 - **Bambu Lab Spool Detection False Positives** — The `is_bambu_lab_spool()` function (backend) and `isBambuLabSpool()` (frontend) incorrectly identified third-party spools as Bambu Lab spools when they used Bambu generic filament presets (e.g., "Generic PLA"). The `tray_info_idx` field (e.g., "GFA00") identifies the filament *type*, not the spool manufacturer — third-party spools using Bambu presets also have GF-prefixed values. Removed `tray_info_idx` from detection logic; now uses only hardware RFID identifiers (`tray_uuid` and `tag_uid`) which are physically embedded in genuine Bambu Lab spools.
 - **FTP Disconnect Raises EOFError When Server Dies** — `BambuFTPClient.disconnect()` only caught `OSError` and `ftplib.Error`, but `quit()` raises `EOFError` when the server has closed the connection mid-session. `EOFError` is not a subclass of either, so it propagated to callers. Now caught alongside the other exception types for clean best-effort disconnect.
+- **RFID Spool Data Erased by Periodic AMS Updates** — Periodic MQTT push-all responses cleared `tag_uid` and `tray_uuid` fields because they were included in the "always update" list. These fields are now preserved during updates and only cleared when a spool is physically removed (slot clearing detected by empty `tray_type`). This fixes the AMS "eye" icon disappearing for RFID spools after startup.
+- **AMS Slot Configuration Overwrites RFID Spool State** — Configuring an AMS slot for an RFID-detected Bambu Lab spool sent `ams_set_filament_setting`, which replaced the firmware's RFID-managed filament config with a manual one — causing the slicer's "eye" icon to change to a "pen" icon. Now detects RFID spools and skips the filament setting command, only sending K-profile selection.
+- **K-Profile Selection Corrupts Existing Profiles on X1C/P1S** — The `extrusion_cali_sel` command included a `setting_id` field that BambuStudio never sends, causing firmware to mislink calibration data. The `extrusion_cali_set` command was sent unconditionally, overwriting existing profile metadata. Now `setting_id` is removed from selection commands, and `extrusion_cali_set` is only sent when no existing profile is selected (`cali_idx < 0`).
 
 ### Improved
 - **SQLite WAL Mode for Database Reliability** — Database now uses Write-Ahead Logging (WAL) mode with a 5-second busy timeout, reducing "database is locked" errors under concurrent access. WAL mode allows simultaneous reads during writes, improving responsiveness for multi-printer setups. Automatically enabled on startup.

+ 2 - 1
README.md

@@ -88,7 +88,8 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - AMS slot RFID re-read
-- AMS slot configuration (custom presets, K profiles, color picker)
+- AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
+- Dual external spool support for H2D (Ext-L / Ext-R)
 - HMS error monitoring with history
 - Print success rates & trends
 - Filament usage tracking

+ 61 - 0
backend/app/api/routes/cloud.py

@@ -889,6 +889,67 @@ async def get_builtin_filaments(
     return [{"filament_id": fid, "name": name} for fid, name in _BUILTIN_FILAMENT_NAMES.items()]
 
 
+# Cache for filament_id → name mapping (resolved from cloud preset details)
+_filament_id_name_cache: dict[str, str] = {}
+_filament_id_name_cache_time: float = 0
+
+
+@router.get("/filament-id-map")
+async def get_filament_id_map(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
+    """
+    Get filament_id → name mapping for user cloud presets.
+
+    K-profiles store a filament_id (e.g., "P4d64437") which is different from
+    the cloud preset setting_id (e.g., "PFUS9ac902733670a9"). This endpoint
+    fetches details for all custom presets and returns the mapping.
+    Cached for 5 minutes.
+    """
+    import time
+
+    global _filament_id_name_cache, _filament_id_name_cache_time
+
+    if _filament_id_name_cache and time.time() - _filament_id_name_cache_time < FILAMENT_CACHE_TTL:
+        return _filament_id_name_cache
+
+    token, _ = await get_stored_token(db)
+    if not token:
+        return _filament_id_name_cache or {}
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+    if not cloud.is_authenticated:
+        return _filament_id_name_cache or {}
+
+    try:
+        data = await cloud.get_slicer_settings()
+        custom_presets = data.get("filament", {}).get("private", [])
+
+        result: dict[str, str] = {}
+        for preset in custom_presets:
+            setting_id = preset.get("setting_id", "")
+            if not setting_id:
+                continue
+            try:
+                detail = await cloud.get_setting_detail(setting_id)
+                fid = detail.get("filament_id", "")
+                name = detail.get("name", "")
+                if fid and name:
+                    # Strip printer/nozzle suffix: "Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle" → "Devil Design PLA Basic"
+                    clean_name = name.split(" @")[0].strip() if " @" in name else name
+                    result[fid] = clean_name
+            except Exception:
+                pass
+
+        _filament_id_name_cache = result
+        _filament_id_name_cache_time = time.time()
+        return result
+    except Exception:
+        return _filament_id_name_cache or {}
+
+
 @router.get("/fields/{preset_type}")
 async def get_preset_fields(
     preset_type: Literal["filament", "print", "process", "printer"],

+ 38 - 14
backend/app/api/routes/inventory.py

@@ -645,18 +645,32 @@ async def assign_spool(
     fingerprint_type = None
     state = printer_manager.get_status(data.printer_id)
     if state and state.raw_data:
-        ams_data = state.raw_data.get("ams", {})
-        ams_list = (
-            ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
-        )
-        tray = _find_tray_in_ams_data(
-            ams_list,
-            data.ams_id,
-            data.tray_id,
-        )
-        if tray:
-            fingerprint_color = tray.get("tray_color", "")
-            fingerprint_type = tray.get("tray_type", "")
+        if data.ams_id == 255:
+            # External slot: look up tray from vt_tray by global ID
+            vt_tray = state.raw_data.get("vt_tray") or []
+            ext_id = data.tray_id + 254  # 0→254, 1→255
+            for vt in vt_tray:
+                if isinstance(vt, dict) and int(vt.get("id", 254)) == ext_id:
+                    fingerprint_color = vt.get("tray_color", "")
+                    fingerprint_type = vt.get("tray_type", "")
+                    break
+        else:
+            ams_data = state.raw_data.get("ams", {})
+            ams_list = (
+                ams_data.get("ams", [])
+                if isinstance(ams_data, dict)
+                else ams_data
+                if isinstance(ams_data, list)
+                else []
+            )
+            tray = _find_tray_in_ams_data(
+                ams_list,
+                data.ams_id,
+                data.tray_id,
+            )
+            if tray:
+                fingerprint_color = tray.get("tray_color", "")
+                fingerprint_type = tray.get("tray_type", "")
 
     # 3. Upsert assignment (replace if same printer+ams+tray)
     existing = await db.execute(
@@ -715,16 +729,27 @@ async def assign_spool(
                 setting_id=setting_id,
             )
 
-            # b. Look up K-profile for this spool + printer + nozzle
+            # b. Look up K-profile for this spool + printer + nozzle + extruder
             nozzle_diameter = "0.4"
             if state and state.nozzles:
                 nd = state.nozzles[0].nozzle_diameter
                 if nd:
                     nozzle_diameter = nd
 
+            # Determine slot's extruder from ams_extruder_map
+            slot_extruder = None
+            if state and state.ams_extruder_map:
+                if data.ams_id == 255:
+                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
+                    slot_extruder = 1 - data.tray_id  # 0→1, 1→0
+                else:
+                    slot_extruder = state.ams_extruder_map.get(str(data.ams_id))
+
             matching_kp = None
             for kp in spool.k_profiles:
                 if kp.printer_id == data.printer_id and kp.nozzle_diameter == nozzle_diameter:
+                    if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder:
+                        continue
                     matching_kp = kp
                     break
 
@@ -735,7 +760,6 @@ async def assign_spool(
                     cali_idx=matching_kp.cali_idx,
                     filament_id=tray_info_idx,
                     nozzle_diameter=nozzle_diameter,
-                    setting_id=matching_kp.setting_id,
                 )
 
             configured = True

+ 3 - 0
backend/app/api/routes/kprofiles.py

@@ -278,6 +278,9 @@ async def delete_kprofile(
     if not success:
         raise HTTPException(500, "Failed to send K-profile delete command")
 
+    # Wait for printer to process the delete before frontend refetches
+    await asyncio.sleep(0.5)
+
     return {"success": True, "message": "K-profile deleted successfully"}
 
 

+ 216 - 56
backend/app/api/routes/printers.py

@@ -237,7 +237,7 @@ async def get_printer_status(
 
     # Parse AMS data from raw_data
     ams_units = []
-    vt_tray = None
+    vt_tray = []
     ams_exists = False
     raw_data = state.raw_data or {}
 
@@ -319,38 +319,41 @@ async def get_printer_status(
                 )
             )
 
-    # Virtual tray (external spool holder) - comes from vt_tray in raw_data
+    # Virtual tray (external spool holder) - comes from vt_tray in raw_data (list)
     if "vt_tray" in raw_data:
-        vt_data = raw_data["vt_tray"]
-        # Filter out empty/invalid tag values for vt_tray
-        vt_tag_uid = vt_data.get("tag_uid", "")
-        if vt_tag_uid in ("", "0000000000000000"):
-            vt_tag_uid = None
-        vt_tray_uuid = vt_data.get("tray_uuid", "")
-        if vt_tray_uuid in ("", "00000000000000000000000000000000"):
-            vt_tray_uuid = None
-
-        # Get K value: first try tray's k field, then lookup from K-profiles
-        vt_k_value = vt_data.get("k")
-        vt_cali_idx = vt_data.get("cali_idx")
-        if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
-            vt_k_value = kprofile_map[vt_cali_idx]
-
-        vt_tray = AMSTray(
-            id=254,  # Virtual tray ID
-            tray_color=vt_data.get("tray_color"),
-            tray_type=vt_data.get("tray_type"),
-            tray_sub_brands=vt_data.get("tray_sub_brands"),
-            tray_id_name=vt_data.get("tray_id_name"),
-            tray_info_idx=vt_data.get("tray_info_idx"),
-            remain=vt_data.get("remain", 0),
-            k=vt_k_value,
-            cali_idx=vt_cali_idx,
-            tag_uid=vt_tag_uid,
-            tray_uuid=vt_tray_uuid,
-            nozzle_temp_min=vt_data.get("nozzle_temp_min"),
-            nozzle_temp_max=vt_data.get("nozzle_temp_max"),
-        )
+        for vt_data in raw_data["vt_tray"]:
+            # Filter out empty/invalid tag values for vt_tray
+            vt_tag_uid = vt_data.get("tag_uid", "")
+            if vt_tag_uid in ("", "0000000000000000"):
+                vt_tag_uid = None
+            vt_tray_uuid = vt_data.get("tray_uuid", "")
+            if vt_tray_uuid in ("", "00000000000000000000000000000000"):
+                vt_tray_uuid = None
+
+            # Get K value: first try tray's k field, then lookup from K-profiles
+            vt_k_value = vt_data.get("k")
+            vt_cali_idx = vt_data.get("cali_idx")
+            if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
+                vt_k_value = kprofile_map[vt_cali_idx]
+
+            tray_id = int(vt_data.get("id", 254))
+            vt_tray.append(
+                AMSTray(
+                    id=tray_id,
+                    tray_color=vt_data.get("tray_color"),
+                    tray_type=vt_data.get("tray_type"),
+                    tray_sub_brands=vt_data.get("tray_sub_brands"),
+                    tray_id_name=vt_data.get("tray_id_name"),
+                    tray_info_idx=vt_data.get("tray_info_idx"),
+                    remain=vt_data.get("remain", 0),
+                    k=vt_k_value,
+                    cali_idx=vt_cali_idx,
+                    tag_uid=vt_tag_uid,
+                    tray_uuid=vt_tray_uuid,
+                    nozzle_temp_min=vt_data.get("nozzle_temp_min"),
+                    nozzle_temp_max=vt_data.get("nozzle_temp_max"),
+                )
+            )
 
     # Convert nozzle info to response format
     nozzles = [
@@ -1637,40 +1640,72 @@ async def configure_ams_slot(
     if not client:
         raise HTTPException(status_code=400, detail="Printer not connected")
 
-    # Send the filament setting command (type, color, temp)
-    success = client.ams_set_filament_setting(
-        ams_id=ams_id,
-        tray_id=tray_id,
-        tray_info_idx=tray_info_idx,
-        tray_type=tray_type,
-        tray_sub_brands=tray_sub_brands,
-        tray_color=tray_color,
-        nozzle_temp_min=nozzle_temp_min,
-        nozzle_temp_max=nozzle_temp_max,
-        setting_id=setting_id,
-    )
+    # Detect RFID spool before sending commands
+    is_rfid_spool = False
+    state = printer_manager.get_status(printer_id)
+    if state and state.raw_data:
+        from backend.app.api.routes.inventory import _find_tray_in_ams_data
+        from backend.app.services.spool_tag_matcher import is_valid_tag
 
-    if not success:
-        raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
+        ams_data = state.raw_data.get("ams", {})
+        ams_list = (
+            ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
+        )
+        current_tray = _find_tray_in_ams_data(ams_list, ams_id, tray_id)
+        if current_tray:
+            is_rfid_spool = is_valid_tag(
+                current_tray.get("tag_uid", ""),
+                current_tray.get("tray_uuid", ""),
+            )
 
-    # Send the calibration/K-profile commands
-    # Use the K profile's filament_id if provided, otherwise use tray_info_idx
+    # Send filament setting + K-profile commands
     filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else tray_info_idx
 
+    if is_rfid_spool:
+        # RFID spool: skip ams_set_filament_setting to preserve RFID state (eye icon).
+        # The firmware already has filament config from the RFID tag.
+        logger.info("[configure_ams_slot] RFID spool detected — skipping ams_set_filament_setting")
+    else:
+        # Non-RFID spool: send filament setting (type, color, temp)
+        # When a K-profile is selected, use the K-profile's filament_id as
+        # tray_info_idx so BambuStudio queries the right PA history table.
+        # But always use the PRESET's setting_id (not the K-profile's) —
+        # BambuStudio uses setting_id to identify the filament preset and
+        # overriding it with the K-profile's setting_id confuses the slicer.
+        effective_tray_info_idx = filament_id_for_kprofile if cali_idx >= 0 else tray_info_idx
+        success = client.ams_set_filament_setting(
+            ams_id=ams_id,
+            tray_id=tray_id,
+            tray_info_idx=effective_tray_info_idx,
+            tray_type=tray_type,
+            tray_sub_brands=tray_sub_brands,
+            tray_color=tray_color,
+            nozzle_temp_min=nozzle_temp_min,
+            nozzle_temp_max=nozzle_temp_max,
+            setting_id=setting_id,
+        )
+
+        if not success:
+            raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
+
     # Method 1: Select existing calibration profile by cali_idx
-    # IMPORTANT: Only pass setting_id if the K profile itself has one (from kprofile_setting_id)
-    # Do NOT use the preset's setting_id as fallback - it breaks the K profile linking in the slicer
+    # Do NOT include setting_id — BambuStudio never sends it in extrusion_cali_sel,
+    # and including it causes the firmware to mislink the profile on X1C/P1S.
     client.extrusion_cali_sel(
         ams_id=ams_id,
         tray_id=tray_id,
         cali_idx=cali_idx,
         filament_id=filament_id_for_kprofile,
         nozzle_diameter=nozzle_diameter,
-        setting_id=kprofile_setting_id if kprofile_setting_id else None,
     )
 
-    # Method 2: Also directly set the K value if provided (for better compatibility)
-    if k_value > 0:
+    # Method 2: Only send extrusion_cali_set when NO existing profile was selected
+    # (cali_idx == -1). When cali_idx >= 0, extrusion_cali_sel already selected the
+    # correct profile. Sending extrusion_cali_set with the same cali_idx would MODIFY
+    # the existing profile's metadata (extruder_id, nozzle_id, name, setting_id),
+    # corrupting it — e.g., overwriting a High Flow extruder 1 profile with
+    # hardcoded extruder_id=0 and nozzle_id=HS00.
+    if k_value > 0 and cali_idx < 0:
         # Calculate global tray ID for extrusion_cali_set
         if ams_id <= 3:
             global_tray_id = ams_id * 4 + tray_id
@@ -1682,11 +1717,12 @@ async def configure_ams_slot(
         client.extrusion_cali_set(
             tray_id=global_tray_id,
             k_value=k_value,
-            n_coef=0.0,
             nozzle_diameter=nozzle_diameter,
-            bed_temp=60,
             nozzle_temp=nozzle_temp_max,
-            max_volumetric_speed=20.0,
+            filament_id=filament_id_for_kprofile,
+            setting_id=kprofile_setting_id or "",
+            name=tray_sub_brands or "",
+            cali_idx=cali_idx,
         )
 
     # Request fresh status push from printer so frontend gets updated data via WebSocket
@@ -2109,9 +2145,133 @@ async def refresh_ams_slot(
     if not success:
         raise HTTPException(400, message)
 
+    # Apply PA profile after delay (RFID re-read takes a few seconds)
+    asyncio.create_task(_apply_pa_after_refresh(printer_id, ams_id, slot_id))
+
     return {"success": True, "message": message}
 
 
+async def _apply_pa_after_refresh(printer_id: int, ams_id: int, slot_id: int):
+    """Apply PA profile after RFID re-read completes.
+
+    Waits for the printer to finish processing the RFID data, then selects
+    the K-profile via extrusion_cali_sel.  Does NOT re-send ams_set_filament_setting
+    because that would overwrite the RFID-provided filament data.
+    """
+    await asyncio.sleep(5)
+    try:
+        from backend.app.api.routes.inventory import _find_tray_in_ams_data
+        from backend.app.core.database import async_session
+        from backend.app.models.spool import Spool
+        from backend.app.models.spool_assignment import SpoolAssignment as SA
+        from backend.app.services.spool_tag_matcher import is_bambu_tag
+
+        client = printer_manager.get_client(printer_id)
+        if not client:
+            return
+
+        state = printer_manager.get_status(printer_id)
+        if not state or not state.raw_data:
+            return
+
+        # Find current tray data (should have RFID data by now)
+        ams_data = state.raw_data.get("ams", {})
+        ams_list = (
+            ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
+        )
+        tray = _find_tray_in_ams_data(ams_list, ams_id, slot_id)
+        if not tray or not tray.get("tray_type"):
+            logger.debug("PA re-apply: no tray data for AMS%d-T%d", ams_id, slot_id)
+            return
+
+        tag_uid = tray.get("tag_uid", "")
+        tray_uuid = tray.get("tray_uuid", "")
+        tray_info_idx = tray.get("tray_info_idx", "")
+        if not is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
+            return
+
+        async with async_session() as db:
+            from sqlalchemy import select as sa_select
+            from sqlalchemy.orm import selectinload
+
+            result = await db.execute(
+                sa_select(SA)
+                .options(selectinload(SA.spool).selectinload(Spool.k_profiles))
+                .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == slot_id)
+            )
+            assignment = result.scalar_one_or_none()
+            if not assignment or not assignment.spool or not assignment.spool.k_profiles:
+                return
+
+            spool = assignment.spool
+            nozzle_diameter = "0.4"
+            if state.nozzles:
+                nd = state.nozzles[0].nozzle_diameter
+                if nd:
+                    nozzle_diameter = nd
+
+            # Determine slot's extruder from ams_extruder_map
+            slot_extruder = None
+            if state.ams_extruder_map:
+                if ams_id == 255:
+                    # External slots: ext-L (tray 0) → extruder 1, ext-R (tray 1) → extruder 0
+                    slot_extruder = 1 - slot_id  # 0→1, 1→0
+                else:
+                    slot_extruder = state.ams_extruder_map.get(str(ams_id))
+
+            matching_kp = None
+            for kp in spool.k_profiles:
+                if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:
+                    if slot_extruder is not None and kp.extruder_id is not None and kp.extruder_id != slot_extruder:
+                        continue
+                    matching_kp = kp
+                    break
+
+            if not matching_kp or matching_kp.cali_idx is None:
+                return
+
+            # The filament_id in extrusion_cali_sel must match the filament preset
+            # under which the K-profile was calibrated. Use spool.slicer_filament
+            # (the preset assigned in inventory), falling back to tray's RFID value.
+            kp_filament_id = spool.slicer_filament or tray_info_idx
+
+            logger.info(
+                "PA re-apply AMS%d-T%d: cali_idx=%d, filament_id=%s",
+                ams_id,
+                slot_id,
+                matching_kp.cali_idx,
+                kp_filament_id,
+            )
+
+            # 1. Select K-profile
+            # NOTE: Do NOT send ams_set_filament_setting here — it tells the firmware
+            # "this is a manual config" which destroys the RFID-detected spool state
+            # (changes eye icon to pen icon in slicer).
+            client.extrusion_cali_sel(
+                ams_id=ams_id,
+                tray_id=slot_id,
+                cali_idx=matching_kp.cali_idx,
+                filament_id=kp_filament_id,
+                nozzle_diameter=nozzle_diameter,
+            )
+
+            # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
+            # selected the correct profile by cali_idx. Sending extrusion_cali_set with
+            # the same cali_idx would MODIFY the existing profile's metadata (extruder_id,
+            # nozzle_id, name), corrupting it.
+
+            logger.info(
+                "Applied PA profile cali_idx=%d k=%.3f to printer %d AMS%d-T%d",
+                matching_kp.cali_idx,
+                matching_kp.k_value or 0,
+                printer_id,
+                ams_id,
+                slot_id,
+            )
+    except Exception as e:
+        logger.warning("Failed to apply PA profile after RFID re-read: %s", e)
+
+
 @router.get("/{printer_id}/runtime-debug")
 async def get_runtime_debug(
     printer_id: int,

+ 1 - 1
backend/app/api/routes/support.py

@@ -473,7 +473,7 @@ async def _collect_support_info() -> dict:
                 for unit in ams_units:
                     trays = unit.get("tray", [])
                     ams_tray_count += len([t for t in trays if t.get("tray_type")])
-                has_vt_tray = state.raw_data.get("vt_tray") is not None
+                has_vt_tray = bool(state.raw_data.get("vt_tray"))
 
             info["printers"].append(
                 {

+ 4 - 1
backend/app/main.py

@@ -335,12 +335,14 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     bed_target = round(temps.get("bed_target", 0))
     nozzle_target = round(temps.get("nozzle_target", 0))
 
+    # Include tray_now and vt_tray hash so external spool changes trigger broadcasts
+    vt_tray_key = hash(str(state.raw_data.get("vt_tray", []))) if state.raw_data else 0
     status_key = (
         f"{state.connected}:{state.state}:{state.progress}:{state.layer_num}:"
         f"{nozzle_temp}:{bed_temp}:{nozzle_2_temp}:{chamber_temp}:"
         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}"
+        f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}"
     )
 
     # MQTT relay - publish status (before dedup check - always publish to MQTT)
@@ -707,6 +709,7 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                 spool,
                                 printer_manager,
                                 db,
+                                tray_info_idx=tray_info_idx,
                             )
                             await db.commit()
                             await ws_manager.broadcast(

+ 1 - 1
backend/app/schemas/printer.py

@@ -199,7 +199,7 @@ class PrinterStatus(BaseModel):
     hms_errors: list[HMSErrorResponse] = []
     ams: list[AMSUnit] = []
     ams_exists: bool = False
-    vt_tray: AMSTray | None = None  # Virtual tray / external spool
+    vt_tray: list[AMSTray] = []  # Virtual tray / external spool(s)
     sdcard: bool = False  # SD card inserted
     store_to_sdcard: bool = False  # Store sent files on SD card
     timelapse: bool = False  # Timelapse recording active

+ 151 - 57
backend/app/services/bambu_mqtt.py

@@ -128,7 +128,7 @@ class PrinterState:
     chamber_light: bool = False
     # Active extruder for dual nozzle (0=right, 1=left) - from device.extruder.info[X].hnow
     active_extruder: int = 0
-    # Currently loaded tray (global ID): 254 = external spool, 255 = no filament
+    # Currently loaded tray (global ID): 254/255 = external spools, 255 = no filament on legacy printers
     tray_now: int = 255
     # Pending load target - used to track what tray we're loading for H2D disambiguation
     pending_tray_target: int | None = None
@@ -451,14 +451,33 @@ class BambuMQTTClient:
                 except Exception as e:
                     logger.error("[%s] Error handling AMS data from print: %s", self.serial_number, e)
 
+            # Handle vir_slot (H2-series external spool data) — list of external trays
+            # Process vir_slot FIRST so it takes priority over vt_tray
+            if "vir_slot" in print_data:
+                vir_slot = print_data["vir_slot"]
+                if isinstance(vir_slot, list) and vir_slot:
+                    # Fix: single-nozzle printers (X1C, P1S, A1) report their single
+                    # external slot with id=255 in vir_slot, but tray_now=254 when active.
+                    # Remap id=255→254 for single-slot printers so active detection works.
+                    # Dual-nozzle (H2D) has 2 slots: id=254 (Ext-L) and id=255 (Ext-R).
+                    if len(vir_slot) == 1 and str(vir_slot[0].get("id", "")) == "255":
+                        vir_slot[0]["id"] = "254"
+                    self.state.raw_data["vt_tray"] = vir_slot
+
             # Handle vt_tray (virtual tray / external spool) data
-            if "vt_tray" in print_data:
+            # Only use vt_tray if vir_slot is NOT in this message AND we don't already
+            # have vir_slot data (H2-series sends vt_tray as a single active spool dict
+            # which would overwrite the correct multi-slot vir_slot data)
+            if "vt_tray" in print_data and "vir_slot" not in print_data:
                 vt_tray = print_data["vt_tray"]
-                self.state.raw_data["vt_tray"] = vt_tray
-                # Log vt_tray to investigate per-extruder data for H2D
-                if not hasattr(self, "_vt_tray_logged") or not self._vt_tray_logged:
-                    logger.info("[%s] vt_tray data: %s", self.serial_number, vt_tray)
-                    self._vt_tray_logged = True
+                existing = self.state.raw_data.get("vt_tray")
+                # Don't let a single-spool vt_tray dict overwrite multi-slot vir_slot data
+                if isinstance(vt_tray, dict) and isinstance(existing, list) and len(existing) > 1:
+                    pass  # Keep the vir_slot data
+                else:
+                    if isinstance(vt_tray, dict):
+                        vt_tray = [vt_tray]
+                    self.state.raw_data["vt_tray"] = vt_tray
 
             # Parse ams_status directly from print data (NOT from print.ams)
             # ams_status is a combined value: lower 8 bits = sub status, bits 8-15 = main status
@@ -486,7 +505,10 @@ class BambuMQTTClient:
 
             # Check for K-profile response (extrusion_cali)
             if "command" in print_data:
-                logger.debug("[%s] Received command response: %s", self.serial_number, print_data.get("command"))
+                cmd = print_data.get("command")
+                logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
+                if cmd in ("extrusion_cali_sel", "extrusion_cali_set", "extrusion_cali_del", "ams_filament_setting"):
+                    logger.info("[%s] %s response: %s", self.serial_number, cmd, print_data)
             if "command" in print_data and print_data.get("command") == "extrusion_cali_get":
                 self._handle_kprofile_response(print_data)
 
@@ -941,13 +963,20 @@ class BambuMQTTClient:
                         if tray_id is not None and tray_id in existing_trays:
                             # Merge: start with existing, update with new non-empty values
                             merged_tray = existing_trays[tray_id].copy()
+                            # Detect slot-clearing updates (spool removal):
+                            # When tray_type is explicitly empty, clear everything
+                            # including RFID data (tag_uid/tray_uuid).
+                            slot_clearing = new_tray.get("tray_type") == ""
                             for key, value in new_tray.items():
                                 # Fields that should always be updated (even with empty/zero values):
                                 # - remain, k, id, cali_idx: status indicators where 0 is valid
-                                # - tray_type, tray_sub_brands, tag_uid, tray_uuid, tray_info_idx,
-                                #   tray_color, tray_id_name: slot content indicators that must
-                                #   be cleared when a spool is removed (fixes #147 - old AMS
-                                #   empty slot)
+                                # - tray_type, tray_sub_brands, tray_info_idx, tray_color,
+                                #   tray_id_name: slot content indicators that must be cleared
+                                #   when a spool is removed (fixes #147 - old AMS empty slot)
+                                # NOTE: tag_uid and tray_uuid are NOT in always_update_fields.
+                                # They are only cleared during spool removal (slot_clearing=True).
+                                # Periodic AMS updates often include empty RFID fields which
+                                # would overwrite valid data from the initial pushall.
                                 always_update_fields = (
                                     "remain",
                                     "k",
@@ -955,17 +984,20 @@ class BambuMQTTClient:
                                     "cali_idx",
                                     "tray_type",
                                     "tray_sub_brands",
-                                    "tag_uid",
-                                    "tray_uuid",
                                     "tray_info_idx",
                                     "tray_color",
                                     "tray_id_name",
                                 )
-                                if key in always_update_fields or value not in (
-                                    None,
-                                    "",
-                                    "0000000000000000",
-                                    "00000000000000000000000000000000",
+                                if (
+                                    key in always_update_fields
+                                    or slot_clearing
+                                    or value
+                                    not in (
+                                        None,
+                                        "",
+                                        "0000000000000000",
+                                        "00000000000000000000000000000000",
+                                    )
                                 ):
                                     merged_tray[key] = value
                             merged_trays.append(merged_tray)
@@ -2424,7 +2456,7 @@ class BambuMQTTClient:
     def _handle_kprofile_response(self, data: dict):
         """Handle K-profile response from printer."""
         response_nozzle = data.get("nozzle_diameter")
-        _response_seq_id = data.get("sequence_id", "?")
+        response_seq_id = data.get("sequence_id", "?")
         filaments = data.get("filaments", [])
         expected_nozzle = getattr(self, "_expected_kprofile_nozzle", None)
         has_pending_request = self._pending_kprofile_response is not None
@@ -2432,7 +2464,8 @@ class BambuMQTTClient:
         # Log all incoming responses when we have a pending request (for debugging)
         if has_pending_request:
             logger.info(
-                f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, {len(filaments)} profiles, expected={expected_nozzle}"
+                f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, "
+                f"seq_id={response_seq_id}, {len(filaments)} profiles, expected={expected_nozzle}"
             )
 
         # If we have a pending request, only accept responses with matching nozzle_diameter
@@ -3305,6 +3338,7 @@ class BambuMQTTClient:
         command = {"print": {"command": "ams_get_rfid", "ams_id": ams_id, "slot_id": tray_id, "sequence_id": "0"}}
         self._client.publish(self.topic_publish, json.dumps(command), qos=1)
         logger.info("[%s] Triggering RFID re-read: AMS %s, slot %s", self.serial_number, ams_id, tray_id)
+
         return True, f"Refreshing AMS {ams_id} tray {tray_id}"
 
     def ams_set_filament_setting(
@@ -3341,18 +3375,33 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot set AMS filament setting: not connected", self.serial_number)
             return False
 
-        # Calculate slot_id based on AMS type
-        if ams_id <= 3:
+        # Calculate mqtt IDs based on AMS type
+        if ams_id == 255:
+            vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
+            if len(vt_tray) > 1:
+                # Dual external slots (H2D): each ext slot is its own virtual AMS unit
+                # (254=ext-L / slot 0, 255=ext-R / slot 1)
+                mqtt_ams_id = 254 + tray_id
+            else:
+                # Single external slot (X1C, P1S, A1): always ams_id=255
+                mqtt_ams_id = 255
+            mqtt_tray_id = 0
+            slot_id = 0
+        elif ams_id <= 3:
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = tray_id
             slot_id = tray_id
         else:
-            # AMS-HT or external: slot_id = 0
+            # AMS-HT: single tray per unit
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = tray_id
             slot_id = 0
 
         command = {
             "print": {
                 "command": "ams_filament_setting",
-                "ams_id": ams_id,
-                "tray_id": tray_id,
+                "ams_id": mqtt_ams_id,
+                "tray_id": mqtt_tray_id,
                 "slot_id": slot_id,
                 "tray_info_idx": tray_info_idx,
                 "tray_type": tray_type,
@@ -3390,17 +3439,32 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot reset AMS slot: not connected", self.serial_number)
             return False
 
-        # Calculate slot_id based on AMS type
-        if ams_id <= 3:
+        # Calculate mqtt IDs based on AMS type
+        if ams_id == 255:
+            vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
+            if len(vt_tray) > 1:
+                # Dual external slots (H2D): each ext slot is its own virtual AMS unit
+                mqtt_ams_id = 254 + tray_id
+            else:
+                # Single external slot (X1C, P1S, A1): always ams_id=255
+                mqtt_ams_id = 255
+            mqtt_tray_id = 0
+            slot_id = 0
+        elif ams_id <= 3:
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = tray_id
             slot_id = tray_id
         else:
+            # AMS-HT: single tray per unit
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = tray_id
             slot_id = 0
 
         command = {
             "print": {
                 "command": "ams_filament_setting",
-                "ams_id": ams_id,
-                "tray_id": tray_id,
+                "ams_id": mqtt_ams_id,
+                "tray_id": mqtt_tray_id,
                 "slot_id": slot_id,
                 "tray_info_idx": "",
                 "tray_type": "",
@@ -3425,20 +3489,21 @@ class BambuMQTTClient:
         cali_idx: int,
         filament_id: str,
         nozzle_diameter: str = "0.4",
-        setting_id: str | None = None,
     ) -> bool:
         """Set calibration profile (K value) for an AMS slot.
 
         This command selects a K profile from the printer's calibration list.
         Use cali_idx=-1 to use the default K value (0.020).
 
+        Note: Do NOT send setting_id in this command — BambuStudio never includes
+        it, and adding it causes the firmware to mislink the profile on X1C/P1S.
+
         Args:
             ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
             tray_id: Tray ID within the AMS (0-3)
             cali_idx: Calibration profile index (-1 for default)
             filament_id: Filament preset ID (same as tray_info_idx)
             nozzle_diameter: Nozzle diameter string (e.g., "0.4")
-            setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
 
         Returns:
             True if command was sent, False otherwise
@@ -3447,13 +3512,34 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot set calibration: not connected", self.serial_number)
             return False
 
-        # Calculate slot_id based on AMS type
-        # tray_id in the command should be the local tray index (0-3)
-        if ams_id <= 3:
+        # Calculate mqtt IDs based on AMS type.
+        # IMPORTANT: extrusion_cali_sel uses GLOBAL tray_id (unlike ams_filament_setting
+        # which uses LOCAL).  BambuStudio confirms: tray_id = ams_id * 4 + slot.
+        if ams_id == 255:
+            # External spool: extrusion_cali_sel uses GLOBAL tray_id (unlike
+            # ams_filament_setting which uses LOCAL tray_id=0).
+            vt_tray = self.state.raw_data.get("vt_tray", []) if self.state.raw_data else []
+            if len(vt_tray) > 1:
+                # Dual external slots (H2D): each ext slot is its own virtual AMS unit
+                # Confirmed from BambuStudio logs: ext-R sends ams_id=255, tray_id=255
+                mqtt_ams_id = 254 + tray_id
+                mqtt_tray_id = 254 + tray_id
+            else:
+                # Single external slot (X1C, P1S, A1): global tray_id=254
+                mqtt_ams_id = 254
+                mqtt_tray_id = 254
+            slot_id = 0
+        elif ams_id <= 3:
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = ams_id * 4 + tray_id
             slot_id = tray_id
         elif ams_id >= 128 and ams_id <= 135:
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = tray_id
             slot_id = 0
         else:
+            mqtt_ams_id = ams_id
+            mqtt_tray_id = tray_id
             slot_id = 0
 
         command = {
@@ -3462,20 +3548,16 @@ class BambuMQTTClient:
                 "cali_idx": cali_idx,
                 "filament_id": filament_id,
                 "nozzle_diameter": nozzle_diameter,
-                "ams_id": ams_id,
-                "tray_id": tray_id,  # Local tray index (0-3), not global
+                "ams_id": mqtt_ams_id,
+                "tray_id": mqtt_tray_id,
                 "slot_id": slot_id,
                 "sequence_id": "0",
             }
         }
 
-        # Include setting_id if provided (helps slicer show correct K profile)
-        if setting_id:
-            command["print"]["setting_id"] = setting_id
-
         command_json = json.dumps(command)
         logger.info(
-            f"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}, setting_id={setting_id}"
+            f"[{self.serial_number}] Publishing extrusion_cali_sel: AMS {ams_id}, tray {tray_id}, cali_idx={cali_idx}"
         )
         logger.debug("[%s] extrusion_cali_sel command: %s", self.serial_number, command_json)
         self._client.publish(self.topic_publish, command_json, qos=1)
@@ -3485,25 +3567,26 @@ class BambuMQTTClient:
         self,
         tray_id: int,
         k_value: float,
-        n_coef: float = 0.0,
         nozzle_diameter: str = "0.4",
-        bed_temp: int = 60,
         nozzle_temp: int = 220,
-        max_volumetric_speed: float = 20.0,
+        filament_id: str = "",
+        setting_id: str = "",
+        name: str = "",
+        cali_idx: int = -1,
     ) -> bool:
         """Directly set K value (pressure advance) for a tray.
 
-        This command sets the K value directly without selecting from stored profiles.
-        Use this when you want to apply a specific K value to a tray.
+        Uses the filaments array format required by current firmware.
 
         Args:
             tray_id: Global tray ID (ams_id * 4 + slot)
             k_value: Pressure advance K value (e.g., 0.020)
-            n_coef: N coefficient (usually 0.0 for manual, 1.4 for auto-calibration)
             nozzle_diameter: Nozzle diameter string (e.g., "0.4")
-            bed_temp: Bed temperature for calibration reference
             nozzle_temp: Nozzle temperature for calibration reference
-            max_volumetric_speed: Max volumetric speed for calibration reference
+            filament_id: Filament preset ID (e.g., "GFA02")
+            setting_id: Setting ID (e.g., "GFSA02_07")
+            name: Profile display name
+            cali_idx: Calibration index (-1 for new)
 
         Returns:
             True if command was sent, False otherwise
@@ -3512,17 +3595,28 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
             return False
 
+        nozzle_id = f"HS00-{nozzle_diameter}"
+
+        filament_entry = {
+            "ams_id": 0,
+            "cali_idx": cali_idx,
+            "extruder_id": 0,
+            "filament_id": filament_id,
+            "k_value": f"{k_value:.6f}",
+            "n_coef": "1.400000",
+            "name": name,
+            "nozzle_diameter": nozzle_diameter,
+            "nozzle_id": nozzle_id,
+            "setting_id": setting_id,
+            "tray_id": tray_id,
+        }
+
         command = {
             "print": {
                 "command": "extrusion_cali_set",
-                "tray_id": tray_id,
-                "k_value": k_value,
-                "n_coef": n_coef,
+                "filaments": [filament_entry],
                 "nozzle_diameter": nozzle_diameter,
-                "bed_temp": bed_temp,
-                "nozzle_temp": nozzle_temp,
-                "max_volumetric_speed": max_volumetric_speed,
-                "sequence_id": "0",
+                "sequence_id": str(self._sequence_id),
             }
         }
 

+ 21 - 21
backend/app/services/print_scheduler.py

@@ -333,10 +333,9 @@ class PrintScheduler:
                     if tray_type:
                         loaded_types.add(tray_type.upper())
 
-        # Check external spool (virtual tray, stored in raw_data["vt_tray"])
-        vt_tray = status.raw_data.get("vt_tray")
-        if vt_tray:
-            vt_type = vt_tray.get("tray_type")
+        # Check external spool(s) (virtual tray, stored in raw_data["vt_tray"] as list)
+        for vt in status.raw_data.get("vt_tray") or []:
+            vt_type = vt.get("tray_type")
             if vt_type:
                 loaded_types.add(vt_type.upper())
 
@@ -538,23 +537,24 @@ class PrintScheduler:
                         }
                     )
 
-        # Check external spool (vt_tray)
-        vt_tray = status.raw_data.get("vt_tray")
-        if vt_tray and vt_tray.get("tray_type"):
-            color = self._normalize_color(vt_tray.get("tray_color", ""))
-            filaments.append(
-                {
-                    "type": vt_tray["tray_type"],
-                    "color": color,
-                    "tray_info_idx": vt_tray.get("tray_info_idx", ""),
-                    "ams_id": -1,
-                    "tray_id": 0,
-                    "is_ht": False,
-                    "is_external": True,
-                    "global_tray_id": 254,
-                    "extruder_id": 0 if ams_extruder_map else None,
-                }
-            )
+        # Check external spool(s) (vt_tray is a list)
+        for idx, vt in enumerate(status.raw_data.get("vt_tray") or []):
+            if vt.get("tray_type"):
+                color = self._normalize_color(vt.get("tray_color", ""))
+                tray_id = int(vt.get("id", 254))
+                filaments.append(
+                    {
+                        "type": vt["tray_type"],
+                        "color": color,
+                        "tray_info_idx": vt.get("tray_info_idx", ""),
+                        "ams_id": -1,
+                        "tray_id": idx,
+                        "is_ht": False,
+                        "is_external": True,
+                        "global_tray_id": tray_id,
+                        "extruder_id": (tray_id - 254) if ams_extruder_map else None,
+                    }
+                )
 
         return filaments
 

+ 34 - 31
backend/app/services/printer_manager.py

@@ -502,7 +502,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
     """
     # Parse AMS data from raw_data
     ams_units = []
-    vt_tray = None
+    vt_tray = []
     raw_data = state.raw_data or {}
 
     # Build K-profile lookup map: cali_idx -> k_value
@@ -578,37 +578,40 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                 }
             )
 
-    # Parse virtual tray (external spool)
+    # Parse virtual tray (external spool) — now a list
     if "vt_tray" in raw_data:
-        vt_data = raw_data["vt_tray"]
-        vt_tag_uid = vt_data.get("tag_uid")
-        if vt_tag_uid in ("", "0000000000000000"):
-            vt_tag_uid = None
-        vt_tray_uuid = vt_data.get("tray_uuid")
-        if vt_tray_uuid in ("", "00000000000000000000000000000000"):
-            vt_tray_uuid = None
-
-        # Get K value for vt_tray
-        vt_k_value = vt_data.get("k")
-        vt_cali_idx = vt_data.get("cali_idx")
-        if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
-            vt_k_value = kprofile_map[vt_cali_idx]
-
-        vt_tray = {
-            "id": 254,
-            "tray_color": vt_data.get("tray_color"),
-            "tray_type": vt_data.get("tray_type"),
-            "tray_sub_brands": vt_data.get("tray_sub_brands"),
-            "tray_id_name": vt_data.get("tray_id_name"),
-            "tray_info_idx": vt_data.get("tray_info_idx"),
-            "remain": vt_data.get("remain", 0),
-            "k": vt_k_value,
-            "cali_idx": vt_cali_idx,
-            "tag_uid": vt_tag_uid,
-            "tray_uuid": vt_tray_uuid,
-            "nozzle_temp_min": vt_data.get("nozzle_temp_min"),
-            "nozzle_temp_max": vt_data.get("nozzle_temp_max"),
-        }
+        for vt_data in raw_data["vt_tray"]:
+            vt_tag_uid = vt_data.get("tag_uid")
+            if vt_tag_uid in ("", "0000000000000000"):
+                vt_tag_uid = None
+            vt_tray_uuid = vt_data.get("tray_uuid")
+            if vt_tray_uuid in ("", "00000000000000000000000000000000"):
+                vt_tray_uuid = None
+
+            # Get K value for vt_tray
+            vt_k_value = vt_data.get("k")
+            vt_cali_idx = vt_data.get("cali_idx")
+            if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
+                vt_k_value = kprofile_map[vt_cali_idx]
+
+            tray_id = int(vt_data.get("id", 254))
+            vt_tray.append(
+                {
+                    "id": tray_id,
+                    "tray_color": vt_data.get("tray_color"),
+                    "tray_type": vt_data.get("tray_type"),
+                    "tray_sub_brands": vt_data.get("tray_sub_brands"),
+                    "tray_id_name": vt_data.get("tray_id_name"),
+                    "tray_info_idx": vt_data.get("tray_info_idx"),
+                    "remain": vt_data.get("remain", 0),
+                    "k": vt_k_value,
+                    "cali_idx": vt_cali_idx,
+                    "tag_uid": vt_tag_uid,
+                    "tray_uuid": vt_tray_uuid,
+                    "nozzle_temp_min": vt_data.get("nozzle_temp_min"),
+                    "nozzle_temp_max": vt_data.get("nozzle_temp_max"),
+                }
+            )
 
     # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)
     ams_extruder_map = raw_data.get("ams_extruder_map", {})

+ 34 - 34
backend/app/services/spool_tag_matcher.py

@@ -196,16 +196,19 @@ async def auto_assign_spool(
     spool: Spool,
     printer_manager,
     db: AsyncSession,
+    tray_info_idx: str = "",
 ) -> SpoolAssignment:
     """Create a SpoolAssignment and auto-configure the AMS slot via MQTT.
 
-    Reuses the same MQTT configuration logic as the manual assign endpoint.
+    For BL spools (RFID-detected), only K-profile commands are sent.
+    ams_set_filament_setting is NOT sent because the firmware already has
+    filament configuration from the RFID tag, and sending it would destroy
+    the RFID-detected state (eye → pen icon in BambuStudio).
     """
-    from backend.app.api.routes.inventory import MATERIAL_TEMPS
-
     # Get current tray state for fingerprint
     fingerprint_color = None
     fingerprint_type = None
+    tray = None
     state = printer_manager.get_status(printer_id)
     if state and state.raw_data:
         from backend.app.api.routes.inventory import _find_tray_in_ams_data
@@ -246,34 +249,14 @@ async def auto_assign_spool(
     db.add(assignment)
     await db.flush()
 
-    # Auto-configure AMS slot via MQTT
+    # Apply K-profile via MQTT (if available)
+    # NOTE: Do NOT send ams_set_filament_setting here. This function is only
+    # called for BL spools (RFID-detected). The firmware already has the filament
+    # configuration from the RFID tag. Sending ams_set_filament_setting would
+    # destroy the RFID-detected state (eye → pen icon in BambuStudio/OrcaSlicer).
     try:
         client = printer_manager.get_client(printer_id)
         if client:
-            tray_type = spool.material
-            tray_sub_brands = f"{spool.material} {spool.subtype}" if spool.subtype else spool.material
-            tray_color = spool.rgba or "FFFFFFFF"
-            tray_info_idx = spool.slicer_filament or ""
-            setting_id = ""
-
-            temp_min, temp_max = MATERIAL_TEMPS.get(spool.material.upper(), (200, 240))
-            if spool.nozzle_temp_min is not None:
-                temp_min = spool.nozzle_temp_min
-            if spool.nozzle_temp_max is not None:
-                temp_max = spool.nozzle_temp_max
-
-            client.ams_set_filament_setting(
-                ams_id=ams_id,
-                tray_id=tray_id,
-                tray_info_idx=tray_info_idx,
-                tray_type=tray_type,
-                tray_sub_brands=tray_sub_brands,
-                tray_color=tray_color,
-                nozzle_temp_min=temp_min,
-                nozzle_temp_max=temp_max,
-                setting_id=setting_id,
-            )
-
             # Apply K-profile if available
             nozzle_diameter = "0.4"
             if state and state.nozzles:
@@ -288,23 +271,40 @@ async def auto_assign_spool(
                     break
 
             if matching_kp and matching_kp.cali_idx is not None:
+                # The filament_id in extrusion_cali_sel must match the filament preset
+                # under which the K-profile was calibrated. Use spool.slicer_filament
+                # (the preset assigned in inventory), falling back to tray's RFID value.
+                cali_filament_id = spool.slicer_filament or tray_info_idx or ""
                 client.extrusion_cali_sel(
                     ams_id=ams_id,
                     tray_id=tray_id,
                     cali_idx=matching_kp.cali_idx,
-                    filament_id=tray_info_idx,
+                    filament_id=cali_filament_id,
                     nozzle_diameter=nozzle_diameter,
-                    setting_id=matching_kp.setting_id,
+                )
+
+                # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
+                # selected the correct profile by cali_idx. Sending extrusion_cali_set
+                # with the same cali_idx would MODIFY the existing profile's metadata
+                # (extruder_id, nozzle_id, name), corrupting it.
+
+                logger.info(
+                    "Applied K-profile cali_idx=%d for spool %d on printer %d AMS%d-T%d",
+                    matching_kp.cali_idx,
+                    spool.id,
+                    printer_id,
+                    ams_id,
+                    tray_id,
                 )
 
             logger.info(
-                "Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d (RFID match)",
-                ams_id,
-                tray_id,
+                "Auto-assigned spool %d to printer %d AMS%d-T%d (RFID match)",
                 spool.id,
                 printer_id,
+                ams_id,
+                tray_id,
             )
     except Exception as e:
-        logger.warning("MQTT auto-configure failed for spool %d (RFID match): %s", spool.id, e)
+        logger.warning("K-profile apply failed for spool %d (RFID match): %s", spool.id, e)
 
     return assignment

+ 9 - 8
backend/app/services/spoolman_tracking.py

@@ -66,14 +66,15 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
                 "tray_type": tray.get("tray_type", ""),
             }
 
-    # External spool (global_tray_id = 254)
-    vt_tray = raw_data.get("vt_tray")
-    if vt_tray and vt_tray.get("tray_type"):
-        lookup[254] = {
-            "tray_uuid": vt_tray.get("tray_uuid", ""),
-            "tag_uid": vt_tray.get("tag_uid", ""),
-            "tray_type": vt_tray.get("tray_type", ""),
-        }
+    # External spool(s) (vt_tray is a list, global_tray_id from each entry's "id")
+    for vt in raw_data.get("vt_tray") or []:
+        if vt.get("tray_type"):
+            tray_id = int(vt.get("id", 254))
+            lookup[tray_id] = {
+                "tray_uuid": vt.get("tray_uuid", ""),
+                "tag_uid": vt.get("tag_uid", ""),
+                "tray_type": vt.get("tray_type", ""),
+            }
 
     return lookup
 

+ 11 - 2
backend/app/services/usage_tracker.py

@@ -263,8 +263,13 @@ async def _track_from_3mf(
     tray_now_override: int | None = None
     if not slot_to_tray and len(nonzero_slots) == 1:
         state = printer_manager.get_status(printer_id)
-        if state and state.tray_now < 255:
+        if state and 0 <= state.tray_now <= 254:
             tray_now_override = state.tray_now
+        elif state and state.tray_now == 255:
+            # 255 = "no filament" on legacy printers, but valid 2nd external spool on H2-series
+            vt_tray = state.raw_data.get("vt_tray") or []
+            if any(int(vt.get("id", 0)) == 255 for vt in vt_tray if isinstance(vt, dict)):
+                tray_now_override = state.tray_now
 
     # Scale factor for partial prints (failed/aborted)
     if status == "completed":
@@ -322,7 +327,11 @@ async def _track_from_3mf(
                 if isinstance(mapped, int) and mapped >= 0:
                     global_tray_id = mapped
 
-        if global_tray_id >= 128:
+        if global_tray_id >= 254:
+            # External spool: ams_id=255 (sentinel), tray_id=slot index (0 or 1)
+            ams_id = 255
+            tray_id = global_tray_id - 254
+        elif global_tray_id >= 128:
             ams_id = global_tray_id
             tray_id = 0
         else:

+ 15 - 12
backend/tests/unit/services/test_printer_manager.py

@@ -734,23 +734,26 @@ class TestPrinterStateToDict:
         assert result["ams"][0]["tray"][0]["tag_uid"] is None
 
     def test_vt_tray_parsing(self, mock_state):
-        """Verify virtual tray is parsed correctly."""
+        """Verify virtual tray is parsed correctly as a list."""
         mock_state.raw_data = {
-            "vt_tray": {
-                "tray_color": "00FF00",
-                "tray_type": "PETG",
-                "tray_sub_brands": "Generic",
-                "remain": 60,
-                "tag_uid": "VT123",
-            }
+            "vt_tray": [
+                {
+                    "tray_color": "00FF00",
+                    "tray_type": "PETG",
+                    "tray_sub_brands": "Generic",
+                    "remain": 60,
+                    "tag_uid": "VT123",
+                }
+            ]
         }
 
         result = printer_state_to_dict(mock_state)
 
-        assert result["vt_tray"] is not None
-        assert result["vt_tray"]["id"] == 254
-        assert result["vt_tray"]["tray_color"] == "00FF00"
-        assert result["vt_tray"]["tray_type"] == "PETG"
+        assert isinstance(result["vt_tray"], list)
+        assert len(result["vt_tray"]) == 1
+        assert result["vt_tray"][0]["id"] == 254
+        assert result["vt_tray"][0]["tray_color"] == "00FF00"
+        assert result["vt_tray"][0]["tray_type"] == "PETG"
 
     def test_hms_errors_conversion(self, mock_state):
         """Verify HMS errors are converted correctly."""

+ 2 - 2
backend/tests/unit/services/test_spoolman_tracking.py

@@ -99,14 +99,14 @@ class TestBuildAmsTrayLookup:
     def test_external_spool(self):
         raw = {
             "ams": [],
-            "vt_tray": {"tray_uuid": "EXT", "tag_uid": "X", "tray_type": "TPU"},
+            "vt_tray": [{"tray_uuid": "EXT", "tag_uid": "X", "tray_type": "TPU"}],
         }
         lookup = build_ams_tray_lookup(raw)
         assert 254 in lookup
         assert lookup[254]["tray_type"] == "TPU"
 
     def test_empty_external_spool_skipped(self):
-        raw = {"ams": [], "vt_tray": {"tray_type": ""}}
+        raw = {"ams": [], "vt_tray": [{"tray_type": ""}]}
         lookup = build_ams_tray_lookup(raw)
         assert 254 not in lookup
 

+ 4 - 4
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -140,7 +140,7 @@ class TestBuildLoadedFilaments:
         """Should include external spool."""
 
         class MockStatus:
-            raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}}
+            raw_data = {"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF"}]}
 
         result = scheduler._build_loaded_filaments(MockStatus())
         assert len(result) == 1
@@ -466,7 +466,7 @@ class TestBuildLoadedFilamentsTrayInfoIdx:
         """Should extract tray_info_idx from external spool."""
 
         class MockStatus:
-            raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}}
+            raw_data = {"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF", "tray_info_idx": "P4d64437"}]}
 
         result = scheduler._build_loaded_filaments(MockStatus())
         assert len(result) == 1
@@ -624,7 +624,7 @@ class TestNozzleAwareMapping:
 
         class MockStatus:
             raw_data = {
-                "vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"},
+                "vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF"}],
                 "ams_extruder_map": {"0": 0},
             }
 
@@ -637,7 +637,7 @@ class TestNozzleAwareMapping:
         """External spool extruder_id should be None without ams_extruder_map."""
 
         class MockStatus:
-            raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}}
+            raw_data = {"vt_tray": [{"tray_type": "TPU", "tray_color": "0000FF"}]}
 
         result = scheduler._build_loaded_filaments(MockStatus())
         assert len(result) == 1

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

@@ -38,6 +38,7 @@ const mockPrinterStatus = {
   remaining_time: 0,
   filename: null,
   wifi_signal: -50,
+  vt_tray: [],
 };
 
 describe('AddPrinterModal Discovery', () => {

+ 77 - 0
frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx

@@ -18,6 +18,11 @@ vi.mock('../../api/client', () => ({
     saveSlotPreset: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
+    getLocalPresets: vi.fn(),
+    getBuiltinFilaments: vi.fn(),
+    searchColors: vi.fn(),
+    getColorCatalog: vi.fn(),
+    resetAmsSlot: vi.fn(),
   },
 }));
 
@@ -69,10 +74,17 @@ const defaultProps = {
 describe('ConfigureAmsSlotModal', () => {
   beforeEach(() => {
     vi.clearAllMocks();
+    // Mock scrollIntoView which is not available in jsdom
+    Element.prototype.scrollIntoView = vi.fn();
     (api.getCloudSettings as ReturnType<typeof vi.fn>).mockResolvedValue(mockCloudSettings);
     (api.getKProfiles as ReturnType<typeof vi.fn>).mockResolvedValue(mockKProfiles);
     (api.configureAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
     (api.saveSlotPreset as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
+    (api.getLocalPresets as ReturnType<typeof vi.fn>).mockResolvedValue({ filament: [] });
+    (api.getBuiltinFilaments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+    (api.searchColors as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+    (api.getColorCatalog as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+    (api.resetAmsSlot as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true, message: 'ok' });
   });
 
   it('renders nothing visible when closed', () => {
@@ -204,4 +216,69 @@ describe('ConfigureAmsSlotModal', () => {
     const configureButton = screen.getByRole('button', { name: /Configure Slot/i });
     expect(configureButton).toBeInTheDocument();
   });
+
+  it('filters presets by printer model', async () => {
+    // Render with printerModel="H2D"
+    render(<ConfigureAmsSlotModal {...defaultProps} printerModel="H2D" />);
+    // Wait for presets to load - the H2D preset should be visible
+    await waitFor(() => {
+      expect(screen.getByText(/Overture Matte PLA/)).toBeInTheDocument();
+    });
+    // The X1C preset should NOT be visible (filtered out by model)
+    expect(screen.queryByText(/Bambu PLA Basic @BBL X1C/)).not.toBeInTheDocument();
+  });
+
+  it('shows current preset even when it does not match model filter', async () => {
+    // Render with printerModel="H2D" but savedPresetId pointing to the X1C preset
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'GFSL05_09',  // X1C preset
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} printerModel="H2D" />);
+    await waitFor(() => {
+      // Both should be visible - H2D matches model, X1C is saved preset
+      // Use the full preset name to match the list item (not the "Filtering for" label)
+      expect(screen.getByText('Bambu PLA Basic @BBL X1C')).toBeInTheDocument();
+      expect(screen.getByText(/Overture Matte PLA/)).toBeInTheDocument();
+    });
+  });
+
+  it('pre-selects saved preset when opening configured slot', async () => {
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      savedPresetId: 'GFSL05_09',
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+    await waitFor(() => {
+      // The saved preset should have the selected style (green border)
+      // Use the full preset name to avoid matching the "Filtering for" label
+      const presetButton = screen.getByText('Bambu PLA Basic @BBL X1C').closest('button');
+      expect(presetButton).toHaveClass('bg-bambu-green/20');
+    });
+  });
+
+  it('pre-populates color from trayColor', async () => {
+    const slotInfo = {
+      ...defaultProps.slotInfo,
+      trayColor: 'FF0000FF',  // Red with alpha
+    };
+    render(<ConfigureAmsSlotModal {...defaultProps} slotInfo={slotInfo} />);
+    await waitFor(() => {
+      expect(screen.getByTitle('White')).toBeInTheDocument();
+    });
+    // The hex display should show the pre-populated color
+    expect(screen.getByText('Hex: #FF0000', { exact: false })).toBeInTheDocument();
+  });
+
+  it('uses translated text for modal elements', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      expect(screen.getByText('Configure AMS Slot')).toBeInTheDocument();
+      expect(screen.getByText('Filament Profile')).toBeInTheDocument();
+    });
+    // Check footer buttons
+    expect(screen.getByRole('button', { name: /Configure Slot/i })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /Reset Slot/i })).toBeInTheDocument();
+  });
 });

+ 1 - 1
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -67,7 +67,7 @@ describe('PrintModal', () => {
         return HttpResponse.json({ filaments: [] });
       }),
       http.get('/api/v1/printers/:id/status', () => {
-        return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: null });
+        return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: [] });
       }),
       http.post('/api/v1/archives/:id/reprint', () => {
         return HttpResponse.json({ success: true });

+ 3 - 3
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -13,7 +13,7 @@ import {
 import type { PrinterStatus } from '../../api/client';
 
 // Helper to create a minimal printer status with AMS data
-function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray?: PrinterStatus['vt_tray']): PrinterStatus {
+function createPrinterStatus(ams: PrinterStatus['ams'], vt_tray: PrinterStatus['vt_tray'] = []): PrinterStatus {
   return {
     ams,
     vt_tray,
@@ -89,7 +89,7 @@ describe('buildLoadedFilaments', () => {
   it('extracts external spool with tray_info_idx', () => {
     const status = createPrinterStatus(
       [],
-      { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
+      [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
     );
 
     const result = buildLoadedFilaments(status);
@@ -339,7 +339,7 @@ describe('computeAmsMapping', () => {
     };
     const status = createPrinterStatus(
       [],
-      { tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }
+      [{ tray_type: 'TPU', tray_color: '0000FF', tray_info_idx: 'EXT001' }]
     );
 
     const result = computeAmsMapping(reqs, status);

+ 1 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -56,6 +56,7 @@ const mockPrinterStatus = {
   remaining_time: 0,
   filename: null,
   wifi_signal: -50,
+  vt_tray: [],
 };
 
 describe('PrintersPage', () => {

+ 5 - 1
frontend/src/api/client.ts

@@ -204,7 +204,7 @@ export interface PrinterStatus {
   hms_errors: HMSError[];
   ams: AMSUnit[];
   ams_exists: boolean;
-  vt_tray: AMSTray | null;  // Virtual tray / external spool
+  vt_tray: AMSTray[];  // Virtual tray / external spool(s)
   sdcard: boolean;  // SD card inserted
   store_to_sdcard: boolean;  // Store sent files on SD card
   timelapse: boolean;  // Timelapse recording active
@@ -3028,6 +3028,8 @@ export const api = {
     request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
   getBuiltinFilaments: () =>
     request<BuiltinFilament[]>('/cloud/builtin-filaments'),
+  getFilamentIdMap: () =>
+    request<Record<string, string>>('/cloud/filament-id-map'),
   getCloudSettingDetail: (settingId: string) =>
     request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
   createCloudSetting: (data: SlicerSettingCreate) =>
@@ -3425,6 +3427,8 @@ export const api = {
     request<{ status: string }>('/inventory/colors/reset', { method: 'POST' }),
   lookupColor: (manufacturer: string, colorName: string, material?: string) =>
     request<ColorLookupResult>(`/inventory/colors/lookup?manufacturer=${encodeURIComponent(manufacturer)}&color_name=${encodeURIComponent(colorName)}${material ? `&material=${encodeURIComponent(material)}` : ''}`),
+  searchColors: (manufacturer?: string, material?: string) =>
+    request<ColorCatalogEntry[]>(`/inventory/colors/search?${manufacturer ? `manufacturer=${encodeURIComponent(manufacturer)}` : ''}${manufacturer && material ? '&' : ''}${material ? `material=${encodeURIComponent(material)}` : ''}`),
   linkTagToSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string; tag_type?: string; data_origin?: string }) =>
     request<InventorySpool>(`/inventory/spools/${spoolId}/link-tag`, {
       method: 'PATCH',

+ 5 - 3
frontend/src/components/AssignSpoolModal.tsx

@@ -62,15 +62,17 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
 
   if (!isOpen) return null;
 
-  // Filter out Bambu Lab spools (identified by RFID tag_uid or tray_uuid)
-  // and spools already assigned to other slots
+  // Filter out spools already assigned to other slots
   const assignedSpoolIds = new Set(
     (assignments || [])
       .filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
       .map(a => a.spool_id)
   );
+  // External slots (amsId 254 or 255) have no RFID reader, so show all spools.
+  // AMS slots only show manual spools (no tag_uid or tray_uuid).
+  const isExternalSlot = amsId === 254 || amsId === 255;
   const manualSpools = spools?.filter((spool: InventorySpool) =>
-    !spool.tag_uid && !spool.tray_uuid && !assignedSpoolIds.has(spool.id)
+    !assignedSpoolIds.has(spool.id) && (isExternalSlot || (!spool.tag_uid && !spool.tray_uuid))
   );
 
   const filteredSpools = manualSpools?.filter((spool: InventorySpool) => {

+ 180 - 39
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -14,6 +14,9 @@ interface SlotInfo {
   trayColor?: string;
   traySubBrands?: string;
   trayInfoIdx?: string;
+  extruderId?: number;
+  caliIdx?: number | null;
+  savedPresetId?: string;
 }
 
 // Get proper AMS label (handles HT AMS with ID 128+)
@@ -72,6 +75,7 @@ interface ConfigureAmsSlotModalProps {
   printerId: number;
   slotInfo: SlotInfo;
   nozzleDiameter?: string;
+  printerModel?: string;
   onSuccess?: () => void;
 }
 
@@ -209,12 +213,23 @@ function colorNameToHex(name: string): string | null {
   return COLOR_NAME_MAP[normalized] || null;
 }
 
+// Extract printer model from preset name suffix "@BBL X1C 0.4 nozzle" → "X1C"
+function extractPresetModel(name: string): string | null {
+  const atIdx = name.indexOf('@');
+  if (atIdx < 0) return null;
+  const suffix = name.slice(atIdx + 1).trim();
+  const bblMatch = suffix.match(/^BBL\s+(.+?)(?:\s+[\d.]+\s*nozzle)?$/i);
+  if (bblMatch) return bblMatch[1].trim();
+  return null;
+}
+
 export function ConfigureAmsSlotModal({
   isOpen,
   onClose,
   printerId,
   slotInfo,
   nozzleDiameter = '0.4',
+  printerModel,
   onSuccess,
 }: ConfigureAmsSlotModalProps) {
   const { t } = useTranslation();
@@ -256,6 +271,14 @@ export function ConfigureAmsSlotModal({
     enabled: isOpen && !!printerId,
   });
 
+  // Fetch color catalog
+  const { data: colorCatalog } = useQuery({
+    queryKey: ['colorCatalog'],
+    queryFn: () => api.getColorCatalog(),
+    enabled: isOpen,
+    staleTime: Infinity,
+  });
+
   // Configure slot mutation
   const configureMutation = useMutation({
     mutationFn: async () => {
@@ -435,22 +458,44 @@ export function ConfigureAmsSlotModal({
     // Collect IDs already covered by cloud and local to avoid duplicates in fallback
     const coveredIds = new Set<string>();
 
+    // Currently-configured preset should always be shown (bypass model filter)
+    const savedId = slotInfo.savedPresetId;
+    const trayIdx = slotInfo.trayInfoIdx;
+
     // 1. Cloud presets
     if (cloudSettings?.filament) {
       for (const cp of cloudSettings.filament) {
         coveredIds.add(cp.setting_id);
-        if (!query || cp.name.toLowerCase().includes(query)) {
-          items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
+        // Keep preset if it matches the slot's saved mapping or current tray_info_idx
+        const isCurrentPreset = savedId === cp.setting_id
+          || (trayIdx && (cp.setting_id === trayIdx || convertToTrayInfoIdx(cp.setting_id) === trayIdx));
+        if (!isCurrentPreset && query && !cp.name.toLowerCase().includes(query)) continue;
+        // Filter by printer model if set (skip for current preset)
+        if (!isCurrentPreset && printerModel) {
+          const presetModel = extractPresetModel(cp.name);
+          if (presetModel && presetModel.toUpperCase() !== printerModel.toUpperCase()) continue;
         }
+        items.push({ id: cp.setting_id, name: cp.name, source: 'cloud', isUser: isUserPreset(cp.setting_id) });
       }
     }
 
     // 2. Local presets
     if (localPresets?.filament) {
       for (const lp of localPresets.filament) {
-        if (!query || lp.name.toLowerCase().includes(query)) {
-          items.push({ id: `local_${lp.id}`, name: lp.name, source: 'local', isUser: false });
+        const localId = `local_${lp.id}`;
+        const isSaved = savedId === localId;
+        if (!isSaved && query && !lp.name.toLowerCase().includes(query)) continue;
+        // Filter by compatible_printers if set (skip for saved preset)
+        if (!isSaved && printerModel && lp.compatible_printers) {
+          const compatModels = lp.compatible_printers.split(';').map(p => {
+            // Extract model from "BBL X1C" → "X1C"
+            const trimmed = p.trim();
+            const bblMatch = trimmed.match(/^BBL\s+(.+)/i);
+            return bblMatch ? bblMatch[1].trim().toUpperCase() : trimmed.toUpperCase();
+          }).filter(Boolean);
+          if (compatModels.length > 0 && !compatModels.includes(printerModel.toUpperCase())) continue;
         }
+        items.push({ id: localId, name: lp.name, source: 'local', isUser: false });
       }
     }
 
@@ -478,7 +523,7 @@ export function ConfigureAmsSlotModal({
       if (!a.isUser && b.isUser) return 1;
       return a.name.localeCompare(b.name);
     });
-  }, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery]);
+  }, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx]);
 
   // Get full preset name for K profile filtering (brand + material, without printer suffix)
   const selectedPresetInfo = useMemo(() => {
@@ -518,6 +563,41 @@ export function ConfigureAmsSlotModal({
   // For backwards compatibility with the label
   const selectedMaterial = selectedPresetInfo?.fullName || '';
 
+  // Filter color catalog entries matching the selected preset's brand + material
+  const catalogColors = useMemo(() => {
+    if (!colorCatalog || !selectedPresetInfo) return [];
+
+    const { fullName, brand } = selectedPresetInfo;
+
+    // Try to find colors matching the full preset name (e.g., "PLA Metal")
+    // The catalog uses the variant as part of the material field (e.g., material="PLA Metal")
+    // Extract the full material+variant from the preset name
+    const materialVariant = fullName.replace(/^(Bambu\s*(Lab)?|eSUN|Polymaker|Overture|Sunlu|Hatchbox)\s*/i, '').trim();
+
+    return colorCatalog.filter(entry => {
+      const entryMaterial = (entry.material || '').toUpperCase();
+      const entryManufacturer = entry.manufacturer.toUpperCase();
+
+      // Match material: try full material+variant first, then just material type
+      const materialMatch = entryMaterial === materialVariant.toUpperCase()
+        || entryMaterial.includes(materialVariant.toUpperCase())
+        || materialVariant.toUpperCase().includes(entryMaterial);
+
+      if (!materialMatch) return false;
+
+      // If brand is present, also match manufacturer
+      if (brand) {
+        const upperBrand = brand.toUpperCase();
+        // Fuzzy match: "Bambu" matches "Bambu Lab", etc.
+        if (!entryManufacturer.includes(upperBrand) && !upperBrand.includes(entryManufacturer)) {
+          return false;
+        }
+      }
+
+      return true;
+    });
+  }, [colorCatalog, selectedPresetInfo]);
+
   const matchingKProfiles = useMemo(() => {
     if (!kprofilesData?.profiles || !selectedPresetInfo) return [];
 
@@ -575,34 +655,52 @@ export function ConfigureAmsSlotModal({
     });
 
     // Deduplicate profiles with same name and k_value (multi-nozzle printers have duplicates)
-    // Prefer extruder_id=1 (High Flow) profiles as they're more commonly used on H2D
+    // Prefer the profile matching the slot's extruder (e.g. ext-R uses extruder 0, ext-L uses extruder 1)
     const seen = new Map<string, KProfile>();
     for (const profile of filtered) {
       const key = `${profile.name}|${profile.k_value}`;
       const existing = seen.get(key);
       if (!existing) {
         seen.set(key, profile);
-      } else if (profile.extruder_id === 1 && existing.extruder_id === 0) {
-        // Replace extruder_id=0 profile with extruder_id=1 (High Flow) profile
+      } else if (slotInfo.extruderId !== undefined && profile.extruder_id === slotInfo.extruderId && existing.extruder_id !== slotInfo.extruderId) {
+        // Replace with profile matching slot's extruder
         seen.set(key, profile);
       }
     }
     return Array.from(seen.values());
-  }, [kprofilesData?.profiles, selectedPresetInfo]);
+  }, [kprofilesData?.profiles, selectedPresetInfo, slotInfo.extruderId]);
 
   // Pre-select current profile when modal opens, reset when closes
   useEffect(() => {
-    if (isOpen && cloudSettings?.filament) {
-      // Try to pre-select current profile based on trayInfoIdx
-      if (slotInfo.trayInfoIdx) {
-        const currentPreset = cloudSettings.filament.find(
+    if (isOpen) {
+      // Pre-populate from saved preset mapping (most reliable)
+      if (slotInfo.savedPresetId) {
+        setSelectedPresetId(slotInfo.savedPresetId);
+      } else if (slotInfo.trayInfoIdx && cloudSettings?.filament) {
+        // Fallback: try to match by tray_info_idx in cloud presets
+        // First try exact match on setting_id
+        let currentPreset = cloudSettings.filament.find(
           p => p.setting_id === slotInfo.trayInfoIdx
         );
+        // Then try matching by converting setting_id → filament_id format
+        if (!currentPreset) {
+          currentPreset = cloudSettings.filament.find(
+            p => convertToTrayInfoIdx(p.setting_id) === slotInfo.trayInfoIdx
+          );
+        }
         if (currentPreset) {
           setSelectedPresetId(currentPreset.setting_id);
         }
       }
-    } else if (!isOpen) {
+
+      // Pre-populate color from current slot
+      if (slotInfo.trayColor) {
+        const hex = slotInfo.trayColor.slice(0, 6);
+        if (hex && hex !== '000000') {
+          setColorHex(hex);
+        }
+      }
+    } else {
       // Reset when modal closes
       setSelectedPresetId('');
       setSelectedKProfile(null);
@@ -611,17 +709,25 @@ export function ConfigureAmsSlotModal({
       setSearchQuery('');
       setShowSuccess(false);
     }
-  }, [isOpen, cloudSettings?.filament, slotInfo.trayInfoIdx]);
+  }, [isOpen, slotInfo.savedPresetId, slotInfo.trayInfoIdx, slotInfo.trayColor, cloudSettings?.filament]);
 
   // Auto-select best matching K profile when preset changes
   useEffect(() => {
     if (matchingKProfiles.length > 0) {
-      // Auto-select first matching profile
+      // Prefer the currently-active K-profile (by cali_idx) if available
+      if (slotInfo.caliIdx != null && slotInfo.caliIdx > 0) {
+        const active = matchingKProfiles.find(p => p.slot_id === slotInfo.caliIdx);
+        if (active) {
+          setSelectedKProfile(active);
+          return;
+        }
+      }
+      // Fallback: first matching profile
       setSelectedKProfile(matchingKProfiles[0]);
     } else {
       setSelectedKProfile(null);
     }
-  }, [selectedPresetId, matchingKProfiles]);
+  }, [selectedPresetId, matchingKProfiles, slotInfo.caliIdx]);
 
   // Escape key handler
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -659,7 +765,7 @@ export function ConfigureAmsSlotModal({
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <div className="flex items-center gap-2">
             <Settings2 className="w-5 h-5 text-bambu-blue" />
-            <h2 className="text-lg font-semibold text-white">Configure AMS Slot</h2>
+            <h2 className="text-lg font-semibold text-white">{t('configureAmsSlot.title')}</h2>
           </div>
           <button
             onClick={onClose}
@@ -676,7 +782,7 @@ export function ConfigureAmsSlotModal({
             <div className="absolute inset-0 bg-bambu-dark-secondary/95 z-10 flex items-center justify-center rounded-xl">
               <div className="text-center space-y-3">
                 <CheckCircle2 className="w-16 h-16 text-bambu-green mx-auto" />
-                <p className="text-lg font-semibold text-white">Slot Configured!</p>
+                <p className="text-lg font-semibold text-white">{t('configureAmsSlot.slotConfigured')}</p>
                 <p className="text-sm text-bambu-gray">{t('configureAmsSlot.settingsSentToPrinter')}</p>
               </div>
             </div>
@@ -684,7 +790,7 @@ export function ConfigureAmsSlotModal({
 
           {/* Slot info */}
           <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
-            <p className="text-xs text-bambu-gray mb-1">Configuring slot:</p>
+            <p className="text-xs text-bambu-gray mb-1">{t('configureAmsSlot.configuringSlot')}</p>
             <div className="flex items-center gap-2">
               {slotInfo.trayColor && (
                 <span
@@ -693,7 +799,7 @@ export function ConfigureAmsSlotModal({
                 />
               )}
               <span className="text-white font-medium">
-                {getAmsLabel(slotInfo.amsId, slotInfo.trayCount)} Slot {slotInfo.trayId + 1}
+                {t('configureAmsSlot.slotLabel', { ams: getAmsLabel(slotInfo.amsId, slotInfo.trayCount), slot: slotInfo.trayId + 1 })}
               </span>
               {slotInfo.traySubBrands && (
                 <span className="text-bambu-gray">({slotInfo.traySubBrands})</span>
@@ -710,7 +816,7 @@ export function ConfigureAmsSlotModal({
               {/* Filament Profile Select */}
               <div>
                 <label className="block text-sm text-bambu-gray mb-2">
-                  Filament Profile <span className="text-red-400">*</span>
+                  {t('configureAmsSlot.filamentProfile')} <span className="text-red-400">*</span>
                 </label>
                 <div className="relative">
                   <input
@@ -724,13 +830,16 @@ export function ConfigureAmsSlotModal({
                     {filteredPresets.length === 0 ? (
                       <p className="text-center py-4 text-bambu-gray">
                         {(cloudSettings?.filament?.length === 0 && !localPresets?.filament?.length)
-                          ? 'No presets available. Login to Bambu Cloud or import local profiles.'
-                          : 'No matching presets found.'}
+                          ? t('configureAmsSlot.noPresetsAvailable')
+                          : t('configureAmsSlot.noMatchingPresets')}
                       </p>
                     ) : (
                       filteredPresets.map((preset) => (
                         <button
                           key={preset.id}
+                          ref={selectedPresetId === preset.id ? (el) => {
+                            el?.scrollIntoView({ block: 'nearest' });
+                          } : undefined}
                           onClick={() => setSelectedPresetId(preset.id)}
                           className={`w-full p-2 rounded-lg border text-left transition-colors ${
                             selectedPresetId === preset.id
@@ -768,10 +877,10 @@ export function ConfigureAmsSlotModal({
               {/* K Profile Select */}
               <div>
                 <label className="block text-sm text-bambu-gray mb-2">
-                  K Profile (Pressure Advance)
+                  {t('configureAmsSlot.kProfileLabel')}
                   {selectedMaterial && (
                     <span className="ml-2 text-xs text-bambu-blue">
-                      Filtering for: {selectedMaterial}
+                      {t('configureAmsSlot.filteringFor', { material: selectedMaterial })}
                     </span>
                   )}
                 </label>
@@ -785,7 +894,7 @@ export function ConfigureAmsSlotModal({
                       }}
                       className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none appearance-none pr-10"
                     >
-                      <option value="">No K profile (use default 0.020)</option>
+                      <option value="">{t('configureAmsSlot.noKProfile')}</option>
                       {matchingKProfiles.map((profile) => (
                         <option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
                           {profile.name} (K={profile.k_value})
@@ -796,16 +905,16 @@ export function ConfigureAmsSlotModal({
                   </div>
                 ) : selectedPresetId ? (
                   <p className="text-sm text-bambu-gray italic py-2">
-                    No matching K profiles found. Default K=0.020 will be used.
+                    {t('configureAmsSlot.noMatchingKProfiles')}
                   </p>
                 ) : (
                   <span className="inline-block text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-400 border border-amber-500/30">
-                    Select a filament profile first
+                    {t('configureAmsSlot.selectFilamentFirst')}
                   </span>
                 )}
                 {selectedKProfile && (
                   <p className="text-xs text-bambu-green mt-1">
-                    K={selectedKProfile.k_value} from printer calibration
+                    {t('configureAmsSlot.kFromCalibration', { value: selectedKProfile.k_value })}
                   </p>
                 )}
               </div>
@@ -813,8 +922,40 @@ export function ConfigureAmsSlotModal({
               {/* Optional: Custom color */}
               <div>
                 <label className="block text-sm text-bambu-gray mb-2">
-                  Custom Color (optional)
+                  {t('configureAmsSlot.customColorLabel')}
                 </label>
+                {/* Catalog colors matching selected preset */}
+                {catalogColors.length > 0 && (
+                  <div className="mb-3">
+                    <p className="text-xs text-bambu-gray mb-1.5">
+                      {t('configureAmsSlot.presetColors', { name: selectedPresetInfo?.fullName })}
+                    </p>
+                    <div className="flex flex-wrap gap-1.5">
+                      {catalogColors.map((entry) => (
+                        <button
+                          key={entry.id}
+                          onClick={() => {
+                            const hex = entry.hex_color.replace('#', '').toUpperCase();
+                            setColorHex(hex);
+                            setColorInput(entry.color_name);
+                          }}
+                          className={`h-7 px-2 rounded-md border-2 transition-all flex items-center gap-1.5 ${
+                            colorHex === entry.hex_color.replace('#', '').toUpperCase()
+                              ? 'border-bambu-green scale-105'
+                              : 'border-white/20 hover:border-white/40'
+                          }`}
+                          title={entry.color_name}
+                        >
+                          <span
+                            className="w-4 h-4 rounded-full border border-white/30 flex-shrink-0"
+                            style={{ backgroundColor: entry.hex_color }}
+                          />
+                          <span className="text-xs text-white/80 whitespace-nowrap">{entry.color_name}</span>
+                        </button>
+                      ))}
+                    </div>
+                  </div>
+                )}
                 {/* Quick color buttons */}
                 <div className="flex flex-wrap gap-1.5 mb-2">
                   {QUICK_COLORS_BASIC.map((color) => (
@@ -836,7 +977,7 @@ export function ConfigureAmsSlotModal({
                   <button
                     onClick={() => setShowExtendedColors(!showExtendedColors)}
                     className="w-7 h-7 rounded-md border-2 border-white/20 hover:border-white/40 flex items-center justify-center text-white/60 hover:text-white/80 transition-all text-xs"
-                    title={showExtendedColors ? 'Show less colors' : 'Show more colors'}
+                    title={showExtendedColors ? t('configureAmsSlot.showLessColors') : t('configureAmsSlot.showMoreColors')}
                   >
                     {showExtendedColors ? '−' : '+'}
                   </button>
@@ -902,13 +1043,13 @@ export function ConfigureAmsSlotModal({
                       className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded"
                       title={t('configureAmsSlot.clearCustomColor')}
                     >
-                      Clear
+                      {t('configureAmsSlot.clear')}
                     </button>
                   )}
                 </div>
                 {colorHex && (
                   <p className="text-xs text-bambu-gray mt-1.5">
-                    Hex: #{colorHex}
+                    {t('configureAmsSlot.hexLabel', { hex: colorHex })}
                   </p>
                 )}
               </div>
@@ -928,19 +1069,19 @@ export function ConfigureAmsSlotModal({
             {resetMutation.isPending ? (
               <>
                 <Loader2 className="w-4 h-4 animate-spin" />
-                Resetting...
+                {t('configureAmsSlot.resetting')}
               </>
             ) : (
               <>
                 <RotateCcw className="w-4 h-4" />
-                Reset Slot
+                {t('configureAmsSlot.resetSlot')}
               </>
             )}
           </Button>
           {/* Cancel and Configure buttons on the right */}
           <div className="flex gap-2">
             <Button variant="secondary" onClick={onClose}>
-              Cancel
+              {t('configureAmsSlot.cancel')}
             </Button>
             <Button
               onClick={() => configureMutation.mutate()}
@@ -949,12 +1090,12 @@ export function ConfigureAmsSlotModal({
               {configureMutation.isPending ? (
                 <>
                   <Loader2 className="w-4 h-4 animate-spin" />
-                  Configuring...
+                  {t('configureAmsSlot.configuring')}
                 </>
               ) : (
                 <>
                   <Settings2 className="w-4 h-4" />
-                  Configure Slot
+                  {t('configureAmsSlot.configureSlot')}
                 </>
               )}
             </Button>

+ 66 - 5
frontend/src/components/KProfilesView.tsx

@@ -150,6 +150,7 @@ interface KProfileModalProps {
   printerId: number;
   nozzleDiameter: string;
   existingProfiles?: KProfile[];  // Existing profiles for filament selection
+  builtinFilaments?: { filament_id: string; name: string }[];  // Filament ID → name lookup
   isDualNozzle?: boolean;  // Whether this is a dual-nozzle printer
   initialNote?: string;  // Initial note value for the profile
   initialNoteKey?: string | null;  // Key the note was stored under (for clearing)
@@ -164,6 +165,7 @@ function KProfileModal({
   printerId,
   nozzleDiameter,
   existingProfiles = [],
+  builtinFilaments = [],
   isDualNozzle = false,
   initialNote = '',
   initialNoteKey = null,
@@ -197,12 +199,21 @@ function KProfileModal({
   const [note, setNote] = useState(initialNote);
 
   // Extract unique filaments from existing K-profiles on the printer
-  // These have valid filament_ids that the printer recognizes
+  // Use builtin filament table for accurate name resolution (filament_id → name)
+  // Falls back to extracting from profile name for custom/unknown presets
   const knownFilaments = React.useMemo(() => {
+    // Build lookup map from builtin filament names (includes cloud presets from parent)
+    const builtinMap = new Map<string, string>();
+    for (const bf of builtinFilaments) {
+      builtinMap.set(bf.filament_id, bf.name);
+    }
+
     const filamentMap = new Map<string, { id: string; name: string }>();
     for (const p of existingProfiles) {
       if (p.filament_id && !filamentMap.has(p.filament_id)) {
-        const filamentName = extractFilamentName(p.name || '');
+        // Prefer builtin name (accurate), fall back to extracting from profile name
+        const builtinName = builtinMap.get(p.filament_id);
+        const filamentName = builtinName || extractFilamentName(p.name || '');
         filamentMap.set(p.filament_id, {
           id: p.filament_id,
           name: filamentName || p.filament_id,
@@ -212,7 +223,7 @@ function KProfileModal({
     return Array.from(filamentMap.values()).sort((a, b) =>
       a.name.localeCompare(b.name)
     );
-  }, [existingProfiles]);
+  }, [existingProfiles, builtinFilaments]);
 
   const saveMutation = useMutation({
     mutationFn: (data: KProfileCreate) => {
@@ -769,6 +780,20 @@ export function KProfilesView() {
     staleTime: 60000,  // Cache for 1 minute
   });
 
+  // Fetch builtin filament names for accurate filament_id → name resolution
+  const { data: builtinFilaments } = useQuery({
+    queryKey: ['builtinFilaments'],
+    queryFn: () => api.getBuiltinFilaments(),
+    staleTime: 300000,  // Cache for 5 minutes (static data)
+  });
+
+  // Fetch filament_id → name mapping for user cloud presets (P* IDs)
+  const { data: filamentIdMap } = useQuery({
+    queryKey: ['filamentIdMap'],
+    queryFn: () => api.getFilamentIdMap(),
+    staleTime: 300000,  // Cache for 5 minutes
+  });
+
   // Fetch K-profile notes (stored locally)
   const {
     data: notesData,
@@ -807,6 +832,39 @@ export function KProfilesView() {
   // Get connected printers for display
   const connectedPrinters = printers?.filter((p) => p.is_active) || [];
 
+  // Build filament lookup for name resolution (builtin + user cloud presets)
+  const builtinFilamentMap = React.useMemo(() => {
+    const map = new Map<string, string>();
+    if (builtinFilaments) {
+      for (const bf of builtinFilaments) {
+        map.set(bf.filament_id, bf.name);
+      }
+    }
+    // Also add user cloud presets (P* filament_ids resolved from cloud details)
+    if (filamentIdMap) {
+      for (const [fid, name] of Object.entries(filamentIdMap)) {
+        if (!map.has(fid)) {
+          map.set(fid, name);
+        }
+      }
+    }
+    return map;
+  }, [builtinFilaments, filamentIdMap]);
+
+  // Enriched builtin filaments array (builtin + cloud presets merged)
+  // Pass this to modals so they have the full filament name lookup
+  const enrichedBuiltinFilaments = React.useMemo(() => {
+    return Array.from(builtinFilamentMap.entries()).map(([fid, name]) => ({
+      filament_id: fid,
+      name,
+    }));
+  }, [builtinFilamentMap]);
+
+  // Resolve filament name: builtin table first, then extract from profile name
+  const resolveFilamentName = React.useCallback((profile: KProfile) => {
+    return builtinFilamentMap.get(profile.filament_id) || extractFilamentName(profile.name);
+  }, [builtinFilamentMap]);
+
   // Filter and sort profiles
   // Note: nozzle diameter filtering is done server-side via MQTT request
   const filteredProfiles = React.useMemo(() => {
@@ -841,13 +899,13 @@ export function KProfilesView() {
         case 'k_value':
           return parseFloat(a.k_value) - parseFloat(b.k_value);
         case 'filament':
-          return extractFilamentName(a.name).localeCompare(extractFilamentName(b.name));
+          return resolveFilamentName(a).localeCompare(resolveFilamentName(b));
         case 'name':
         default:
           return a.name.localeCompare(b.name);
       }
     });
-  }, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption]);
+  }, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption, resolveFilamentName]);
 
   // Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
   const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
@@ -1394,6 +1452,7 @@ export function KProfilesView() {
             printerId={selectedPrinter}
             nozzleDiameter={nozzleDiameter}
             existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+            builtinFilaments={enrichedBuiltinFilaments}
             isDualNozzle={isDualNozzle}
             initialNote={note}
             initialNoteKey={key}
@@ -1418,6 +1477,7 @@ export function KProfilesView() {
           printerId={selectedPrinter}
           nozzleDiameter={nozzleDiameter}
           existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+          builtinFilaments={enrichedBuiltinFilaments}
           isDualNozzle={isDualNozzle}
           onSaveNote={handleSaveNote}
           hasPermission={hasPermission}
@@ -1438,6 +1498,7 @@ export function KProfilesView() {
           printerId={selectedPrinter}
           nozzleDiameter={nozzleDiameter}
           existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
+          builtinFilaments={enrichedBuiltinFilaments}
           isDualNozzle={isDualNozzle}
           onSaveNote={handleSaveNote}
           hasPermission={hasPermission}

+ 20 - 16
frontend/src/hooks/useFilamentMapping.ts

@@ -41,22 +41,26 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
     });
   });
 
-  // Add external spool if loaded
-  if (printerStatus?.vt_tray?.tray_type) {
-    const color = normalizeColor(printerStatus.vt_tray.tray_color);
-    filaments.push({
-      type: printerStatus.vt_tray.tray_type,
-      color,
-      colorName: getColorName(color),
-      amsId: -1,
-      trayId: 0,
-      isHt: false,
-      isExternal: true,
-      label: 'External',
-      globalTrayId: 254,
-      trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
-      extruderId: hasDualNozzle ? 0 : undefined,
-    });
+  // Add external spool(s) if loaded
+  for (const extTray of printerStatus?.vt_tray ?? []) {
+    if (extTray.tray_type) {
+      const color = normalizeColor(extTray.tray_color);
+      const trayId = extTray.id ?? 254;
+      const hasDualExternal = (printerStatus?.vt_tray?.length ?? 0) > 1;
+      filaments.push({
+        type: extTray.tray_type,
+        color,
+        colorName: getColorName(color),
+        amsId: -1,
+        trayId: trayId - 254,
+        isHt: false,
+        isExternal: true,
+        label: hasDualExternal ? (trayId === 254 ? 'Ext-L' : 'Ext-R') : 'External',
+        globalTrayId: trayId,
+        trayInfoIdx: extTray.tray_info_idx || '',
+        extruderId: hasDualNozzle ? (trayId - 254) : undefined,
+      });
+    }
   }
 
   return filaments;

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

@@ -171,6 +171,8 @@ export default {
     // Printer card
     readyToPrint: 'Druckbereit',
     external: 'Extern',
+    extL: 'Ext-L',
+    extR: 'Ext-R',
     deleteArchives: 'Druckarchive löschen',
     noLabel: 'Keine Bezeichnung',
     printPreview: 'Druckvorschau',
@@ -3204,15 +3206,37 @@ export default {
 
   // Configure AMS Slot Modal
   configureAmsSlot: {
+    title: 'AMS-Slot konfigurieren',
+    slotConfigured: 'Slot konfiguriert!',
+    configuringSlot: 'Slot wird konfiguriert:',
+    slotLabel: '{{ams}} Slot {{slot}}',
     searchPresets: 'Voreinstellungen suchen...',
     colorPlaceholder: 'Farbname oder Hex (z.B. braun, FF8800)',
     clearCustomColor: 'Benutzerdefinierte Farbe löschen',
     noCloudPresets: 'Keine Cloud-Voreinstellungen. Melden Sie sich bei Bambu Cloud an, um zu synchronisieren.',
+    noPresetsAvailable: 'Keine Voreinstellungen verfügbar. Melden Sie sich bei Bambu Cloud an oder importieren Sie lokale Profile.',
     noMatchingPresets: 'Keine passenden Voreinstellungen gefunden.',
     custom: 'Benutzerdefiniert',
     builtin: 'Integriert',
     settingsSentToPrinter: 'Einstellungen an Drucker gesendet',
     filamentProfile: 'Filamentprofil',
+    kProfileLabel: 'K-Profil (Pressure Advance)',
+    filteringFor: 'Filtern nach: {{material}}',
+    noKProfile: 'Kein K-Profil (Standard 0.020 verwenden)',
+    noMatchingKProfiles: 'Keine passenden K-Profile gefunden. Standard K=0.020 wird verwendet.',
+    selectFilamentFirst: 'Zuerst ein Filamentprofil auswählen',
+    kFromCalibration: 'K={{value}} aus Druckerkalibrierung',
+    customColorLabel: 'Benutzerdefinierte Farbe (optional)',
+    presetColors: '{{name}} Farben:',
+    showLessColors: 'Weniger Farben anzeigen',
+    showMoreColors: 'Mehr Farben anzeigen',
+    clear: 'Löschen',
+    hexLabel: 'Hex: #{{hex}}',
+    resetting: 'Wird zurückgesetzt...',
+    resetSlot: 'Slot zurücksetzen',
+    cancel: 'Abbrechen',
+    configuring: 'Wird konfiguriert...',
+    configureSlot: 'Slot konfigurieren',
   },
 
   // GitHub Backup Settings

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

@@ -171,6 +171,8 @@ export default {
     // Printer card
     readyToPrint: 'Ready to print',
     external: 'External',
+    extL: 'Ext-L',
+    extR: 'Ext-R',
     deleteArchives: 'Delete print archives',
     noLabel: 'No label',
     printPreview: 'Print preview',
@@ -3209,15 +3211,37 @@ export default {
 
   // Configure AMS Slot Modal
   configureAmsSlot: {
+    title: 'Configure AMS Slot',
+    slotConfigured: 'Slot Configured!',
+    configuringSlot: 'Configuring slot:',
+    slotLabel: '{{ams}} Slot {{slot}}',
     searchPresets: 'Search presets...',
     colorPlaceholder: 'Color name or hex (e.g., brown, FF8800)',
     clearCustomColor: 'Clear custom color',
     noCloudPresets: 'No cloud presets. Login to Bambu Cloud to sync.',
+    noPresetsAvailable: 'No presets available. Login to Bambu Cloud or import local profiles.',
     noMatchingPresets: 'No matching presets found.',
     custom: 'Custom',
     builtin: 'Built-in',
     settingsSentToPrinter: 'Settings sent to printer',
     filamentProfile: 'Filament Profile',
+    kProfileLabel: 'K Profile (Pressure Advance)',
+    filteringFor: 'Filtering for: {{material}}',
+    noKProfile: 'No K profile (use default 0.020)',
+    noMatchingKProfiles: 'No matching K profiles found. Default K=0.020 will be used.',
+    selectFilamentFirst: 'Select a filament profile first',
+    kFromCalibration: 'K={{value}} from printer calibration',
+    customColorLabel: 'Custom Color (optional)',
+    presetColors: '{{name}} colors:',
+    showLessColors: 'Show less colors',
+    showMoreColors: 'Show more colors',
+    clear: 'Clear',
+    hexLabel: 'Hex: #{{hex}}',
+    resetting: 'Resetting...',
+    resetSlot: 'Reset Slot',
+    cancel: 'Cancel',
+    configuring: 'Configuring...',
+    configureSlot: 'Configure Slot',
   },
 
   // GitHub Backup Settings

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

@@ -171,6 +171,8 @@ export default {
     // Printer card
     readyToPrint: 'Prête à imprimer',
     external: 'Externe',
+    extL: 'Ext-L',
+    extR: 'Ext-R',
     deleteArchives: 'Supprimer les archives d\'impression',
     noLabel: 'Pas d\'étiquette',
     printPreview: 'Aperçu avant impression',
@@ -3205,15 +3207,37 @@ export default {
 
   // Configure AMS Slot Modal
   configureAmsSlot: {
+    title: 'Configurer le slot AMS',
+    slotConfigured: 'Slot configuré !',
+    configuringSlot: 'Configuration du slot :',
+    slotLabel: '{{ams}} Slot {{slot}}',
     searchPresets: 'Chercher presets...',
     colorPlaceholder: 'Nom couleur ou hex (ex: brown, FF8800)',
     clearCustomColor: 'Effacer couleur perso',
     noCloudPresets: 'Profils Cloud absents. Connectez-vous.',
+    noPresetsAvailable: 'Aucun preset disponible. Connectez-vous à Bambu Cloud ou importez des profils locaux.',
     noMatchingPresets: 'Aucun profil trouvé.',
     custom: 'Perso',
     builtin: 'Inclus',
     settingsSentToPrinter: 'Réglages envoyés',
     filamentProfile: 'Profil Filament',
+    kProfileLabel: 'Profil K (Pressure Advance)',
+    filteringFor: 'Filtrage pour : {{material}}',
+    noKProfile: 'Pas de profil K (utiliser défaut 0.020)',
+    noMatchingKProfiles: 'Aucun profil K trouvé. K=0.020 par défaut sera utilisé.',
+    selectFilamentFirst: 'Sélectionnez d\'abord un profil filament',
+    kFromCalibration: 'K={{value}} de la calibration imprimante',
+    customColorLabel: 'Couleur personnalisée (optionnel)',
+    presetColors: 'Couleurs {{name}} :',
+    showLessColors: 'Moins de couleurs',
+    showMoreColors: 'Plus de couleurs',
+    clear: 'Effacer',
+    hexLabel: 'Hex : #{{hex}}',
+    resetting: 'Réinitialisation...',
+    resetSlot: 'Réinitialiser le slot',
+    cancel: 'Annuler',
+    configuring: 'Configuration...',
+    configureSlot: 'Configurer le slot',
   },
 
   // GitHub Backup Settings

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

@@ -168,6 +168,8 @@ export default {
     // Printer card
     readyToPrint: 'Pronta a stampare',
     external: 'Esterna',
+    extL: 'Ext-L',
+    extR: 'Ext-R',
     deleteArchives: 'Elimina archivi stampa',
     noLabel: 'Nessuna etichetta',
     printPreview: 'Anteprima stampa',
@@ -2732,4 +2734,39 @@ export default {
     replaceCarbonFilter: 'Sostituisci filtro a carbone attivo',
     lubricateLeftNozzleRail: 'Lubrifica guida ugello sinistro (serie H2)',
   },
+
+  // Configure AMS Slot Modal
+  configureAmsSlot: {
+    title: 'Configura Slot AMS',
+    slotConfigured: 'Slot configurato!',
+    configuringSlot: 'Configurazione slot:',
+    slotLabel: '{{ams}} Slot {{slot}}',
+    searchPresets: 'Cerca preset...',
+    colorPlaceholder: 'Nome colore o hex (es. marrone, FF8800)',
+    clearCustomColor: 'Cancella colore personalizzato',
+    noCloudPresets: 'Nessun preset cloud. Accedi a Bambu Cloud per sincronizzare.',
+    noPresetsAvailable: 'Nessun preset disponibile. Accedi a Bambu Cloud o importa profili locali.',
+    noMatchingPresets: 'Nessun preset corrispondente trovato.',
+    custom: 'Personalizzato',
+    builtin: 'Integrato',
+    settingsSentToPrinter: 'Impostazioni inviate alla stampante',
+    filamentProfile: 'Profilo filamento',
+    kProfileLabel: 'Profilo K (Pressure Advance)',
+    filteringFor: 'Filtrando per: {{material}}',
+    noKProfile: 'Nessun profilo K (usa predefinito 0.020)',
+    noMatchingKProfiles: 'Nessun profilo K corrispondente. Verrà usato K=0.020 predefinito.',
+    selectFilamentFirst: 'Seleziona prima un profilo filamento',
+    kFromCalibration: 'K={{value}} dalla calibrazione stampante',
+    customColorLabel: 'Colore personalizzato (opzionale)',
+    presetColors: 'Colori {{name}}:',
+    showLessColors: 'Mostra meno colori',
+    showMoreColors: 'Mostra più colori',
+    clear: 'Cancella',
+    hexLabel: 'Hex: #{{hex}}',
+    resetting: 'Ripristino...',
+    resetSlot: 'Ripristina slot',
+    cancel: 'Annulla',
+    configuring: 'Configurazione...',
+    configureSlot: 'Configura slot',
+  },
 };

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

@@ -173,6 +173,8 @@ export default {
     noPrintersConfigured: 'プリンターが設定されていません',
     readyToPrint: '印刷可能',
     external: '外部',
+    extL: 'Ext-L',
+    extR: 'Ext-R',
     deleteArchives: '印刷アーカイブを削除',
     willBeSkipped: 'スキップされます',
     name: '名前',
@@ -2920,15 +2922,37 @@ export default {
 
   // Configure AMS Slot Modal
   configureAmsSlot: {
+    title: 'AMSスロットの設定',
+    slotConfigured: 'スロットを設定しました!',
+    configuringSlot: 'スロットを設定中:',
+    slotLabel: '{{ams}} スロット {{slot}}',
     searchPresets: 'プリセットを検索...',
     colorPlaceholder: '色名またはHex(例: 茶色、FF8800)',
     clearCustomColor: 'カスタム色をクリア',
     noCloudPresets: 'クラウドプリセットがありません。Bambu Cloudにログインして同期してください。',
+    noPresetsAvailable: 'プリセットがありません。Bambu Cloudにログインするか、ローカルプロファイルをインポートしてください。',
     noMatchingPresets: '一致するプリセットが見つかりません。',
     custom: 'カスタム',
     builtin: '内蔵',
     settingsSentToPrinter: '設定をプリンターに送信しました',
     filamentProfile: 'フィラメントプロファイル',
+    kProfileLabel: 'Kプロファイル(Pressure Advance)',
+    filteringFor: 'フィルター中: {{material}}',
+    noKProfile: 'Kプロファイルなし(デフォルト0.020を使用)',
+    noMatchingKProfiles: '一致するKプロファイルが見つかりません。デフォルトK=0.020が使用されます。',
+    selectFilamentFirst: 'まずフィラメントプロファイルを選択してください',
+    kFromCalibration: 'K={{value}}(プリンターキャリブレーションから)',
+    customColorLabel: 'カスタム色(オプション)',
+    presetColors: '{{name}}の色:',
+    showLessColors: '色を減らす',
+    showMoreColors: '色をもっと表示',
+    clear: 'クリア',
+    hexLabel: 'Hex: #{{hex}}',
+    resetting: 'リセット中...',
+    resetSlot: 'スロットをリセット',
+    cancel: 'キャンセル',
+    configuring: '設定中...',
+    configureSlot: 'スロットを設定',
   },
 
   // Email Settings

+ 219 - 179
frontend/src/pages/PrintersPage.tsx

@@ -1384,6 +1384,44 @@ function getStatusDisplay(state: string | null | undefined, stg_cur_name: string
   }
 }
 
+// Map SSDP model codes to display names
+function mapModelCode(ssdpModel: string | null): string {
+  if (!ssdpModel) return '';
+  const modelMap: Record<string, string> = {
+    // H2 Series
+    'O1D': 'H2D',
+    'O1E': 'H2D Pro',
+    'O2D': 'H2D Pro',
+    'O1C': 'H2C',
+    'O1S': 'H2S',
+    // X1 Series
+    'BL-P001': 'X1C',
+    'BL-P002': 'X1',
+    'BL-P003': 'X1E',
+    // P Series
+    'C11': 'P1S',
+    'C12': 'P1P',
+    'C13': 'P2S',
+    // A1 Series
+    'N2S': 'A1',
+    'N1': 'A1 Mini',
+    // Direct matches
+    'X1C': 'X1C',
+    'X1': 'X1',
+    'X1E': 'X1E',
+    'P1S': 'P1S',
+    'P1P': 'P1P',
+    'P2S': 'P2S',
+    'A1': 'A1',
+    'A1 Mini': 'A1 Mini',
+    'H2D': 'H2D',
+    'H2D Pro': 'H2D Pro',
+    'H2C': 'H2C',
+    'H2S': 'H2S',
+  };
+  return modelMap[ssdpModel] || ssdpModel;
+}
+
 function PrinterCard({
   printer,
   hideIfDisconnected,
@@ -1469,6 +1507,9 @@ function PrinterCard({
     trayColor?: string;
     traySubBrands?: string;
     trayInfoIdx?: string;
+    extruderId?: number;
+    caliIdx?: number | null;
+    savedPresetId?: string;
   } | null>(null);
   const [showFirmwareModal, setShowFirmwareModal] = useState(false);
   const [plateCheckResult, setPlateCheckResult] = useState<{
@@ -1516,8 +1557,8 @@ function PrinterCard({
         }
       }
     }
-    if (status?.vt_tray?.tray_info_idx) {
-      ids.add(status.vt_tray.tray_info_idx);
+    for (const vt of status?.vt_tray ?? []) {
+      if (vt.tray_info_idx) ids.add(vt.tray_info_idx);
     }
     if (status?.nozzle_rack) {
       for (const slot of status.nozzle_rack) {
@@ -1582,18 +1623,20 @@ function PrinterCard({
   }, [status?.ams]);
   const amsData = (status?.ams && status.ams.length > 0) ? status.ams : cachedAmsData.current;
 
-  // Cache tray_now to prevent flickering when 255 (unloaded) or undefined values come in
-  // Only update cache when we get a valid tray ID (0-253 or 254 for external)
-  const cachedTrayNow = useRef<number>(255);
+  // Cache tray_now to prevent flickering when undefined values come in
+  // Valid tray IDs: 0-253 for AMS, 254 for external spool
+  // tray_now=255 means "no tray loaded" (Bambu protocol sentinel) — never active
+  const cachedTrayNow = useRef<number | undefined>(undefined);
   const currentTrayNow = status?.tray_now;
-  // Update cache synchronously during render if we have a valid value
+  // Update cache: 255 means "no tray" so clear cache; valid values get cached
   if (currentTrayNow !== undefined && currentTrayNow !== 255) {
     cachedTrayNow.current = currentTrayNow;
+  } else if (currentTrayNow === 255) {
+    cachedTrayNow.current = undefined;
   }
-  // Use cached value if current is 255/undefined but we had a valid value before
-  const effectiveTrayNow = (currentTrayNow === undefined || currentTrayNow === 255)
-    ? cachedTrayNow.current
-    : currentTrayNow;
+  const effectiveTrayNow = (currentTrayNow !== undefined && currentTrayNow !== 255)
+    ? currentTrayNow
+    : cachedTrayNow.current;
 
   // Fetch smart plug for this printer
   const { data: smartPlug } = useQuery({
@@ -2658,7 +2701,7 @@ function PrinterCard({
             })()}
 
             {/* AMS Units - 2-Column Grid Layout */}
-            {amsData && amsData.length > 0 && viewMode === 'expanded' && (() => {
+            {(amsData?.length > 0 || status.vt_tray.length > 0) && viewMode === 'expanded' && (() => {
               // Separate regular AMS (4-tray) from HT AMS (1-tray)
               const regularAms = amsData.filter(ams => ams.tray.length > 1);
               const htAms = amsData.filter(ams => ams.tray.length === 1);
@@ -2915,6 +2958,9 @@ function PrinterCard({
                                             trayColor: tray?.tray_color || undefined,
                                             traySubBrands: tray?.tray_sub_brands || undefined,
                                             trayInfoIdx: tray?.tray_info_idx || undefined,
+                                            extruderId: mappedExtruderId,
+                                            caliIdx: tray?.cali_idx,
+                                            savedPresetId: slotPreset?.preset_id,
                                           }),
                                         }}
                                       >
@@ -2928,6 +2974,7 @@ function PrinterCard({
                                             amsId: ams.id,
                                             trayId: slotIdx,
                                             trayCount: ams.tray.length,
+                                            extruderId: mappedExtruderId,
                                           }),
                                         }}
                                       >
@@ -2945,7 +2992,7 @@ function PrinterCard({
                   )}
 
                     {/* Row 3: HT AMS + External spools (same style as regular AMS, 4 across) */}
-                    {(htAms.length > 0 || (status.vt_tray && status.vt_tray.tray_type)) && (
+                    {(htAms.length > 0 || status.vt_tray.length > 0) && (
                       <div className="grid grid-cols-4 gap-3">
                       {/* HT AMS units - name/badge top, slot left, stats right */}
                       {htAms.map((ams) => {
@@ -3149,6 +3196,9 @@ function PrinterCard({
                                         trayColor: tray?.tray_color || undefined,
                                         traySubBrands: tray?.tray_sub_brands || undefined,
                                         trayInfoIdx: tray?.tray_info_idx || undefined,
+                                        extruderId: mappedExtruderId,
+                                        caliIdx: tray?.cali_idx,
+                                        savedPresetId: slotPreset?.preset_id,
                                       }),
                                     }}
                                   >
@@ -3162,6 +3212,7 @@ function PrinterCard({
                                         amsId: ams.id,
                                         trayId: htSlotId,
                                         trayCount: ams.tray.length,
+                                        extruderId: mappedExtruderId,
                                       }),
                                     }}
                                   >
@@ -3204,138 +3255,162 @@ function PrinterCard({
                           </div>
                         );
                       })}
-                      {/* External spool - name top, slot below (no stats) */}
-                      {status.vt_tray && status.vt_tray.tray_type && (() => {
-                        const extTray = status.vt_tray;
-                        // Check if external spool is active (tray_now = 254)
-                        const isExtActive = effectiveTrayNow === 254;
-                        // Get cloud preset info if available
-                        const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
-                        // Get saved slot preset mapping (external spool uses amsId=255, trayId=0)
-                        const extSlotPreset = slotPresets?.[255 * 4 + 0];
-
-                        // Fill level fallback chain: Spoolman → Inventory spool
-                        const extTrayTag = extTray.tray_uuid?.toUpperCase();
-                        const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
-                        const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
-                        const extInventoryAssignment = onGetAssignment?.(printer.id, 255, 0);
-                        const extInventoryFill = (() => {
-                          const sp = extInventoryAssignment?.spool;
-                          if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
-                            return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
-                          }
-                          return null;
-                        })();
-                        const extEffectiveFill = extSpoolmanFill ?? extInventoryFill ?? null;
-
-                        // Build filament data for hover card
-                        const extFilamentData = {
-                          vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                          profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
-                          colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
-                          colorHex: extTray.tray_color || null,
-                          kFactor: formatKValue(extTray.k),
-                          fillLevel: extEffectiveFill,
-                          trayUuid: extTray.tray_uuid || null,
-                          tagUid: extTray.tag_uid || null,
-                          fillSource: extSpoolmanFill !== null ? 'spoolman' as const
-                            : extInventoryFill !== null ? 'inventory' as const
-                            : undefined,
-                        };
-
-                        const extSlotContent = (
-                          <div className={`bg-bambu-dark-tertiary rounded p-1 text-center cursor-default ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
-                            <div
-                              className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
-                              style={{
-                                backgroundColor: extTray.tray_color ? `#${extTray.tray_color}` : '#333',
-                                borderColor: isExtActive ? 'var(--accent)' : 'rgba(255,255,255,0.1)',
-                              }}
-                            />
-                            <div className="text-[9px] text-white font-bold truncate">
-                              {extTray.tray_type || 'Spool'}
-                            </div>
-                            {/* Fill bar - use Spoolman data if available */}
-                            <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
-                              {extSpoolmanFill !== null ? (
-                                <div
-                                  className="h-full rounded-full transition-all"
-                                  style={{
-                                    width: `${extSpoolmanFill}%`,
-                                    backgroundColor: getFillBarColor(extSpoolmanFill),
-                                  }}
-                                />
-                              ) : (
-                                <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
-                              )}
-                            </div>
+                      {/* External spool(s) - grouped in one card like regular AMS */}
+                      {status.vt_tray.length > 0 && (
+                        <div className={`p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30 ${status.vt_tray.length === 1 ? 'max-w-[50%]' : ''}`}>
+                          <div className="flex items-center gap-1 mb-2">
+                            <span className="text-[10px] text-white font-medium">{t('printers.external')}</span>
                           </div>
-                        );
-
-                        return (
-                          <div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
-                            {/* Row 1: Label */}
-                            <div className="flex items-center gap-1 mb-2">
-                              <span className="text-[10px] text-white font-medium">{t('printers.external')}</span>
-                            </div>
-                            {/* Row 2: Slot (full width since no stats) */}
-                            <FilamentHoverCard
-                              data={extFilamentData}
-                              spoolman={{
-                                enabled: spoolmanEnabled,
-                                hasUnlinkedSpools,
-                                linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()]?.id : undefined,
-                                spoolmanUrl,
-                                onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
-                                  setLinkSpoolModal({
-                                    tagUid: extFilamentData.tagUid || '',
-                                    trayUuid: uuid,
-                                    printerId: printer.id,
-                                    amsId: 255,
-                                    trayId: 0,
-                                  });
-                                } : undefined,
-                              }}
-                              inventory={(() => {
-                                const assignment = onGetAssignment?.(printer.id, 255, 0);
-                                return {
-                                  assignedSpool: assignment?.spool ? {
-                                    id: assignment.spool.id,
-                                    material: assignment.spool.material,
-                                    brand: assignment.spool.brand,
-                                    color_name: assignment.spool.color_name,
-                                  } : null,
-                                  onAssignSpool: extFilamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
-                                    printerId: printer.id,
-                                    amsId: 255,
-                                    trayId: 0,
-                                    trayInfo: {
-                                      type: extFilamentData.profile,
-                                      color: extFilamentData.colorHex || '',
-                                      location: 'External Spool',
-                                    },
-                                  }) : undefined,
-                                  onUnassignSpool: assignment && extFilamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, 255, 0) : undefined,
-                                };
-                              })()}
-                              configureSlot={{
-                                enabled: hasPermission('printers:control'),
-                                onConfigure: () => setConfigureSlotModal({
-                                  amsId: 255, // External spool indicator
-                                  trayId: 0,
-                                  trayCount: 1, // External = single slot
-                                  trayType: extTray.tray_type || undefined,
-                                  trayColor: extTray.tray_color || undefined,
-                                  traySubBrands: extTray.tray_sub_brands || undefined,
-                                  trayInfoIdx: extTray.tray_info_idx || undefined,
-                                }),
-                              }}
-                            >
-                              {extSlotContent}
-                            </FilamentHoverCard>
+                          <div className={`grid ${status.vt_tray.length > 1 ? 'grid-cols-2' : 'grid-cols-1'} gap-1.5`}>
+                            {[...status.vt_tray].sort((a, b) => (a.id ?? 254) - (b.id ?? 254)).map((extTray) => {
+                              const extTrayId = extTray.id ?? 254;
+                              const isExtActive = effectiveTrayNow === extTrayId;
+                              const slotTrayId = extTrayId - 254; // 0 or 1
+                              const extLabel = isDualNozzle
+                                ? (extTrayId === 254 ? t('printers.extL') : t('printers.extR'))
+                                : '';
+                              const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
+                              const extSlotPreset = slotPresets?.[255 * 4 + slotTrayId];
+
+                              const extTrayTag = extTray.tray_uuid?.toUpperCase();
+                              const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
+                              const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
+                              const extInventoryAssignment = onGetAssignment?.(printer.id, 255, slotTrayId);
+                              const extInventoryFill = (() => {
+                                const sp = extInventoryAssignment?.spool;
+                                if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
+                                  return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
+                                }
+                                return null;
+                              })();
+                              const extEffectiveFill = extSpoolmanFill ?? extInventoryFill ?? null;
+
+                              const extFilamentData = {
+                                vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
+                                profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
+                                colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
+                                colorHex: extTray.tray_color || null,
+                                kFactor: formatKValue(extTray.k),
+                                fillLevel: extEffectiveFill,
+                                trayUuid: extTray.tray_uuid || null,
+                                tagUid: extTray.tag_uid || null,
+                                fillSource: extSpoolmanFill !== null ? 'spoolman' as const
+                                  : extInventoryFill !== null ? 'inventory' as const
+                                  : undefined,
+                              };
+
+                              const isEmpty = !extTray.tray_type;
+                              const extSlotContent = (
+                                <div className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
+                                  <div
+                                    className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
+                                    style={{
+                                      backgroundColor: extTray.tray_color ? `#${extTray.tray_color}` : (extTray.tray_type ? '#333' : 'transparent'),
+                                      borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
+                                      borderStyle: isEmpty ? 'dashed' : 'solid',
+                                    }}
+                                  />
+                                  <div className={`text-[9px] font-bold truncate ${isEmpty ? 'text-white/40' : 'text-white'}`}>
+                                    {extTray.tray_type || '—'}
+                                  </div>
+                                  <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
+                                    {extEffectiveFill !== null && extEffectiveFill >= 0 && !isEmpty ? (
+                                      <div
+                                        className="h-full rounded-full transition-all"
+                                        style={{
+                                          width: `${extEffectiveFill}%`,
+                                          backgroundColor: getFillBarColor(extEffectiveFill),
+                                        }}
+                                      />
+                                    ) : !isEmpty ? (
+                                      <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
+                                    ) : null}
+                                  </div>
+                                  {extLabel && <div className="text-[7px] text-white/40 mt-0.5 truncate">{extLabel}</div>}
+                                </div>
+                              );
+
+                              return (
+                                <div key={extTrayId} className="relative group">
+                                  {!isEmpty ? (
+                                    <FilamentHoverCard
+                                      data={extFilamentData}
+                                      spoolman={{
+                                        enabled: spoolmanEnabled,
+                                        hasUnlinkedSpools,
+                                        linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()]?.id : undefined,
+                                        spoolmanUrl,
+                                        onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
+                                          setLinkSpoolModal({
+                                            tagUid: extFilamentData.tagUid || '',
+                                            trayUuid: uuid,
+                                            printerId: printer.id,
+                                            amsId: 255,
+                                            trayId: slotTrayId,
+                                          });
+                                        } : undefined,
+                                      }}
+                                      inventory={(() => {
+                                        const assignment = onGetAssignment?.(printer.id, 255, slotTrayId);
+                                        return {
+                                          assignedSpool: assignment?.spool ? {
+                                            id: assignment.spool.id,
+                                            material: assignment.spool.material,
+                                            brand: assignment.spool.brand,
+                                            color_name: assignment.spool.color_name,
+                                          } : null,
+                                          onAssignSpool: () => setAssignSpoolModal({
+                                            printerId: printer.id,
+                                            amsId: 255,
+                                            trayId: slotTrayId,
+                                            trayInfo: {
+                                              type: extFilamentData.profile,
+                                              color: extFilamentData.colorHex || '',
+                                              location: extLabel || t('printers.external'),
+                                            },
+                                          }),
+                                          onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, 255, slotTrayId) : undefined,
+                                        };
+                                      })()}
+                                      configureSlot={{
+                                        enabled: hasPermission('printers:control'),
+                                        onConfigure: () => setConfigureSlotModal({
+                                          amsId: 255,
+                                          trayId: slotTrayId,
+                                          trayCount: 1,
+                                          trayType: extTray.tray_type || undefined,
+                                          trayColor: extTray.tray_color || undefined,
+                                          traySubBrands: extTray.tray_sub_brands || undefined,
+                                          trayInfoIdx: extTray.tray_info_idx || undefined,
+                                          extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
+                                          caliIdx: extTray.cali_idx,
+                                          savedPresetId: extSlotPreset?.preset_id,
+                                        }),
+                                      }}
+                                    >
+                                      {extSlotContent}
+                                    </FilamentHoverCard>
+                                  ) : (
+                                    <EmptySlotHoverCard
+                                      configureSlot={{
+                                        enabled: hasPermission('printers:control'),
+                                        onConfigure: () => setConfigureSlotModal({
+                                          amsId: 255,
+                                          trayId: slotTrayId,
+                                          trayCount: 1,
+                                          extruderId: isDualNozzle ? (extTrayId === 254 ? 1 : 0) : undefined,
+                                        }),
+                                      }}
+                                    >
+                                      {extSlotContent}
+                                    </EmptySlotHoverCard>
+                                  )}
+                                </div>
+                              );
+                            })}
                           </div>
-                        );
-                      })()}
+                        </div>
+                      )}
                       </div>
                     )}
                   </div>
@@ -3974,6 +4049,7 @@ function PrinterCard({
           onClose={() => setConfigureSlotModal(null)}
           printerId={printer.id}
           slotInfo={configureSlotModal}
+          printerModel={mapModelCode(printer.model) || undefined}
           onSuccess={() => {
             // Refresh slot presets to show updated profile name
             queryClient.invalidateQueries({ queryKey: ['slotPresets', printer.id] });
@@ -4128,43 +4204,7 @@ function AddPrinterModal({
     }
   };
 
-  // Map SSDP model codes to dropdown values
-  const mapModelCode = (ssdpModel: string | null): string => {
-    if (!ssdpModel) return '';
-    const modelMap: Record<string, string> = {
-      // H2 Series
-      'O1D': 'H2D',
-      'O1E': 'H2D Pro',  // Some devices report O1E
-      'O2D': 'H2D Pro',  // Some devices report O2D
-      'O1C': 'H2C',
-      'O1S': 'H2S',
-      // X1 Series
-      'BL-P001': 'X1C',
-      'BL-P002': 'X1',
-      'BL-P003': 'X1E',
-      // P Series
-      'C11': 'P1S',
-      'C12': 'P1P',
-      'C13': 'P2S',
-      // A1 Series
-      'N2S': 'A1',
-      'N1': 'A1 Mini',
-      // Direct matches
-      'X1C': 'X1C',
-      'X1': 'X1',
-      'X1E': 'X1E',
-      'P1S': 'P1S',
-      'P1P': 'P1P',
-      'P2S': 'P2S',
-      'A1': 'A1',
-      'A1 Mini': 'A1 Mini',
-      'H2D': 'H2D',
-      'H2D Pro': 'H2D Pro',
-      'H2C': 'H2C',
-      'H2S': 'H2S',
-    };
-    return modelMap[ssdpModel] || ssdpModel;
-  };
+  // Reuse module-level mapModelCode
 
   const selectPrinter = (printer: DiscoveredPrinter) => {
     // Don't pre-fill serial if it's a placeholder (unknown-*) - user needs to enter actual serial

+ 1 - 1
frontend/src/utils/amsHelpers.ts

@@ -88,7 +88,7 @@ export function getGlobalTrayId(
   trayId: number,
   isExternal: boolean
 ): number {
-  if (isExternal) return 254;
+  if (isExternal) return 254 + trayId;
   // AMS-HT units have IDs starting at 128 with a single tray — use ID directly
   if (amsId >= 128) return amsId;
   return amsId * 4 + trayId;

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BOd5pCVD.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CS3Lw7Ok.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-D--TAtCz.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DZOdnZuT.css


+ 2 - 2
static/index.html

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

Some files were not shown because too many files changed in this diff