Browse Source

Merge branch '0.2.0b' into feature/mobile_view_fix

Keybored 3 months ago
parent
commit
c5ff824372
55 changed files with 1986 additions and 858 deletions
  1. 16 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. 11 7
      backend/app/api/routes/settings.py
  8. 3 1
      backend/app/api/routes/support.py
  9. 13 0
      backend/app/api/routes/updates.py
  10. 482 11
      backend/app/core/catalog_defaults.py
  11. 11 4
      backend/app/main.py
  12. 1 1
      backend/app/schemas/printer.py
  13. 0 5
      backend/app/schemas/settings.py
  14. 210 114
      backend/app/services/bambu_mqtt.py
  15. 21 21
      backend/app/services/print_scheduler.py
  16. 34 31
      backend/app/services/printer_manager.py
  17. 34 34
      backend/app/services/spool_tag_matcher.py
  18. 9 8
      backend/app/services/spoolman_tracking.py
  19. 11 2
      backend/app/services/usage_tracker.py
  20. 15 12
      backend/tests/unit/services/test_printer_manager.py
  21. 2 2
      backend/tests/unit/services/test_spoolman_tracking.py
  22. 4 4
      backend/tests/unit/test_scheduler_ams_mapping.py
  23. 1 0
      frontend/package-lock.json
  24. 1 0
      frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx
  25. 77 0
      frontend/src/__tests__/components/ConfigureAmsSlotModal.test.tsx
  26. 1 1
      frontend/src/__tests__/components/PrintModal.test.tsx
  27. 3 3
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  28. 1 0
      frontend/src/__tests__/pages/PrintersPage.test.tsx
  29. 9 6
      frontend/src/api/client.ts
  30. 7 56
      frontend/src/components/AssignSpoolModal.tsx
  31. 1 1
      frontend/src/components/ColorCatalogSettings.tsx
  32. 180 39
      frontend/src/components/ConfigureAmsSlotModal.tsx
  33. 2 2
      frontend/src/components/ConfirmModal.tsx
  34. 66 5
      frontend/src/components/KProfilesView.tsx
  35. 4 127
      frontend/src/components/PrintModal/index.tsx
  36. 1 1
      frontend/src/components/SpoolCatalogSettings.tsx
  37. 4 26
      frontend/src/components/SpoolmanSettings.tsx
  38. 20 16
      frontend/src/hooks/useFilamentMapping.ts
  39. 26 9
      frontend/src/i18n/locales/de.ts
  40. 26 9
      frontend/src/i18n/locales/en.ts
  41. 26 9
      frontend/src/i18n/locales/fr.ts
  42. 37 18
      frontend/src/i18n/locales/it.ts
  43. 26 9
      frontend/src/i18n/locales/ja.ts
  44. 5 0
      frontend/src/pages/ArchivesPage.tsx
  45. 23 6
      frontend/src/pages/InventoryPage.tsx
  46. 227 183
      frontend/src/pages/PrintersPage.tsx
  47. 1 0
      frontend/src/pages/SettingsPage.tsx
  48. 1 1
      frontend/src/utils/amsHelpers.ts
  49. 10 1
      install/start_bambuddy.bat
  50. 0 0
      static/assets/index-BOd5pCVD.js
  51. 0 0
      static/assets/index-C9JxyacH.js
  52. 0 0
      static/assets/index-DZOdnZuT.css
  53. 0 0
      static/assets/index-OqmBOPoC.css
  54. 2 2
      static/index.html
  55. 1 0
      update_website_wiki.sh

+ 16 - 0
CHANGELOG.md

@@ -11,15 +11,27 @@ 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
+- **Update Check Runs When Disabled** ([#367](https://github.com/maziggy/bambuddy/issues/367)) — The Settings page triggered an update check on every visit even when "Check for updates" was disabled, causing error popups on air-gapped systems with no internet. The backend `/updates/check` endpoint also ignored the setting entirely. Now the backend returns early without making GitHub API calls when the setting is disabled, the Settings page respects the `check_updates` flag before auto-fetching, and the printer card firmware badge shows a neutral version-only display instead of disappearing when firmware update checks are off.
+- **Stale Inventory Assignments Persist After Switching to Spoolman Mode** — When switching from built-in inventory to Spoolman mode, existing spool-to-AMS-slot assignments were not cleaned up. The printer card hover cards continued showing "Assign Spool" buttons that opened the internal inventory modal, and any prior assignments remained visible. Now bulk-deletes all `SpoolAssignment` records when enabling Spoolman, invalidates the frontend cache so printer cards update immediately, and hides the inventory assign/unassign UI on printer cards while in Spoolman mode.
 - **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.
 - **Model-Specific Maintenance Tasks for Carbon Rods vs Linear Rails** ([#351](https://github.com/maziggy/bambuddy/issues/351)) — Maintenance tasks "Clean Carbon Rods" and "Lubricate Linear Rails" were shown for all printers regardless of motion system. H2 and A1 series use linear rails (not carbon rods), and X1/P1/P2S series use carbon rods (not linear rails). Maintenance types are now classified by rod/rail type: "Lubricate Carbon Rods" and "Clean Carbon Rods" for X1/P1/P2S, "Lubricate Linear Rails" and "Clean Linear Rails" for A1/H2. Stale and duplicate system types are automatically cleaned up on startup. Includes model-specific wiki links and i18n keys for all 4 locales.
 - **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`).
+- **AMS Slot Configure — Black Filament Color Not Pre-Populated** — When re-opening the Configure AMS Slot modal for a slot with black filament, the color field was empty despite the preset and K-profile being correctly pre-selected. The color pre-population logic excluded hex `000000` (black) as a guard against empty slots, but empty slots already skip color data entirely. Removed the unnecessary check so black is now pre-populated like any other color.
+- **Archive List View Not Labeling Failed Prints** ([#365](https://github.com/maziggy/bambuddy/issues/365)) — The archive grid view displayed a red "Failed" / "Cancelled" badge on failed and aborted prints, but the list view had no equivalent indicator. Now shows an inline status badge next to the print name in list view.
+- **Inventory Spool Weight Resets After Print Completes** — After a print, the usage tracker correctly updated `weight_used` (e.g., +1.6g), but periodic AMS status updates recalculated `weight_used` from the AMS remain% sensor and overwrote the precise value. For small prints on large spools (e.g., 1.6g on 1000g), the AMS remain% stays at 100% (integer resolution = 10g steps), resetting `weight_used` back to 0. The AMS weight sync now only increases `weight_used`, never decreases it, preserving precise values from the usage tracker.
 
 ### Improved
+- **Reduce Log Noise from MQTT Diagnostics** ([#365](https://github.com/maziggy/bambuddy/issues/365)) — Downgraded 58 high-frequency MQTT diagnostic messages from INFO to DEBUG level. Payload dumps, detector state changes, field discovery logs, H2D disambiguation, and periodic status updates no longer flood the log at the default INFO level. Also suppresses paho-mqtt library INFO messages in production. User-initiated actions (print start/stop, AMS load/unload, calibration) remain at INFO. All diagnostic detail is still available when debug logging is enabled.
 - **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.
 - **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.
 - **H2C Nozzle Rack Text Unreadable on Light Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white "0.4" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds.
@@ -36,6 +48,10 @@ All notable changes to Bambuddy will be documented in this file.
 - **Skip Objects: Confirmation Dialog** ([#346](https://github.com/maziggy/bambuddy/issues/346)) — Added a warning confirmation modal before skipping an object during a print. Shows the object name and warns the action is irreversible. Prevents accidentally skipping the wrong object. Translated in all 4 locales (en, de, ja, it).
 - **Additional Currency Options** ([#329](https://github.com/maziggy/bambuddy/issues/329), [#333](https://github.com/maziggy/bambuddy/issues/333)) — Added 17 additional currencies to the cost tracking dropdown: HKD, INR, KRW, SEK, NOK, DKK, PLN, BRL, TWD, SGD, NZD, MXN, CZK, THB, ZAR, RUB.
 - **Move Email Settings Under Authentication Tab** — Renamed the settings "Users" tab to "Authentication" and moved the standalone "Global Email" tab into it as an "Email Authentication" sub-tab. Groups email/SMTP configuration with user management where it logically belongs. Legacy `?tab=email` URLs are handled automatically.
+- **Inventory — Confirmation Modals for Delete & Archive** — The inventory page now uses the app's styled confirmation modal for both delete and archive actions. Previously, delete used the browser's native `confirm()` dialog and archive had no confirmation at all. Delete shows a danger-styled modal, archive shows a warning-styled modal. Translated in all 5 locales (en, de, fr, it, ja).
+- **Default Color Catalog Expanded to 638 Colors Across 20 Brands** — The built-in filament color catalog has been expanded from 258 entries (6 brands) to 638 entries (20 brands). Added Overture, Sunlu, Creality, Elegoo, Jayo, Inland, Eryone, ColorFabb, Fillamentum, FormFutura, Fiberlogy, MatterHackers, Protopasta, 3DXTECH, and Sakata3D. eSUN expanded from 10 generic placeholder entries to 79 measured colors across 10 material lines (PLA+, Pro PLA+, PLA, PLA Silk, PLA Metal, PLA-ST, PETG, PETG-HS, ABS, ABS+). All hex codes sourced from FilamentColors.xyz measured swatches.
+- **Settings — Built-in Inventory Feature Note** — Added a note in Settings > Filament > Built-in Inventory that third-party spools can be assigned to inventory spools for tracking.
+- **Catalog Settings Cards Taller** — Spool Catalog and Color Catalog settings panels increased from 400px to 600px max height for better browsability with the expanded default catalogs.
 
 ## [0.1.9] - 2026-02-10
 

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

+ 11 - 7
backend/app/api/routes/settings.py

@@ -6,7 +6,7 @@ from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, UploadFile
 from fastapi.responses import JSONResponse, StreamingResponse
-from sqlalchemy import select
+from sqlalchemy import delete, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
@@ -87,7 +87,6 @@ async def get_settings(
                 "spoolman_enabled",
                 "spoolman_disable_weight_sync",
                 "spoolman_report_partial_usage",
-                "disable_filament_warnings",
                 "check_updates",
                 "check_printer_firmware",
                 "virtual_printer_enabled",
@@ -238,7 +237,6 @@ async def get_spoolman_settings(
     spoolman_sync_mode = await get_setting(db, "spoolman_sync_mode") or "auto"
     spoolman_disable_weight_sync = await get_setting(db, "spoolman_disable_weight_sync") or "false"
     spoolman_report_partial_usage = await get_setting(db, "spoolman_report_partial_usage") or "true"
-    disable_filament_warnings = await get_setting(db, "disable_filament_warnings") or "false"
 
     return {
         "spoolman_enabled": spoolman_enabled,
@@ -246,7 +244,6 @@ async def get_spoolman_settings(
         "spoolman_sync_mode": spoolman_sync_mode,
         "spoolman_disable_weight_sync": spoolman_disable_weight_sync,
         "spoolman_report_partial_usage": spoolman_report_partial_usage,
-        "disable_filament_warnings": disable_filament_warnings,
     }
 
 
@@ -258,7 +255,16 @@ async def update_spoolman_settings(
 ):
     """Update Spoolman integration settings."""
     if "spoolman_enabled" in settings:
-        await set_setting(db, "spoolman_enabled", settings["spoolman_enabled"])
+        old_val = await get_setting(db, "spoolman_enabled") or "false"
+        new_val = settings["spoolman_enabled"]
+        await set_setting(db, "spoolman_enabled", new_val)
+
+        # Switching to Spoolman: clear built-in inventory slot assignments
+        if old_val.lower() != "true" and new_val.lower() == "true":
+            from backend.app.models.spool_assignment import SpoolAssignment
+
+            result = await db.execute(delete(SpoolAssignment))
+            logger.info("Cleared %d spool assignments on switch to Spoolman mode", result.rowcount)
     if "spoolman_url" in settings:
         await set_setting(db, "spoolman_url", settings["spoolman_url"])
     if "spoolman_sync_mode" in settings:
@@ -267,8 +273,6 @@ async def update_spoolman_settings(
         await set_setting(db, "spoolman_disable_weight_sync", settings["spoolman_disable_weight_sync"])
     if "spoolman_report_partial_usage" in settings:
         await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
-    if "disable_filament_warnings" in settings:
-        await set_setting(db, "disable_filament_warnings", settings["disable_filament_warnings"])
 
     await db.commit()
     db.expire_all()

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

@@ -106,10 +106,12 @@ def _apply_log_level(debug: bool):
         logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
         logging.getLogger("httpcore").setLevel(logging.DEBUG)
         logging.getLogger("httpx").setLevel(logging.DEBUG)
+        logging.getLogger("paho.mqtt").setLevel(logging.DEBUG)
     else:
         logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
         logging.getLogger("httpcore").setLevel(logging.WARNING)
         logging.getLogger("httpx").setLevel(logging.WARNING)
+        logging.getLogger("paho.mqtt").setLevel(logging.WARNING)
 
     logger.info("Log level changed to %s", "DEBUG" if debug else "INFO")
 
@@ -473,7 +475,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(
                 {

+ 13 - 0
backend/app/api/routes/updates.py

@@ -9,12 +9,14 @@ import sys
 
 import httpx
 from fastapi import APIRouter, BackgroundTasks, Depends
+from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.models.settings import Settings
 from backend.app.models.user import User
 
 logger = logging.getLogger(__name__)
@@ -176,6 +178,17 @@ async def check_for_updates(
     """Check GitHub for available updates."""
     global _update_status
 
+    # Respect the check_updates setting
+    result = await db.execute(select(Settings).where(Settings.key == "check_updates"))
+    setting = result.scalar_one_or_none()
+    if setting and setting.value.lower() == "false":
+        return {
+            "update_available": False,
+            "current_version": APP_VERSION,
+            "latest_version": None,
+            "message": "Update checks are disabled",
+        }
+
     _update_status = {
         "status": "checking",
         "progress": 0,

+ 482 - 11
backend/app/core/catalog_defaults.py

@@ -330,17 +330,95 @@ DEFAULT_COLOR_CATALOG: list[tuple[str, str, str, str]] = [
     ("Prusament", "Azure Blue", "#007FFF", "PLA"),
     ("Prusament", "Royal Blue", "#4169E1", "PLA"),
     ("Prusament", "Mystic Purple", "#7B68EE", "PLA"),
-    # eSUN PLA+
-    ("eSUN", "White", "#FFFFFF", "PLA+"),
-    ("eSUN", "Black", "#000000", "PLA+"),
-    ("eSUN", "Grey", "#808080", "PLA+"),
-    ("eSUN", "Red", "#FF0000", "PLA+"),
-    ("eSUN", "Blue", "#0000FF", "PLA+"),
-    ("eSUN", "Green", "#00FF00", "PLA+"),
-    ("eSUN", "Yellow", "#FFFF00", "PLA+"),
-    ("eSUN", "Orange", "#FFA500", "PLA+"),
-    ("eSUN", "Purple", "#800080", "PLA+"),
-    ("eSUN", "Pink", "#FFC0CB", "PLA+"),
+    # eSUN PLA+ (from FilamentColors.xyz measured swatches)
+    ("eSUN", "Beige", "#ECCAB0", "PLA+"),
+    ("eSUN", "Black", "#373838", "PLA+"),
+    ("eSUN", "Blue", "#054795", "PLA+"),
+    ("eSUN", "Bone White", "#C2BAA7", "PLA+"),
+    ("eSUN", "Brown", "#6F513C", "PLA+"),
+    ("eSUN", "Cool White", "#E1E4E5", "PLA+"),
+    ("eSUN", "Dark Blue", "#2F314D", "PLA+"),
+    ("eSUN", "Fire Engine Red", "#91202B", "PLA+"),
+    ("eSUN", "Gold", "#C99B26", "PLA+"),
+    ("eSUN", "Gray", "#697480", "PLA+"),
+    ("eSUN", "Green", "#015E58", "PLA+"),
+    ("eSUN", "Grey", "#5F6574", "PLA+"),
+    ("eSUN", "Light Blue", "#48BFD5", "PLA+"),
+    ("eSUN", "Light Brown", "#A27556", "PLA+"),
+    ("eSUN", "Luminous Blue", "#C8CAC8", "PLA+"),
+    ("eSUN", "Magenta", "#DA3B6C", "PLA+"),
+    ("eSUN", "Olive Green", "#555B45", "PLA+"),
+    ("eSUN", "Orange", "#EF7749", "PLA+"),
+    ("eSUN", "Peak Green", "#A1DA7C", "PLA+"),
+    ("eSUN", "Pink", "#E78397", "PLA+"),
+    ("eSUN", "Purple", "#8350A4", "PLA+"),
+    ("eSUN", "Red", "#C4402A", "PLA+"),
+    ("eSUN", "Silver", "#8B8889", "PLA+"),
+    ("eSUN", "Skin", "#E3C7AF", "PLA+"),
+    ("eSUN", "White", "#E1E9E9", "PLA+"),
+    ("eSUN", "Yellow", "#FBCE2B", "PLA+"),
+    # eSUN Pro PLA+
+    ("eSUN", "Blue", "#065AA1", "Pro PLA+"),
+    # eSUN PLA
+    ("eSUN", "Glow in the Dark", "#C5C2AB", "PLA"),
+    ("eSUN", "Marble White", "#B5BCC0", "PLA"),
+    ("eSUN", "Natural Wood", "#EBCFA6", "PLA"),
+    ("eSUN", "Pine Green", "#375C49", "PLA"),
+    ("eSUN", "UV Change Purple", "#CABBA9", "PLA"),
+    ("eSUN", "eTwinkling Blue", "#115CAF", "PLA"),
+    ("eSUN", "eStars Galaxy Black", "#403936", "PLA"),
+    # eSUN PLA Silk
+    ("eSUN", "Silk Blue", "#2275AA", "PLA Silk"),
+    ("eSUN", "Silk Bronze", "#829172", "PLA Silk"),
+    ("eSUN", "Silk Copper", "#AE6B2F", "PLA Silk"),
+    ("eSUN", "Silk Cyan", "#34A7CF", "PLA Silk"),
+    ("eSUN", "Silk Dark Yellow", "#D4A62E", "PLA Silk"),
+    ("eSUN", "Silk Gold", "#C48E2F", "PLA Silk"),
+    ("eSUN", "Silk Green", "#7FCB43", "PLA Silk"),
+    ("eSUN", "Silk Jacinth", "#DA8061", "PLA Silk"),
+    ("eSUN", "Silk Lime", "#C1D762", "PLA Silk"),
+    ("eSUN", "Silk Magic Green Blue", "#508669", "PLA Silk"),
+    ("eSUN", "Silk Purple", "#905295", "PLA Silk"),
+    ("eSUN", "Silk Red", "#C94830", "PLA Silk"),
+    ("eSUN", "Silk Rose Gold", "#C7886B", "PLA Silk"),
+    ("eSUN", "Silk Silver", "#B5C1C5", "PLA Silk"),
+    ("eSUN", "Silk Violet", "#B93CA1", "PLA Silk"),
+    ("eSUN", "Silk White", "#E3E0DB", "PLA Silk"),
+    ("eSUN", "Silk Yellow", "#DED74B", "PLA Silk"),
+    # eSUN PLA Metal
+    ("eSUN", "Bronze", "#917F57", "PLA Metal"),
+    # eSUN PLA-ST
+    ("eSUN", "Grey", "#626C77", "PLA-ST"),
+    # eSUN PETG
+    ("eSUN", "Black", "#353434", "PETG"),
+    ("eSUN", "Magenta", "#E03E76", "PETG"),
+    ("eSUN", "Solid Blue", "#1A6FB4", "PETG"),
+    ("eSUN", "Solid Green", "#008A58", "PETG"),
+    ("eSUN", "Solid Purple", "#7A4795", "PETG"),
+    ("eSUN", "Solid White", "#F4F1F1", "PETG"),
+    ("eSUN", "Solid Yellow", "#F0CA41", "PETG"),
+    ("eSUN", "Translucent Green", "#378041", "PETG"),
+    ("eSUN", "Translucent Orange", "#DD7135", "PETG"),
+    ("eSUN", "White", "#E7EDED", "PETG"),
+    # eSUN PETG-HS (High Speed)
+    ("eSUN", "Black", "#424445", "PETG-HS"),
+    ("eSUN", "Solid Blue", "#1A6FB4", "PETG-HS"),
+    # eSUN ABS
+    ("eSUN", "Black", "#3F3A3F", "ABS"),
+    ("eSUN", "Brown", "#624741", "ABS"),
+    ("eSUN", "Natural", "#D9E3DD", "ABS"),
+    ("eSUN", "Pine Green", "#3C694E", "ABS"),
+    ("eSUN", "Pink", "#E86477", "ABS"),
+    ("eSUN", "Red", "#A74237", "ABS"),
+    ("eSUN", "Silver", "#838080", "ABS"),
+    # eSUN ABS+
+    ("eSUN", "Gray", "#616777", "ABS+"),
+    ("eSUN", "Green", "#018068", "ABS+"),
+    ("eSUN", "Natural", "#E4DEC9", "ABS+"),
+    ("eSUN", "Orange", "#EE7845", "ABS+"),
+    ("eSUN", "Silver", "#7F807E", "ABS+"),
+    ("eSUN", "White", "#E1E1DF", "ABS+"),
+    ("eSUN", "Yellow", "#D3BC0F", "ABS+"),
     # Hatchbox PLA
     ("Hatchbox", "White", "#FFFFFF", "PLA"),
     ("Hatchbox", "Black", "#000000", "PLA"),
@@ -354,4 +432,397 @@ DEFAULT_COLOR_CATALOG: list[tuple[str, str, str, str]] = [
     ("Hatchbox", "Pink", "#FFC0CB", "PLA"),
     ("Hatchbox", "True Blue", "#0073CF", "PLA"),
     ("Hatchbox", "True Green", "#008000", "PLA"),
+    # Overture PLA (from FilamentColors.xyz measured swatches)
+    ("Overture", "Black", "#2B292E", "PLA"),
+    ("Overture", "Blue", "#034070", "PLA"),
+    ("Overture", "Cement Gray", "#48494A", "PLA"),
+    ("Overture", "Dark Blue", "#124775", "PLA"),
+    ("Overture", "Fresh Red", "#C01F1D", "PLA"),
+    ("Overture", "Gray Blue", "#6D8790", "PLA"),
+    ("Overture", "Green", "#318C49", "PLA"),
+    ("Overture", "Highlight Yellow", "#FBF93C", "PLA"),
+    ("Overture", "Light Blue", "#7CC4D5", "PLA"),
+    ("Overture", "Light Gray", "#8F9694", "PLA"),
+    ("Overture", "Neon Green Air", "#C5ED33", "PLA"),
+    ("Overture", "Olive Green", "#8F843D", "PLA"),
+    ("Overture", "Pink", "#DC99B4", "PLA"),
+    ("Overture", "Red", "#C9341A", "PLA"),
+    ("Overture", "Royal Gold", "#C58F31", "PLA"),
+    ("Overture", "Space Grey", "#797779", "PLA"),
+    ("Overture", "White", "#E7EBE3", "PLA"),
+    # Overture PLA Matte
+    ("Overture", "Black", "#3F3E41", "PLA Matte"),
+    ("Overture", "Blue", "#277EAB", "PLA Matte"),
+    ("Overture", "Brick Red", "#AE4848", "PLA Matte"),
+    ("Overture", "Green", "#5EAE73", "PLA Matte"),
+    ("Overture", "Light Grey", "#919598", "PLA Matte"),
+    ("Overture", "Light Brown", "#BF9C80", "PLA Matte"),
+    ("Overture", "Light Green", "#A1C1A5", "PLA Matte"),
+    ("Overture", "Olive Green", "#B59837", "PLA Matte"),
+    ("Overture", "Orange", "#F59752", "PLA Matte"),
+    ("Overture", "Pink", "#EBBDCE", "PLA Matte"),
+    ("Overture", "Purple", "#978DC5", "PLA Matte"),
+    ("Overture", "White", "#E1E4DD", "PLA Matte"),
+    ("Overture", "Yellow", "#FFD359", "PLA Matte"),
+    # Overture PLA Pro
+    ("Overture", "Digital Blue", "#008FBE", "PLA Pro"),
+    ("Overture", "Light Blue", "#68C8DB", "PLA Pro"),
+    ("Overture", "Orange", "#F27C1B", "PLA Pro"),
+    ("Overture", "Purple", "#7B5DB0", "PLA Pro"),
+    ("Overture", "Red", "#E62F18", "PLA Pro"),
+    ("Overture", "Yellow", "#DFB233", "PLA Pro"),
+    # Overture PETG
+    ("Overture", "Black", "#2F2821", "PETG"),
+    ("Overture", "Blue", "#225291", "PETG"),
+    ("Overture", "Clear", "#BEC3C5", "PETG"),
+    ("Overture", "Pink", "#E0A1BA", "PETG"),
+    ("Overture", "Purple", "#67518F", "PETG"),
+    ("Overture", "Rock White", "#C2C8C9", "PETG"),
+    ("Overture", "Red", "#AB291B", "PETG"),
+    ("Overture", "Space Grey", "#80817E", "PETG"),
+    ("Overture", "Translucent Blue", "#38487B", "PETG"),
+    ("Overture", "White", "#E7E9E7", "PETG"),
+    ("Overture", "Yellow", "#E6B93C", "PETG"),
+    # Overture ABS
+    ("Overture", "Diamond Gray", "#5D5F5F", "ABS"),
+    ("Overture", "Diamond Purple", "#6B649D", "ABS"),
+    # Overture Silk PLA
+    ("Overture", "Gold", "#CA9B52", "Silk PLA"),
+    ("Overture", "Neon Green", "#C2D74D", "Silk PLA"),
+    ("Overture", "Copper", "#B27052", "Silk PLA"),
+    # Overture Glow PLA
+    ("Overture", "Glow Blue", "#4EA2AA", "Glow PLA"),
+    ("Overture", "Glow Orange", "#C2895E", "Glow PLA"),
+    ("Overture", "Glow Red", "#C27B7D", "Glow PLA"),
+    ("Overture", "Glow Yellow", "#E3F079", "Glow PLA"),
+    # Sunlu PLA (from FilamentColors.xyz measured swatches)
+    ("Sunlu", "Black", "#3C3C3C", "PLA"),
+    ("Sunlu", "Blue", "#006AB8", "PLA"),
+    ("Sunlu", "Cherry Red", "#EA4A5D", "PLA"),
+    ("Sunlu", "Glow in the Dark", "#CBCAB8", "PLA"),
+    ("Sunlu", "Green Mint", "#4CCB9A", "PLA"),
+    ("Sunlu", "Grey", "#6B6E6E", "PLA"),
+    ("Sunlu", "Orange", "#E77932", "PLA"),
+    ("Sunlu", "Red", "#AC3637", "PLA"),
+    ("Sunlu", "Sky Blue", "#0CB7CC", "PLA"),
+    ("Sunlu", "Sunny Orange", "#FF7235", "PLA"),
+    ("Sunlu", "Transparent", "#C8C7BF", "PLA"),
+    ("Sunlu", "Transparent Orange", "#DB7F42", "PLA"),
+    ("Sunlu", "White", "#DEDFD9", "PLA"),
+    ("Sunlu", "Wood", "#D5BA95", "PLA"),
+    # Sunlu PLA Silk
+    ("Sunlu", "Silk Black", "#737272", "PLA Silk"),
+    ("Sunlu", "Silk Green", "#34C0A5", "PLA Silk"),
+    ("Sunlu", "Silk Red", "#CD5C62", "PLA Silk"),
+    ("Sunlu", "Silky Silver", "#C6CBD0", "PLA Silk"),
+    # Sunlu PLA Meta
+    ("Sunlu", "Blue", "#00B2CC", "PLA Meta"),
+    ("Sunlu", "Mint Green", "#03A490", "PLA Meta"),
+    ("Sunlu", "Sakura Pink", "#F5B5C2", "PLA Meta"),
+    ("Sunlu", "Taro Purple", "#A69ED0", "PLA Meta"),
+    # Sunlu PLA+
+    ("Sunlu", "Beige", "#DDBCAC", "PLA+"),
+    ("Sunlu", "Black", "#3A3B3B", "PLA+"),
+    ("Sunlu", "Blue", "#0063A0", "PLA+"),
+    ("Sunlu", "Green", "#4EE349", "PLA+"),
+    ("Sunlu", "Light Gold", "#D3943D", "PLA+"),
+    ("Sunlu", "Mint Green", "#00B39A", "PLA+"),
+    ("Sunlu", "Orange", "#ED7432", "PLA+"),
+    ("Sunlu", "Pure Yellow", "#FFBD2C", "PLA+"),
+    ("Sunlu", "Purple", "#8887C5", "PLA+"),
+    ("Sunlu", "Red", "#B34044", "PLA+"),
+    ("Sunlu", "Silk Blue", "#33ACD4", "PLA+"),
+    ("Sunlu", "Silk Brass", "#F1A050", "PLA+"),
+    ("Sunlu", "Silk Pink", "#FFCAD9", "PLA+"),
+    ("Sunlu", "Silk White", "#EEEFE7", "PLA+"),
+    ("Sunlu", "Skin", "#F7BEA1", "PLA+"),
+    ("Sunlu", "White", "#E6E6E2", "PLA+"),
+    # Sunlu PETG
+    ("Sunlu", "Black", "#3F4141", "PETG"),
+    ("Sunlu", "Blue", "#0068AB", "PETG"),
+    ("Sunlu", "Green", "#67DB25", "PETG"),
+    ("Sunlu", "Olive Green", "#707D63", "PETG"),
+    ("Sunlu", "Transparent", "#BAB9B4", "PETG"),
+    ("Sunlu", "White", "#DBDDD9", "PETG"),
+    # Sunlu ABS
+    ("Sunlu", "Black", "#404142", "ABS"),
+    # Creality Hyper PLA (from FilamentColors.xyz measured swatches)
+    ("Creality", "Black", "#282C2C", "Hyper PLA"),
+    ("Creality", "Blue", "#0881BE", "Hyper PLA"),
+    ("Creality", "Grey", "#7A7C7C", "Hyper PLA"),
+    ("Creality", "Purple", "#B0347E", "Hyper PLA"),
+    ("Creality", "Red", "#C32E2F", "Hyper PLA"),
+    ("Creality", "White", "#DEE4E1", "Hyper PLA"),
+    # Creality Hyper PLA-CF
+    ("Creality", "Black", "#322F2D", "Hyper PLA-CF"),
+    # Creality PLA
+    ("Creality", "Gray", "#8F9395", "PLA"),
+    ("Creality", "White", "#E1DFD0", "PLA"),
+    # Creality PETG
+    ("Creality", "White", "#E3E5E1", "PETG"),
+    # Creality Silk PLA
+    ("Creality", "Blue-Green", "#479B7D", "Silk PLA"),
+    # Elegoo PLA (from FilamentColors.xyz measured swatches)
+    ("Elegoo", "Black", "#282929", "PLA"),
+    ("Elegoo", "Clear", "#BEBBBF", "PLA"),
+    ("Elegoo", "Galaxy Black", "#32464E", "PLA"),
+    ("Elegoo", "Galaxy Purple", "#3A2F6F", "PLA"),
+    ("Elegoo", "Grey", "#B5B7B7", "PLA"),
+    ("Elegoo", "Peacock Blue", "#21606B", "PLA"),
+    ("Elegoo", "Sky Blue", "#46C8D4", "PLA"),
+    # Elegoo PLA+
+    ("Elegoo", "Black", "#343132", "PLA+"),
+    ("Elegoo", "Orange", "#CC6A2F", "PLA+"),
+    ("Elegoo", "Purple", "#6E45A7", "PLA+"),
+    # Elegoo Silk PLA
+    ("Elegoo", "Coral Pink", "#DB6E6D", "Silk PLA"),
+    ("Elegoo", "Gold", "#E2AC00", "Silk PLA"),
+    ("Elegoo", "Silver", "#93969B", "Silk PLA"),
+    # Jayo PLA+ (from FilamentColors.xyz measured swatches)
+    ("Jayo", "Black", "#2F2E2D", "PLA+"),
+    ("Jayo", "Cherry Red", "#C43536", "PLA+"),
+    ("Jayo", "White", "#D9E0E7", "PLA+"),
+    # Inland PLA (from FilamentColors.xyz measured swatches)
+    ("Inland", "Black", "#27272C", "PLA"),
+    ("Inland", "Blue", "#044482", "PLA"),
+    ("Inland", "Coral", "#C16062", "PLA"),
+    ("Inland", "Egyptian Blue", "#075AAC", "PLA"),
+    ("Inland", "Gold", "#D7B536", "PLA"),
+    ("Inland", "Green", "#407166", "PLA"),
+    ("Inland", "Grey", "#6F7983", "PLA"),
+    ("Inland", "Light Blue", "#3CA4B8", "PLA"),
+    ("Inland", "Military Green", "#5B6D37", "PLA"),
+    ("Inland", "Pink", "#FC97AF", "PLA"),
+    ("Inland", "Red", "#C43220", "PLA"),
+    ("Inland", "Silver", "#8A8F92", "PLA"),
+    ("Inland", "True Red", "#B13137", "PLA"),
+    ("Inland", "White", "#E0E3E3", "PLA"),
+    ("Inland", "Wood", "#DEB98F", "PLA"),
+    # Inland PLA+
+    ("Inland", "Black", "#2B272B", "PLA+"),
+    ("Inland", "Blue", "#054990", "PLA+"),
+    ("Inland", "Bone White", "#ABA18F", "PLA+"),
+    ("Inland", "Dark Blue", "#2C3353", "PLA+"),
+    ("Inland", "Light Blue", "#079FBF", "PLA+"),
+    ("Inland", "Magenta", "#DE2B60", "PLA+"),
+    ("Inland", "Orange", "#FB8B5A", "PLA+"),
+    ("Inland", "Pink", "#F291A4", "PLA+"),
+    ("Inland", "Purple", "#744FA0", "PLA+"),
+    ("Inland", "Silver", "#868A8B", "PLA+"),
+    ("Inland", "White", "#E3E5E5", "PLA+"),
+    ("Inland", "Yellow", "#F8D008", "PLA+"),
+    # Inland PETG
+    ("Inland", "Blue", "#084480", "PETG"),
+    ("Inland", "Green", "#2B783E", "PETG"),
+    ("Inland", "Magenta", "#E14170", "PETG"),
+    ("Inland", "Transparent", "#D1D6D1", "PETG"),
+    ("Inland", "True Red", "#97392B", "PETG"),
+    # Inland ABS
+    ("Inland", "Grey", "#8A97A2", "ABS"),
+    ("Inland", "Light Blue", "#6CBECF", "ABS"),
+    ("Inland", "Orange", "#E8712F", "ABS"),
+    # Inland Tough PLA
+    ("Inland", "Light Gray", "#8D9497", "Tough PLA"),
+    ("Inland", "Yellow", "#FFBB3F", "Tough PLA"),
+    # Eryone PLA (from FilamentColors.xyz measured swatches)
+    ("Eryone", "Galaxy Purple", "#60617B", "PLA"),
+    ("Eryone", "Galaxy Red", "#8E3332", "PLA"),
+    ("Eryone", "Glow in the Dark", "#C2C1AF", "PLA"),
+    ("Eryone", "Ivory White", "#DCDCD3", "PLA"),
+    ("Eryone", "Silk Blue", "#64A9D3", "PLA"),
+    ("Eryone", "Silk Copper", "#B36A50", "PLA"),
+    ("Eryone", "Silk Gold", "#D5983D", "PLA"),
+    ("Eryone", "Silk Gold Copper", "#D69366", "PLA"),
+    ("Eryone", "Silk Gold Silver", "#ABA787", "PLA"),
+    ("Eryone", "Ultra Silk Black", "#5B6264", "PLA"),
+    ("Eryone", "Ultra Silk Copper", "#B46A4D", "PLA"),
+    ("Eryone", "Ultra Silk Silver", "#999BA5", "PLA"),
+    # Eryone PLA+
+    ("Eryone", "Army Green", "#5D644D", "PLA+"),
+    # Eryone ASA
+    ("Eryone", "Black", "#414446", "ASA"),
+    # Eryone PLA Wood
+    ("Eryone", "Light Wood", "#A5886E", "PLA Wood"),
+    # ColorFabb PLA (from FilamentColors.xyz measured swatches)
+    ("ColorFabb", "Stonefill Light Gray", "#A9B2B7", "PLA"),
+    ("ColorFabb", "WoodFill", "#B89775", "PLA"),
+    # ColorFabb PLA/PHA
+    ("ColorFabb", "CopperFill", "#9D7465", "PLA/PHA"),
+    ("ColorFabb", "CorkFill", "#7F6150", "PLA/PHA"),
+    ("ColorFabb", "Natural", "#CFCFC2", "PLA/PHA"),
+    # ColorFabb XT
+    ("ColorFabb", "Light Gray", "#BFC5BE", "XT"),
+    ("ColorFabb", "Black", "#3B3635", "XT"),
+    # Fillamentum PLA Extrafill (from FilamentColors.xyz measured swatches)
+    ("Fillamentum", "Baby Blue", "#B9D7DC", "PLA Extrafill"),
+    ("Fillamentum", "Chocolate Brown", "#5B4A45", "PLA Extrafill"),
+    ("Fillamentum", "Cobalt Blue", "#333D5C", "PLA Extrafill"),
+    ("Fillamentum", "Crystal Clear Smaragd Green", "#028D77", "PLA Extrafill"),
+    ("Fillamentum", "Everybody's Magenta", "#E1347D", "PLA Extrafill"),
+    ("Fillamentum", "Gold Happens", "#BC994D", "PLA Extrafill"),
+    ("Fillamentum", "Mukha", "#A88866", "PLA Extrafill"),
+    ("Fillamentum", "Pearl Night Blue", "#045589", "PLA Extrafill"),
+    ("Fillamentum", "Pearl Ruby Red", "#791F2A", "PLA Extrafill"),
+    ("Fillamentum", "Rapunzel Silver", "#AFAFB0", "PLA Extrafill"),
+    ("Fillamentum", "Vertigo Cherry", "#752F38", "PLA Extrafill"),
+    ("Fillamentum", "Vertigo Galaxy", "#333928", "PLA Extrafill"),
+    ("Fillamentum", "Vertigo Grey", "#5A5963", "PLA Extrafill"),
+    ("Fillamentum", "Vertigo Starlight", "#343A4F", "PLA Extrafill"),
+    ("Fillamentum", "Wizard's Voodoo", "#3F465E", "PLA Extrafill"),
+    # Fillamentum PLA (Crystal Clear / Timberfill / Vertigo lines)
+    ("Fillamentum", "Crystal Clear", "#EBECF2", "PLA"),
+    ("Fillamentum", "Crystal Clear Amethyst Purple", "#9F99BC", "PLA"),
+    ("Fillamentum", "Crystal Clear Iceland Blue", "#82BBCD", "PLA"),
+    ("Fillamentum", "Crystal Clear Tangerine Orange", "#ECD082", "PLA"),
+    ("Fillamentum", "Lilac", "#A99FCF", "PLA"),
+    ("Fillamentum", "Timberfill Cinnamon", "#AC7C67", "PLA"),
+    ("Fillamentum", "Timberfill Rosewood", "#6A564E", "PLA"),
+    ("Fillamentum", "Vertigo Jade", "#217F60", "PLA"),
+    # Fillamentum ASA Extrafill
+    ("Fillamentum", "Anthracite Grey", "#4B4F50", "ASA Extrafill"),
+    ("Fillamentum", "Green Grass", "#678653", "ASA Extrafill"),
+    ("Fillamentum", "Grey Blue", "#495965", "ASA Extrafill"),
+    ("Fillamentum", "Metallic Grey", "#878A8C", "ASA Extrafill"),
+    ("Fillamentum", "Sky Blue", "#0783B6", "ASA Extrafill"),
+    ("Fillamentum", "Snow White", "#F3F3EF", "ASA Extrafill"),
+    ("Fillamentum", "Traffic Black", "#3B3B3F", "ASA Extrafill"),
+    ("Fillamentum", "Traffic White", "#E9E7DA", "ASA Extrafill"),
+    ("Fillamentum", "White Aluminium", "#9CA1A2", "ASA Extrafill"),
+    # Fillamentum CPE HG100
+    ("Fillamentum", "Black Soul", "#292B27", "CPE HG100"),
+    ("Fillamentum", "Ghost White", "#E5E8E7", "CPE HG100"),
+    ("Fillamentum", "Natural", "#DCE4DF", "CPE HG100"),
+    # Fillamentum Flexfill TPU 98A
+    ("Fillamentum", "Blue Transparent", "#047990", "Flexfill TPU 98A"),
+    ("Fillamentum", "Carrot Orange", "#EA6E21", "Flexfill TPU 98A"),
+    ("Fillamentum", "Metallic Grey", "#8F8E8F", "Flexfill TPU 98A"),
+    ("Fillamentum", "Pistachio Green", "#A7BE36", "Flexfill TPU 98A"),
+    ("Fillamentum", "Signal Red", "#9A2222", "Flexfill TPU 98A"),
+    ("Fillamentum", "Traffic Black", "#26262A", "Flexfill TPU 98A"),
+    ("Fillamentum", "Vertigo Grey", "#515150", "Flexfill TPU 98A"),
+    # FormFutura PLA (from FilamentColors.xyz measured swatches)
+    ("FormFutura", "Basalt Grey", "#5B5F61", "PLA"),
+    ("FormFutura", "Dark Blue", "#084B86", "PLA"),
+    ("FormFutura", "Galaxy Champagne Gold", "#AE9D83", "PLA"),
+    ("FormFutura", "Gold High Gloss", "#C89B4B", "PLA"),
+    ("FormFutura", "High Gloss White", "#D0D7D8", "PLA"),
+    ("FormFutura", "Magenta High Gloss", "#B94474", "PLA"),
+    ("FormFutura", "Stonefil Terracotta", "#BD634C", "PLA"),
+    ("FormFutura", "Yellow Green", "#7AA837", "PLA"),
+    # FormFutura ePLA
+    ("FormFutura", "Pure Orange", "#FA9145", "EasyFil PLA"),
+    # FormFutura rPLA
+    ("FormFutura", "ReForm Black", "#3A3B3B", "ReForm rPLA"),
+    ("FormFutura", "ReForm White", "#F3F3EC", "ReForm rPLA"),
+    # Fiberlogy PLA (from FilamentColors.xyz measured swatches)
+    ("Fiberlogy", "Mineral White", "#E2D9CD", "PLA"),
+    ("Fiberlogy", "Aurora", "#3C4452", "Easy PLA"),
+    ("Fiberlogy", "Army Green", "#535F4F", "Impact PLA"),
+    # Fiberlogy ASA
+    ("Fiberlogy", "Olive Green", "#61634B", "ASA"),
+    # Fiberlogy Easy PETG
+    ("Fiberlogy", "White", "#F2F2EE", "Easy PETG"),
+    # Fiberlogy FiberSilk
+    ("Fiberlogy", "Green", "#A2D780", "FiberSilk Metallic"),
+    # MatterHackers Build PLA (from FilamentColors.xyz measured swatches)
+    ("MatterHackers", "Blue", "#044786", "Build PLA"),
+    ("MatterHackers", "Magenta", "#CD4263", "Build PLA"),
+    ("MatterHackers", "Red", "#C4351B", "Build PLA"),
+    ("MatterHackers", "Shiny Gold", "#DFAC1E", "Build PLA"),
+    ("MatterHackers", "Silky Copper", "#C76F35", "Build PLA"),
+    ("MatterHackers", "Silky Silver", "#BDBDB8", "Build PLA"),
+    ("MatterHackers", "Silky Teal", "#078EBC", "Build PLA"),
+    ("MatterHackers", "Silky Yellow", "#EDB554", "Build PLA"),
+    ("MatterHackers", "Yellow", "#EBC100", "Build PLA"),
+    # MatterHackers PLA
+    ("MatterHackers", "Gold", "#E7AC37", "PLA"),
+    ("MatterHackers", "Lime Green", "#75BA52", "PLA"),
+    ("MatterHackers", "Pearl White", "#D4DCDD", "PLA"),
+    ("MatterHackers", "Red", "#E54931", "PLA"),
+    # MatterHackers Pro PLA
+    ("MatterHackers", "Electric Pink", "#F35886", "Pro PLA"),
+    ("MatterHackers", "Jet Gray", "#474B4C", "Pro PLA"),
+    # MatterHackers PETG
+    ("MatterHackers", "Clear", "#D5DDDA", "PETG"),
+    ("MatterHackers", "White", "#E9EBEF", "PETG"),
+    # MatterHackers NylonX / NylonG
+    ("MatterHackers", "Black", "#3D3C38", "NylonX"),
+    ("MatterHackers", "White", "#DCDED9", "NylonG"),
+    # Protopasta HTPLA (from FilamentColors.xyz measured swatches)
+    ("Protopasta", "Atikam Teal", "#135859", "HTPLA"),
+    ("Protopasta", "Blood of My Enemies", "#7A1A23", "HTPLA"),
+    ("Protopasta", "Blue Opaque", "#044A86", "HTPLA"),
+    ("Protopasta", "Blue Wonder Glitter Flake", "#20556F", "HTPLA"),
+    ("Protopasta", "Bobbi's Purple Iris", "#542B5C", "HTPLA"),
+    ("Protopasta", "Brass Composite", "#8C7A4F", "HTPLA"),
+    ("Protopasta", "Bronze Composite", "#635146", "HTPLA"),
+    ("Protopasta", "Candy Apple Metallic Red", "#A32423", "HTPLA"),
+    ("Protopasta", "Cloverleaf Metallic Green", "#245A3F", "HTPLA"),
+    ("Protopasta", "Copper Composite", "#976252", "HTPLA"),
+    ("Protopasta", "Cupid's Crush Metallic Pink", "#EA8699", "HTPLA"),
+    ("Protopasta", "Double Espresso Metallic Brown", "#6B473B", "HTPLA"),
+    ("Protopasta", "Dragon Fruit Smoothie", "#B3295F", "HTPLA"),
+    ("Protopasta", "Dragon Scale Purple", "#8A80AC", "HTPLA"),
+    ("Protopasta", "Dusty Smoke", "#8F9491", "HTPLA"),
+    ("Protopasta", "Electric Lemonade Metallic Yellow", "#E4CA6B", "HTPLA"),
+    ("Protopasta", "Empire Strikes Metallic Black", "#393B3B", "HTPLA"),
+    ("Protopasta", "Fluorescent Yellow", "#D4DC3A", "HTPLA"),
+    ("Protopasta", "Galactic Empire Metallic Purple", "#3B3F5D", "HTPLA"),
+    ("Protopasta", "Glitter's Mane", "#128C93", "HTPLA"),
+    ("Protopasta", "Gold Dust Glitter Flake", "#BFAE6D", "HTPLA"),
+    ("Protopasta", "Good as Gold", "#9A774B", "HTPLA"),
+    ("Protopasta", "Good Old Gray", "#6D737B", "HTPLA"),
+    ("Protopasta", "Green Glowing Natural", "#D4D3AD", "HTPLA"),
+    ("Protopasta", "Heartthrob Red Metallic", "#7E3030", "HTPLA"),
+    ("Protopasta", "Joel's Highfive Blue", "#056B9A", "HTPLA"),
+    ("Protopasta", "Lootsef Green", "#8FB841", "HTPLA"),
+    ("Protopasta", "Luke's Proton Purple", "#7D3F59", "HTPLA"),
+    ("Protopasta", "Mahogany", "#7F5D4F", "HTPLA"),
+    ("Protopasta", "Matte Fiber Black", "#3F3F3E", "HTPLA"),
+    ("Protopasta", "Matte Fiber Daffodil", "#B79868", "HTPLA"),
+    ("Protopasta", "Matte Fiber Gray", "#767A7D", "HTPLA"),
+    ("Protopasta", "Matte Fiber Walnut", "#6F5D4E", "HTPLA"),
+    ("Protopasta", "Matte Fiber White", "#F4EADB", "HTPLA"),
+    ("Protopasta", "Mermaid's Tale Metallic Teal", "#026768", "HTPLA"),
+    ("Protopasta", "Moonstruck White Satin", "#DCE4DD", "HTPLA"),
+    ("Protopasta", "Obsidian", "#474743", "HTPLA"),
+    ("Protopasta", "Opaque Black", "#312F30", "HTPLA"),
+    ("Protopasta", "Opaque Natural", "#CBD0D0", "HTPLA"),
+    ("Protopasta", "Opaque White", "#DFE4E2", "HTPLA"),
+    ("Protopasta", "Orange Papaya Smoothie", "#C57231", "HTPLA"),
+    ("Protopasta", "Out of Darts Orange", "#E58429", "HTPLA"),
+    ("Protopasta", "Pineapple Banana Smoothie", "#D0A645", "HTPLA"),
+    ("Protopasta", "Pretty in Pink Pearl", "#CE95AE", "HTPLA"),
+    ("Protopasta", "Red Hot Cinnamon", "#7C4448", "HTPLA"),
+    ("Protopasta", "Red Opaque", "#972425", "HTPLA"),
+    ("Protopasta", "Second to None Silver", "#B4B6B6", "HTPLA"),
+    ("Protopasta", "Sparkling Spruce", "#47614C", "HTPLA"),
+    ("Protopasta", "Stardust Glitter Flake", "#AAB1AE", "HTPLA"),
+    ("Protopasta", "Summertime Green", "#89A78A", "HTPLA"),
+    ("Protopasta", "Tangerine Orange Metallic Gold", "#C05834", "HTPLA"),
+    ("Protopasta", "Translucent Iridescent Ice", "#C4CBC8", "HTPLA"),
+    ("Protopasta", "Translucent Silver Smoke", "#A5ACA9", "HTPLA"),
+    ("Protopasta", "Unicorn Tears White Glitter", "#D7DEDE", "HTPLA"),
+    ("Protopasta", "What Karat? Smooth Gold", "#CD974B", "HTPLA"),
+    ("Protopasta", "White", "#F6F5F0", "HTPLA"),
+    ("Protopasta", "White Marble", "#C6CECF", "HTPLA"),
+    ("Protopasta", "Winter Blue Glitter Flake", "#056F9D", "HTPLA"),
+    # Protopasta PLA
+    ("Protopasta", "Black", "#323132", "PLA"),
+    ("Protopasta", "Conductive", "#373838", "PLA"),
+    ("Protopasta", "Iron Composite", "#555451", "PLA"),
+    ("Protopasta", "Natural", "#E1DFD6", "PLA"),
+    ("Protopasta", "Steel Composite", "#676561", "PLA"),
+    # Protopasta Carbon Fiber PLA
+    ("Protopasta", "Black", "#424140", "Carbon Fiber PLA"),
+    # 3DXTECH (from FilamentColors.xyz measured swatches)
+    ("3DXTECH", "Natural", "#DED7C6", "ASA"),
+    ("3DXTECH", "Black", "#444342", "Carbon Fiber PLA"),
+    ("3DXTECH", "Venom", "#CACC19", "ECOMAX PLA"),
+    ("3DXTECH", "Simubone", "#EAE0CB", "PLA"),
+    ("3DXTECH", "Blue Frost", "#B1C1C5", "rPETG"),
+    # Sakata3D PLA (from FilamentColors.xyz measured swatches)
+    ("Sakata3D", "Red", "#B63A32", "PLA"),
+    ("Sakata3D", "Silk Sunset", "#F49545", "PLA"),
+    ("Sakata3D", "Surf Green", "#00C1A8", "PLA"),
 ]

+ 11 - 4
backend/app/main.py

@@ -165,6 +165,7 @@ if not app_settings.debug:
     logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
     logging.getLogger("httpcore").setLevel(logging.WARNING)
     logging.getLogger("httpx").setLevel(logging.WARNING)
+    logging.getLogger("paho.mqtt").setLevel(logging.WARNING)
 
 logging.info("Bambuddy starting - debug=%s, log_level=%s", app_settings.debug, log_level_str)
 from fastapi.responses import FileResponse
@@ -335,12 +336,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)
@@ -673,7 +676,9 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         )
                         existing_assignment = existing.scalar_one_or_none()
                         if existing_assignment:
-                            # Sync spool weight_used from AMS remain if valid
+                            # Sync spool weight_used from AMS remain — only INCREASE, never decrease.
+                            # The AMS remain% is low-resolution (integer %, i.e. 10g steps for 1kg spool)
+                            # and must not overwrite precise values from the usage tracker (3MF/G-code).
                             remain_raw = tray.get("remain")
                             if remain_raw is not None and existing_assignment.spool:
                                 try:
@@ -683,11 +688,12 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                 if 0 <= remain_val <= 100:
                                     lw = existing_assignment.spool.label_weight or 1000
                                     new_used = round(lw * (100 - remain_val) / 100.0, 1)
-                                    if abs((existing_assignment.spool.weight_used or 0) - new_used) > 1:
+                                    current_used = existing_assignment.spool.weight_used or 0
+                                    if new_used > current_used + 1:
                                         logger.info(
                                             "Weight sync: spool %d weight_used %s -> %s (remain=%d)",
                                             existing_assignment.spool_id,
-                                            existing_assignment.spool.weight_used,
+                                            current_used,
                                             new_used,
                                             remain_val,
                                         )
@@ -707,6 +713,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

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

@@ -31,10 +31,6 @@ class AppSettings(BaseModel):
         default=True,
         description="Report Partial Usage for Failed Prints. When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.",
     )
-    disable_filament_warnings: bool = Field(
-        default=False,
-        description="Disable insufficient filament warnings when printing or queueing prints",
-    )
 
     # Updates
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
@@ -162,7 +158,6 @@ class AppSettingsUpdate(BaseModel):
     spoolman_sync_mode: str | None = None
     spoolman_disable_weight_sync: bool | None = None
     spoolman_report_partial_usage: bool | None = None
-    disable_filament_warnings: bool | None = None
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     notification_language: str | None = None

+ 210 - 114
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
@@ -372,7 +372,7 @@ class BambuMQTTClient:
             # TEMP: Dump full payload once to find extruder state field
             if not hasattr(self, "_payload_dumped"):
                 self._payload_dumped = True
-                logger.info("[%s] FULL MQTT PAYLOAD DUMP:\n%s", self.serial_number, json.dumps(payload, indent=2))
+                logger.debug("[%s] FULL MQTT PAYLOAD DUMP:\n%s", self.serial_number, json.dumps(payload, indent=2))
             # Log message if logging is enabled
             if self._logging_enabled:
                 self._message_log.append(
@@ -400,7 +400,7 @@ class BambuMQTTClient:
         # Handle xcam data (camera settings and AI detection) at top level
         if "xcam" in payload:
             xcam_data = payload["xcam"]
-            logger.info("[%s] Received xcam data at top level: %s", self.serial_number, xcam_data)
+            logger.debug("[%s] Received xcam data at top level: %s", self.serial_number, xcam_data)
             self._parse_xcam_data(xcam_data)
             # Fire state change callback for top-level xcam (not nested in "print")
             if "print" not in payload and self.on_state_change:
@@ -409,7 +409,7 @@ class BambuMQTTClient:
         # Handle system responses (accessories info, etc.)
         if "system" in payload:
             system_data = payload["system"]
-            logger.info("[%s] Received system data: %s", self.serial_number, system_data)
+            logger.debug("[%s] Received system data: %s", self.serial_number, system_data)
             self._handle_system_response(system_data)
 
         # Handle info responses (firmware version info from get_version command)
@@ -434,12 +434,12 @@ class BambuMQTTClient:
 
             # Check if xcam is nested inside print data
             if "xcam" in print_data:
-                logger.info("[%s] Found xcam inside print data: %s", self.serial_number, print_data["xcam"])
+                logger.debug("[%s] Found xcam inside print data: %s", self.serial_number, print_data["xcam"])
                 self._parse_xcam_data(print_data["xcam"])
 
             # Log when we see gcode_state changes
             if "gcode_state" in print_data:
-                logger.info(
+                logger.debug(
                     f"[{self.serial_number}] Received gcode_state: {print_data.get('gcode_state')}, "
                     f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
                 )
@@ -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.debug("[%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)
 
@@ -506,7 +528,7 @@ class BambuMQTTClient:
             # Log response for debugging - but DON'T use it to update nozzle data
             # because it returns stale values (e.g., 'stainless_steel' when the
             # actual nozzle is 'HH01' hardened steel high-flow)
-            logger.info("[%s] Accessories response (not used for nozzle data): %s", self.serial_number, data)
+            logger.debug("[%s] Accessories response (not used for nozzle data): %s", self.serial_number, data)
 
     def _handle_version_info(self, data: dict):
         """Handle version info response from get_version command.
@@ -602,7 +624,7 @@ class BambuMQTTClient:
             if should_accept_value("spaghetti_detector", cfg_spaghetti):
                 old_value = self.state.print_options.spaghetti_detector
                 if cfg_spaghetti != old_value:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] spaghetti_detector changed (from cfg): {old_value} -> {cfg_spaghetti}"
                     )
                 self.state.print_options.spaghetti_detector = cfg_spaghetti
@@ -610,7 +632,7 @@ class BambuMQTTClient:
             # Check hold timer for sensitivity before accepting
             if "halt_print_sensitivity" not in self._xcam_hold_start:
                 if cfg_sensitivity != self.state.print_options.halt_print_sensitivity:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] Sensitivity changed (from cfg): "
                         f"{self.state.print_options.halt_print_sensitivity} -> {cfg_sensitivity}"
                     )
@@ -626,7 +648,7 @@ class BambuMQTTClient:
                 else:
                     # Hold expired - accept from cfg
                     if cfg_sensitivity != self.state.print_options.halt_print_sensitivity:
-                        logger.info(
+                        logger.debug(
                             f"[{self.serial_number}] Sensitivity synced (from cfg after hold): "
                             f"{self.state.print_options.halt_print_sensitivity} -> {cfg_sensitivity}"
                         )
@@ -637,14 +659,14 @@ class BambuMQTTClient:
             cfg_pileup, cfg_pileup_sens = decode_detector(8)
             if should_accept_value("pileup_detector", cfg_pileup):
                 if cfg_pileup != self.state.print_options.pileup_detector:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] pileup_detector changed (from cfg): {self.state.print_options.pileup_detector} -> {cfg_pileup}"
                     )
                     self.state.print_options.pileup_detector = cfg_pileup
             # Pileup sensitivity with hold timer
             if "pileup_sensitivity" not in self._xcam_hold_start:
                 if cfg_pileup_sens != self.state.print_options.pileup_sensitivity:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] pileup_sensitivity changed (from cfg): {self.state.print_options.pileup_sensitivity} -> {cfg_pileup_sens}"
                     )
                     self.state.print_options.pileup_sensitivity = cfg_pileup_sens
@@ -653,7 +675,7 @@ class BambuMQTTClient:
                 elapsed = current_time - hold_start
                 if elapsed > self._xcam_hold_time:
                     if cfg_pileup_sens != self.state.print_options.pileup_sensitivity:
-                        logger.info(
+                        logger.debug(
                             f"[{self.serial_number}] pileup_sensitivity synced (from cfg after hold): {self.state.print_options.pileup_sensitivity} -> {cfg_pileup_sens}"
                         )
                         self.state.print_options.pileup_sensitivity = cfg_pileup_sens
@@ -663,14 +685,14 @@ class BambuMQTTClient:
             cfg_clump, cfg_clump_sens = decode_detector(11)
             if should_accept_value("clump_detector", cfg_clump):
                 if cfg_clump != self.state.print_options.nozzle_clumping_detector:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] nozzle_clumping_detector changed (from cfg): {self.state.print_options.nozzle_clumping_detector} -> {cfg_clump}"
                     )
                     self.state.print_options.nozzle_clumping_detector = cfg_clump
             # Clump sensitivity with hold timer
             if "nozzle_clumping_sensitivity" not in self._xcam_hold_start:
                 if cfg_clump_sens != self.state.print_options.nozzle_clumping_sensitivity:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] nozzle_clumping_sensitivity changed (from cfg): {self.state.print_options.nozzle_clumping_sensitivity} -> {cfg_clump_sens}"
                     )
                     self.state.print_options.nozzle_clumping_sensitivity = cfg_clump_sens
@@ -679,7 +701,7 @@ class BambuMQTTClient:
                 elapsed = current_time - hold_start
                 if elapsed > self._xcam_hold_time:
                     if cfg_clump_sens != self.state.print_options.nozzle_clumping_sensitivity:
-                        logger.info(
+                        logger.debug(
                             f"[{self.serial_number}] nozzle_clumping_sensitivity synced (from cfg after hold): {self.state.print_options.nozzle_clumping_sensitivity} -> {cfg_clump_sens}"
                         )
                         self.state.print_options.nozzle_clumping_sensitivity = cfg_clump_sens
@@ -689,14 +711,14 @@ class BambuMQTTClient:
             cfg_airprint, cfg_airprint_sens = decode_detector(14)
             if should_accept_value("airprint_detector", cfg_airprint):
                 if cfg_airprint != self.state.print_options.airprint_detector:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] airprint_detector changed (from cfg): {self.state.print_options.airprint_detector} -> {cfg_airprint}"
                     )
                     self.state.print_options.airprint_detector = cfg_airprint
             # Airprint sensitivity with hold timer
             if "airprint_sensitivity" not in self._xcam_hold_start:
                 if cfg_airprint_sens != self.state.print_options.airprint_sensitivity:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] airprint_sensitivity changed (from cfg): {self.state.print_options.airprint_sensitivity} -> {cfg_airprint_sens}"
                     )
                     self.state.print_options.airprint_sensitivity = cfg_airprint_sens
@@ -705,7 +727,7 @@ class BambuMQTTClient:
                 elapsed = current_time - hold_start
                 if elapsed > self._xcam_hold_time:
                     if cfg_airprint_sens != self.state.print_options.airprint_sensitivity:
-                        logger.info(
+                        logger.debug(
                             f"[{self.serial_number}] airprint_sensitivity synced (from cfg after hold): {self.state.print_options.airprint_sensitivity} -> {cfg_airprint_sens}"
                         )
                         self.state.print_options.airprint_sensitivity = cfg_airprint_sens
@@ -811,7 +833,7 @@ class BambuMQTTClient:
                         pending_slot = pending_target % 4
                         if pending_slot == parsed_tray_now:
                             # Slot matches our pending target - use the full global ID
-                            logger.info(
+                            logger.debug(
                                 f"[{self.serial_number}] H2D tray_now disambiguation: "
                                 f"slot {parsed_tray_now} matches pending_tray_target {pending_target} -> using global ID {pending_target}"
                             )
@@ -840,7 +862,7 @@ class BambuMQTTClient:
                             snow_slot = snow_tray % 4 if snow_tray < 128 else -1
                             if snow_slot == parsed_tray_now:
                                 if self.state.tray_now != snow_tray:
-                                    logger.info(
+                                    logger.debug(
                                         f"[{self.serial_number}] H2D tray_now from snow: "
                                         f"extruder[{active_ext}] snow={snow_tray} (slot {snow_slot})"
                                     )
@@ -868,7 +890,7 @@ class BambuMQTTClient:
                                 # Single AMS on this extruder - unambiguous
                                 active_ams_id = ams_on_extruder[0]
                                 global_tray_id = active_ams_id * 4 + parsed_tray_now
-                                logger.info(
+                                logger.debug(
                                     f"[{self.serial_number}] H2D tray_now fallback: "
                                     f"slot {parsed_tray_now} + single AMS {active_ams_id} -> global ID {global_tray_id}"
                                 )
@@ -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)
@@ -1004,7 +1036,7 @@ class BambuMQTTClient:
                         slot_exists = (tray_exist_bits >> global_bit) & 1
                         if not slot_exists and tray.get("tray_type"):
                             # Slot is marked empty but has data - clear it
-                            logger.info(
+                            logger.debug(
                                 f"[{self.serial_number}] Clearing empty slot: AMS {ams_id} slot {tray_id} "
                                 f"(tray_exist_bits bit {global_bit} = 0)"
                             )
@@ -1066,7 +1098,7 @@ class BambuMQTTClient:
         if ams_hash != self._previous_ams_hash:
             self._previous_ams_hash = ams_hash
             if self.on_ams_change:
-                logger.info("[%s] AMS data changed, triggering sync callback", self.serial_number)
+                logger.debug("[%s] AMS data changed, triggering sync callback", self.serial_number)
                 # Pass merged AMS data (not raw ams_list) — partial MQTT updates
                 # may lack fields like 'remain' that the merged state preserves
                 self.on_ams_change(merged_ams)
@@ -1132,7 +1164,7 @@ class BambuMQTTClient:
         if not hasattr(self, "_fan_fields_logged"):
             fan_fields = {k: v for k, v in data.items() if "fan" in k.lower()}
             if fan_fields:
-                logger.info("[%s] Fan fields in MQTT data: %s", self.serial_number, fan_fields)
+                logger.debug("[%s] Fan fields in MQTT data: %s", self.serial_number, fan_fields)
                 self._fan_fields_logged = True
 
         if "cooling_fan_speed" in data:
@@ -1149,7 +1181,7 @@ class BambuMQTTClient:
             new_stg = data["stg_cur"]
             # Always log ANY stg_cur change for debugging filament operations
             if new_stg != self.state.stg_cur:
-                logger.info(
+                logger.debug(
                     f"[{self.serial_number}] stg_cur changed: {self.state.stg_cur} -> {new_stg} ({get_stage_name(new_stg)})"
                 )
             self.state.stg_cur = new_stg
@@ -1161,15 +1193,15 @@ class BambuMQTTClient:
         # Log all fields for debugging dual-nozzle temperature discovery (only once)
         if "bed_temper" in data and not hasattr(self, "_temp_fields_logged"):
             temp_fields = {k: v for k, v in data.items() if "temp" in k.lower() or "chamber" in k.lower()}
-            logger.info("[%s] Temperature-related fields: %s", self.serial_number, temp_fields)
+            logger.debug("[%s] Temperature-related fields: %s", self.serial_number, temp_fields)
             # Log ALL keys in print data for H2D temperature discovery
             all_keys = sorted(data.keys())
-            logger.info("[%s] ALL print data keys (%s): %s", self.serial_number, len(all_keys), all_keys)
+            logger.debug("[%s] ALL print data keys (%s): %s", self.serial_number, len(all_keys), all_keys)
             self._temp_fields_logged = True
 
         # Log vir_slot data (once) - this may contain per-extruder slot mapping for H2D
         if "vir_slot" in data and not hasattr(self, "_vir_slot_logged"):
-            logger.info("[%s] vir_slot data: %s", self.serial_number, data["vir_slot"])
+            logger.debug("[%s] vir_slot data: %s", self.serial_number, data["vir_slot"])
             self._vir_slot_logged = True
 
         # Log nozzle hardware info fields (once)
@@ -1179,7 +1211,7 @@ class BambuMQTTClient:
             if "nozzle" in k.lower() or "hw" in k.lower() or "extruder" in k.lower() or "upgrade" in k.lower()
         }
         if nozzle_fields and not hasattr(self, "_nozzle_fields_logged"):
-            logger.info("[%s] Nozzle/hardware fields in MQTT data: %s", self.serial_number, nozzle_fields)
+            logger.debug("[%s] Nozzle/hardware fields in MQTT data: %s", self.serial_number, nozzle_fields)
             self._nozzle_fields_logged = True
         # Parse active extruder from device.extruder.state bit 8
         # bit 8 = 0 → RIGHT extruder (active_extruder=0)
@@ -1191,7 +1223,7 @@ class BambuMQTTClient:
                 # Extract bit 8 for extruder position
                 new_extruder = (state_val >> 8) & 0x1
                 if new_extruder != self.state.active_extruder:
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] ACTIVE EXTRUDER CHANGED (state bit 8): {self.state.active_extruder} -> {new_extruder} (0=right, 1=left) [state={state_val}]"
                     )
                     self.state.active_extruder = new_extruder
@@ -1206,12 +1238,12 @@ class BambuMQTTClient:
                     state_val = ext_data["state"]
                     # Extract bits 12-14 (3 bits) for switch state
                     switch_state = (state_val >> 12) & 0x7
-                    logger.info(
+                    logger.debug(
                         f"[{self.serial_number}] device.extruder.state={state_val} (switch_state bits 12-14: {switch_state})"
                     )
                 # Log 'cur' field if present (might indicate current/active extruder)
                 if "cur" in ext_data:
-                    logger.info("[%s] device.extruder.cur: %s", self.serial_number, ext_data["cur"])
+                    logger.debug("[%s] device.extruder.cur: %s", self.serial_number, ext_data["cur"])
         if "bed_temper" in data:
             temps["bed"] = float(data["bed_temper"])
         if "bed_target_temper" in data:
@@ -1413,7 +1445,7 @@ class BambuMQTTClient:
                                     global_tray = ams_id * 4 + (slot & 0x03)
                                     old_val = self.state.h2d_extruder_snow.get(ext_id)
                                     if old_val != global_tray:
-                                        logger.info(
+                                        logger.debug(
                                             f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
                                             f"raw={snow} (AMS {ams_id} slot {slot}) -> global tray {global_tray}"
                                         )
@@ -1423,7 +1455,7 @@ class BambuMQTTClient:
                                     normalized = 254 if slot != 255 else 255
                                     old_val = self.state.h2d_extruder_snow.get(ext_id)
                                     if old_val != normalized:
-                                        logger.info(
+                                        logger.debug(
                                             f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
                                             f"raw={snow} -> {'external' if normalized == 254 else 'unloaded'}"
                                         )
@@ -1432,7 +1464,7 @@ class BambuMQTTClient:
                                     # External spool with hub mapping
                                     old_val = self.state.h2d_extruder_snow.get(ext_id)
                                     if old_val != ams_id:
-                                        logger.info(
+                                        logger.debug(
                                             f"[{self.serial_number}] H2D extruder[{ext_id}] snow: "
                                             f"raw={snow} -> external hub {ams_id}"
                                         )
@@ -1457,7 +1489,7 @@ class BambuMQTTClient:
                 if "modeCur" in airduct_data:
                     new_mode = airduct_data["modeCur"]
                     if new_mode != self.state.airduct_mode:
-                        logger.info(
+                        logger.debug(
                             f"[{self.serial_number}] airduct_mode changed: {self.state.airduct_mode} -> {new_mode}"
                         )
                     self.state.airduct_mode = new_mode
@@ -1565,7 +1597,7 @@ class BambuMQTTClient:
         # Parse HMS (Health Management System) errors
         if "hms" in data:
             hms_list = data["hms"]
-            logger.info("[%s] HMS data received: %s", self.serial_number, hms_list)
+            logger.debug("[%s] HMS data received: %s", self.serial_number, hms_list)
             self.state.hms_errors = []
             if isinstance(hms_list, list):
                 for hms in hms_list:
@@ -1609,7 +1641,7 @@ class BambuMQTTClient:
                 # code stores the short format string for lookup
                 short_code = f"{module:04X}_{error:04X}"
 
-                logger.info(
+                logger.debug(
                     f"[{self.serial_number}] print_error: {print_error} (0x{print_error:08x}) -> short_code={short_code}"
                 )
 
@@ -1644,7 +1676,7 @@ class BambuMQTTClient:
                 home_flag = home_flag & 0xFFFFFFFF
             store_to_sdcard = bool((home_flag >> 11) & 1)
             if store_to_sdcard != self.state.store_to_sdcard:
-                logger.info(
+                logger.debug(
                     f"[{self.serial_number}] store_to_sdcard changed: {self.state.store_to_sdcard} -> {store_to_sdcard}"
                 )
             self.state.store_to_sdcard = store_to_sdcard
@@ -1668,21 +1700,21 @@ class BambuMQTTClient:
                 if "timelapse" in ipcam_data:
                     timelapse_enabled = ipcam_data.get("timelapse") == "enable"
                     if timelapse_enabled != self.state.timelapse:
-                        logger.info(
+                        logger.debug(
                             f"[{self.serial_number}] timelapse changed (from ipcam): {self.state.timelapse} -> {timelapse_enabled}"
                         )
                     self.state.timelapse = timelapse_enabled
                     # Track if timelapse was ever active during this print
                     if self.state.timelapse and self._was_running:
                         self._timelapse_during_print = True
-                        logger.info("[%s] Timelapse detected during print (from ipcam)", self.serial_number)
+                        logger.debug("[%s] Timelapse detected during print (from ipcam)", self.serial_number)
             else:
                 self.state.ipcam = ipcam_data is True
 
         # Parse WiFi signal strength (dBm)
         if "wifi_signal" in data:
             wifi_signal = data["wifi_signal"]
-            logger.info("[%s] wifi_signal received: %s", self.serial_number, wifi_signal)
+            logger.debug("[%s] wifi_signal received: %s", self.serial_number, wifi_signal)
             if isinstance(wifi_signal, (int, float)):
                 self.state.wifi_signal = int(wifi_signal)
             elif isinstance(wifi_signal, str):
@@ -1696,7 +1728,9 @@ class BambuMQTTClient:
         if "spd_lvl" in data:
             new_speed = data["spd_lvl"]
             if new_speed != self.state.speed_level:
-                logger.info("[%s] speed_level changed: %s -> %s", self.serial_number, self.state.speed_level, new_speed)
+                logger.debug(
+                    "[%s] speed_level changed: %s -> %s", self.serial_number, self.state.speed_level, new_speed
+                )
             self.state.speed_level = new_speed
 
         # Parse skipped objects from printer status (s_obj field)
@@ -1707,7 +1741,7 @@ class BambuMQTTClient:
                 # Update skipped objects from printer's list
                 new_skipped = [int(oid) for oid in s_obj if isinstance(oid, (int, str))]
                 if new_skipped != self.state.skipped_objects:
-                    logger.info("[%s] skipped_objects updated from printer: %s", self.serial_number, new_skipped)
+                    logger.debug("[%s] skipped_objects updated from printer: %s", self.serial_number, new_skipped)
                     self.state.skipped_objects = new_skipped
 
         # Parse chamber light status from lights_report
@@ -1719,7 +1753,7 @@ class BambuMQTTClient:
                     if isinstance(light, dict) and light.get("node") == "chamber_light":
                         new_light_state = light.get("mode") == "on"
                         if new_light_state != self.state.chamber_light:
-                            logger.info(
+                            logger.debug(
                                 f"[{self.serial_number}] chamber_light changed: {self.state.chamber_light} -> {new_light_state}"
                             )
                         self.state.chamber_light = new_light_state
@@ -1784,7 +1818,7 @@ class BambuMQTTClient:
                     )
                     if not hasattr(self, "_nozzle_rack_logged") and nozzle_info:
                         self._nozzle_rack_logged = True
-                        logger.info(
+                        logger.debug(
                             "[%s] Nozzle info: %d entries, IDs: %s",
                             self.serial_number,
                             len(nozzle_info),
@@ -1836,11 +1870,11 @@ class BambuMQTTClient:
         # Track RUNNING state for more robust completion detection
         if self.state.state == "RUNNING" and current_file:
             if not self._was_running:
-                logger.info("[%s] Now tracking RUNNING state for %s", self.serial_number, current_file)
+                logger.debug("[%s] Now tracking RUNNING state for %s", self.serial_number, current_file)
                 # Check if timelapse was enabled in the same message (xcam parsed before this)
                 if self.state.timelapse:
                     self._timelapse_during_print = True
-                    logger.info("[%s] Timelapse detected when entering RUNNING state", self.serial_number)
+                    logger.debug("[%s] Timelapse detected when entering RUNNING state", self.serial_number)
             self._was_running = True
             self._completion_triggered = False
 
@@ -1858,7 +1892,7 @@ class BambuMQTTClient:
             # We preserve that value instead of blindly resetting to False.
             if self.state.timelapse:
                 self._timelapse_during_print = True
-                logger.info("[%s] Timelapse detected at print start", self.serial_number)
+                logger.debug("[%s] Timelapse detected at print start", self.serial_number)
             else:
                 self._timelapse_during_print = False
 
@@ -1974,7 +2008,7 @@ class BambuMQTTClient:
         if not self._client or not self.state.connected:
             logger.warning("[%s] request_status_update: not connected", self.serial_number)
             return False
-        logger.info("[%s] Requesting status update (pushall)", self.serial_number)
+        logger.debug("[%s] Requesting status update (pushall)", self.serial_number)
         self._request_push_all()
         # Note: get_accessories returns stale nozzle data on H2D.
         # The correct nozzle data comes from push_status response.
@@ -2131,7 +2165,7 @@ class BambuMQTTClient:
             }
 
             if is_h2d:
-                logger.info(
+                logger.debug(
                     "[%s] H2D series detected: using integer format for calibration fields (use_ams stays boolean)",
                     self.serial_number,
                 )
@@ -2140,7 +2174,7 @@ class BambuMQTTClient:
             # P2S printer doesn't support vibration calibration like X1/P1 series
             if self.model and self.model.upper().strip() in ("P2S", "N7"):
                 command["print"]["vibration_cali"] = False
-                logger.info("[%s] P2S detected: disabling vibration_cali", self.serial_number)
+                logger.debug("[%s] P2S detected: disabling vibration_cali", self.serial_number)
 
             # Add AMS mapping if provided
             if ams_mapping is not None:
@@ -2219,7 +2253,7 @@ class BambuMQTTClient:
 
         command_json = json.dumps(command)
         self._client.publish(self.topic_publish, command_json, qos=1)
-        logger.info(
+        logger.debug(
             "[%s] Set xcam option: %s=%s, sensitivity=%s", self.serial_number, module_name, enabled, sensitivity
         )
         logger.debug("[%s] MQTT command sent: %s", self.serial_number, command_json)
@@ -2298,7 +2332,7 @@ class BambuMQTTClient:
 
         command_json = json.dumps(command)
         self._client.publish(self.topic_publish, command_json, qos=1)
-        logger.info("[%s] Set print option: %s=%s", self.serial_number, option_name, enabled)
+        logger.debug("[%s] Set print option: %s=%s", self.serial_number, option_name, enabled)
 
         # Set hold timer
         hold_key = f"print_option_{option_name}"
@@ -2424,7 +2458,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 +2466,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
@@ -2658,7 +2693,7 @@ class BambuMQTTClient:
         logger.info(
             f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id == 0})"
         )
-        logger.info("[%s] K-profile SET command: %s", self.serial_number, command_json)
+        logger.debug("[%s] K-profile SET command: %s", self.serial_number, command_json)
         self._client.publish(self.topic_publish, command_json, qos=1)
         return True
 
@@ -2726,7 +2761,7 @@ class BambuMQTTClient:
 
         command_json = json.dumps(command)
         logger.info("[%s] Setting %s K-profiles in batch", self.serial_number, len(filament_entries))
-        logger.info("[%s] K-profile SET batch command: %s", self.serial_number, command_json)
+        logger.debug("[%s] K-profile SET batch command: %s", self.serial_number, command_json)
         self._client.publish(self.topic_publish, command_json, qos=1)
         return True
 
@@ -2795,7 +2830,7 @@ class BambuMQTTClient:
         logger.info(
             f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}"
         )
-        logger.info("[%s] K-profile DELETE command: %s", self.serial_number, command_json)
+        logger.debug("[%s] K-profile DELETE command: %s", self.serial_number, command_json)
         # Use QoS 1 for reliable delivery (at least once)
         self._client.publish(self.topic_publish, command_json, qos=1)
         return True
@@ -3305,6 +3340,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 +3377,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 +3441,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 +3491,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 +3514,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 +3550,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 +3569,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 +3597,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/package-lock.json

@@ -6049,6 +6049,7 @@
       "version": "14.1.1",
       "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
       "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
+      "license": "MIT",
       "dependencies": {
         "argparse": "^2.0.1",
         "entities": "^4.4.0",

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

+ 9 - 6
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
@@ -249,6 +249,7 @@ export interface PrinterStatus {
   big_fan1_speed: number | null;     // Auxiliary fan
   big_fan2_speed: number | null;     // Chamber/exhaust fan
   heatbreak_fan_speed: number | null; // Hotend heatbreak fan
+  firmware_version: string | null;   // Firmware version from MQTT
 }
 
 export interface PrinterCreate {
@@ -748,8 +749,6 @@ export interface AppSettings {
   // Date/time format settings
   date_format: 'system' | 'us' | 'eu' | 'iso';
   time_format: 'system' | '12h' | '24h';
-  // Filament tracking
-  disable_filament_warnings: boolean;  // Disable insufficient filament warnings when printing/queueing
   // Default printer
   default_printer_id: number | null;
   // Dark mode theme settings
@@ -3030,6 +3029,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) =>
@@ -3362,9 +3363,9 @@ export const api = {
       body: JSON.stringify({ tray_uuid: trayUuid }),
     }),
   getSpoolmanSettings: () =>
-    request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; disable_filament_warnings: string; }>('/settings/spoolman'),
-  updateSpoolmanSettings: (data: { spoolman_enabled?: string; spoolman_url?: string; spoolman_sync_mode?: string; spoolman_disable_weight_sync?: string; spoolman_report_partial_usage?: string; disable_filament_warnings?: string; }) =>
-    request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; disable_filament_warnings: string; }>('/settings/spoolman', {
+    request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; }>('/settings/spoolman'),
+  updateSpoolmanSettings: (data: { spoolman_enabled?: string; spoolman_url?: string; spoolman_sync_mode?: string; spoolman_disable_weight_sync?: string; spoolman_report_partial_usage?: string; }) =>
+    request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; }>('/settings/spoolman', {
       method: 'PUT',
       body: JSON.stringify(data),
     }),
@@ -3427,6 +3428,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',

+ 7 - 56
frontend/src/components/AssignSpoolModal.tsx

@@ -5,7 +5,6 @@ import { X, Loader2, Package, Check, Search } from 'lucide-react';
 import { api } from '../api/client';
 import type { InventorySpool, SpoolAssignment } from '../api/client';
 import { Button } from './Button';
-import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 
 interface AssignSpoolModalProps {
@@ -27,8 +26,6 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
   const { showToast } = useToast();
   const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
   const [searchFilter, setSearchFilter] = useState('');
-  const [pendingAssignId, setPendingAssignId] = useState<number | null>(null);
-  const [showMismatchConfirm, setShowMismatchConfirm] = useState(false);
 
   const { data: spools, isLoading } = useQuery({
     queryKey: ['inventory-spools'],
@@ -56,8 +53,6 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
       });
       queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
       showToast(t('inventory.assignSuccess'), 'success');
-      setShowMismatchConfirm(false);
-      setPendingAssignId(null);
       onClose();
     },
     onError: (error: Error) => {
@@ -67,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) => {
@@ -89,35 +86,10 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     );
   });
 
-  const normalizeMaterial = (value: string | undefined | null) =>
-    (value ?? '').trim().toUpperCase();
-
-  const materialsMatch = (spoolMaterial: string, trayMaterial: string) => {
-    const normalizedSpool = normalizeMaterial(spoolMaterial);
-    const normalizedTray = normalizeMaterial(trayMaterial);
-    if (!normalizedSpool || !normalizedTray) return true;
-    if (normalizedTray.includes(normalizedSpool)) return true;
-    if (normalizedSpool.includes(normalizedTray)) return true;
-    return false;
-  };
-
   const handleAssign = () => {
-    if (!selectedSpoolId) return;
-    const selectedSpool = spools?.find((spool: InventorySpool) => spool.id === selectedSpoolId);
-    if (selectedSpool && trayInfo?.type) {
-      const mismatch = !materialsMatch(selectedSpool.material, trayInfo.type);
-      if (mismatch) {
-        setPendingAssignId(selectedSpoolId);
-        setShowMismatchConfirm(true);
-        return;
-      }
+    if (selectedSpoolId) {
+      assignMutation.mutate(selectedSpoolId);
     }
-    assignMutation.mutate(selectedSpoolId);
-  };
-
-  const handleConfirmMismatch = () => {
-    if (!pendingAssignId) return;
-    assignMutation.mutate(pendingAssignId);
   };
 
   return (
@@ -256,27 +228,6 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
           </div>
         )}
       </div>
-
-      {showMismatchConfirm && trayInfo && selectedSpoolId && (
-        <ConfirmModal
-          title={t('inventory.assignMismatchTitle')}
-          message={t('inventory.assignMismatchMessage', {
-            spoolMaterial: spools?.find((spool: InventorySpool) => spool.id === selectedSpoolId)?.material ?? '',
-            trayMaterial: trayInfo.type,
-            location: trayInfo.location,
-          })}
-          confirmText={t('inventory.assignMismatchConfirm')}
-          variant="warning"
-          isLoading={assignMutation.isPending}
-          onConfirm={handleConfirmMismatch}
-          onCancel={() => {
-            if (!assignMutation.isPending) {
-              setShowMismatchConfirm(false);
-              setPendingAssignId(null);
-            }
-          }}
-        />
-      )}
     </div>
   );
 }

+ 1 - 1
frontend/src/components/ColorCatalogSettings.tsx

@@ -436,7 +436,7 @@ export function ColorCatalogSettings() {
             {t('common.loading')}
           </div>
         ) : (
-          <div className="max-h-[400px] overflow-auto border border-bambu-dark-tertiary rounded-lg">
+          <div className="max-h-[600px] overflow-auto border border-bambu-dark-tertiary rounded-lg">
             <table className="w-full text-sm">
               <thead className="bg-bambu-dark sticky top-0">
                 <tr>

+ 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 (black is valid — empty slots don't pass trayColor)
+      if (slotInfo.trayColor) {
+        const hex = slotInfo.trayColor.slice(0, 6);
+        if (hex) {
+          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>

+ 2 - 2
frontend/src/components/ConfirmModal.tsx

@@ -51,7 +51,7 @@ export function ConfirmModal({
     },
     warning: {
       icon: 'text-yellow-400',
-      button: 'bg-yellow-500 hover:bg-yellow-600 text-black',
+      button: 'bg-yellow-500 hover:bg-yellow-600',
     },
     default: {
       icon: 'text-bambu-green',
@@ -77,7 +77,7 @@ export function ConfirmModal({
             </div>
             <div className="flex-1">
               <h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
-              <p className="text-bambu-gray text-sm whitespace-pre-line">{message}</p>
+              <p className="text-bambu-gray text-sm">{message}</p>
             </div>
           </div>
           <div className="flex gap-3 mt-6">

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

+ 4 - 127
frontend/src/components/PrintModal/index.tsx

@@ -3,14 +3,13 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
 import { api } from '../../api/client';
-import type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
+import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
 import { Card, CardContent } from '../Card';
 import { Button } from '../Button';
-import { ConfirmModal } from '../ConfirmModal';
 import { useToast } from '../../contexts/ToastContext';
-import { buildLoadedFilaments, useFilamentMapping } from '../../hooks/useFilamentMapping';
+import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
-import { getGlobalTrayId, isPlaceholderDate } from '../../utils/amsHelpers';
+import { isPlaceholderDate } from '../../utils/amsHelpers';
 import { toDateTimeLocalValue } from '../../utils/date';
 import { PrinterSelector } from './PrinterSelector';
 import { PlateSelector } from './PlateSelector';
@@ -51,13 +50,6 @@ export function PrintModal({
   // Determine if we're printing a library file
   const isLibraryFile = !!libraryFileId && !archiveId;
 
-  type FilamentWarningItem = {
-    printerName: string;
-    slotLabel: string;
-    requiredGrams: number;
-    remainingGrams: number;
-  };
-
   // Multiple printer selection (used for all modes now)
   const [selectedPrinters, setSelectedPrinters] = useState<number[]>(() => {
     // Initialize with the queue item's printer if editing
@@ -163,8 +155,6 @@ export function PrintModal({
   const [isSubmitting, setIsSubmitting] = useState(false);
   const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 });
 
-  const [filamentWarningItems, setFilamentWarningItems] = useState<FilamentWarningItem[] | null>(null);
-
   // Track which printers have had the "Expand custom mapping by default" setting applied
   // This ensures the setting only affects initial state, not preventing unchecking
   const [initialExpandApplied, setInitialExpandApplied] = useState<Set<number>>(new Set());
@@ -185,13 +175,6 @@ export function PrintModal({
     queryFn: api.getPrinters,
   });
 
-  const { data: spoolAssignments } = useQuery({
-    queryKey: ['spool-assignments'],
-    queryFn: () => api.getAssignments(),
-    staleTime: 30 * 1000,
-    enabled: (mode === 'reprint' || mode === 'add-to-queue') && assignmentMode === 'printer',
-  });
-
   // Fetch archive details to get sliced_for_model
   const { data: archiveDetails } = useQuery({
     queryKey: ['archive', archiveId],
@@ -346,35 +329,6 @@ export function PrintModal({
   const isMultiPlate = platesData?.is_multi_plate ?? false;
   const plates = platesData?.plates ?? [];
 
-  const spoolAssignmentsByPrinter = useMemo(() => {
-    const map = new Map<number, Map<number, SpoolAssignment>>();
-    if (!spoolAssignments) return map;
-    spoolAssignments.forEach((assignment) => {
-      const globalTrayId = getGlobalTrayId(
-        assignment.ams_id,
-        assignment.tray_id,
-        assignment.ams_id < 0
-      );
-      const printerMap = map.get(assignment.printer_id) ?? new Map();
-      printerMap.set(globalTrayId, assignment);
-      map.set(assignment.printer_id, printerMap);
-    });
-    return map;
-  }, [spoolAssignments]);
-
-  const filamentWarningMessage = useMemo(() => {
-    if (!filamentWarningItems || filamentWarningItems.length === 0) return '';
-    const lines = filamentWarningItems.map((item) =>
-      t('printModal.insufficientFilamentLine', {
-        printer: item.printerName,
-        slot: item.slotLabel,
-        required: Math.round(item.requiredGrams),
-        remaining: Math.round(item.remainingGrams),
-      })
-    );
-    return [t('printModal.insufficientFilamentMessage'), ...lines].join('\n');
-  }, [filamentWarningItems, t]);
-
   // Add to queue mutation (single printer)
   const addToQueueMutation = useMutation({
     mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data),
@@ -394,71 +348,9 @@ export function PrintModal({
     },
   });
 
-  const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
+  const handleSubmit = async (e?: React.FormEvent) => {
     e?.preventDefault();
 
-    if (
-      !options?.skipFilamentCheck &&
-      !settings?.disable_filament_warnings &&
-      (mode === 'reprint' || mode === 'add-to-queue') &&
-      assignmentMode === 'printer'
-    ) {
-      const warningItems: FilamentWarningItem[] = [];
-      const filamentReqs = effectiveFilamentReqs?.filaments ?? [];
-
-      if (filamentReqs.length > 0 && spoolAssignmentsByPrinter.size > 0) {
-        const getRemainingWeight = (labelWeight: number, weightUsed: number) => {
-          if (!Number.isFinite(labelWeight) || labelWeight <= 0) return null;
-          if (!Number.isFinite(weightUsed) || weightUsed < 0) return null;
-          return Math.max(0, labelWeight - weightUsed);
-        };
-
-        for (const printerId of selectedPrinters) {
-          const printerMapping = selectedPrinters.length > 1
-            ? multiPrinterMapping.getFinalMapping(printerId)
-            : amsMapping;
-          if (!printerMapping) continue;
-
-          const printerStatusForWarning = selectedPrinters.length > 1
-            ? multiPrinterMapping.printerResults.find((result) => result.printerId === printerId)?.status
-            : printerStatus;
-
-          const loadedFilaments = buildLoadedFilaments(printerStatusForWarning);
-          const slotLabelByTray = new Map(loadedFilaments.map((f) => [f.globalTrayId, f.label]));
-          const assignments = spoolAssignmentsByPrinter.get(printerId);
-          const printerName = printers?.find((p) => p.id === printerId)?.name ?? `Printer ${printerId}`;
-
-          if (!assignments) continue;
-
-          filamentReqs.forEach((req) => {
-            if (!req.slot_id || req.slot_id <= 0) return;
-            const globalTrayId = printerMapping[req.slot_id - 1];
-            if (!Number.isFinite(globalTrayId) || globalTrayId < 0) return;
-
-            const assignment = assignments.get(globalTrayId);
-            const spool = assignment?.spool;
-            if (!spool) return;
-
-            const remainingGrams = getRemainingWeight(spool.label_weight, spool.weight_used);
-            if (remainingGrams === null) return;
-            if (remainingGrams >= req.used_grams) return;
-
-            warningItems.push({
-              printerName,
-              slotLabel: slotLabelByTray.get(globalTrayId) ?? `Slot ${req.slot_id}`,
-              requiredGrams: req.used_grams,
-              remainingGrams,
-            });
-          });
-        }
-      }
-
-      if (warningItems.length > 0) {
-        setFilamentWarningItems(warningItems);
-        return;
-      }
-    }
-
     // Validate printer/model selection
     if (assignmentMode === 'printer' && selectedPrinters.length === 0) {
       showToast('Please select at least one printer', 'error');
@@ -846,21 +738,6 @@ export function PrintModal({
           </form>
         </CardContent>
       </Card>
-
-      {filamentWarningItems && filamentWarningItems.length > 0 && (
-        <ConfirmModal
-          title={t('printModal.insufficientFilamentTitle')}
-          message={filamentWarningMessage}
-          confirmText={t('printModal.printAnyway')}
-          cancelText={t('common.cancel')}
-          variant="warning"
-          onConfirm={() => {
-            setFilamentWarningItems(null);
-            void handleSubmit(undefined, { skipFilamentCheck: true });
-          }}
-          onCancel={() => setFilamentWarningItems(null)}
-        />
-      )}
     </div>
   );
 }

+ 1 - 1
frontend/src/components/SpoolCatalogSettings.tsx

@@ -271,7 +271,7 @@ export function SpoolCatalogSettings() {
             {t('common.loading')}
           </div>
         ) : (
-          <div className="max-h-[400px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
+          <div className="max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
             <table className="w-full text-sm">
               <thead className="bg-bambu-dark sticky top-0">
                 <tr>

+ 4 - 26
frontend/src/components/SpoolmanSettings.tsx

@@ -17,7 +17,6 @@ export function SpoolmanSettings() {
   const [localSyncMode, setLocalSyncMode] = useState('auto');
   const [localDisableWeightSync, setLocalDisableWeightSync] = useState(false);
   const [localReportPartialUsage, setLocalReportPartialUsage] = useState(true);
-  const [localDisableFilamentWarnings, setLocalDisableFilamentWarnings] = useState(false);
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
   const [isInitialized, setIsInitialized] = useState(false);
   const [showAllSkipped, setShowAllSkipped] = useState(false);
@@ -49,7 +48,6 @@ export function SpoolmanSettings() {
       setLocalSyncMode(settings.spoolman_sync_mode || 'auto');
       setLocalDisableWeightSync(settings.spoolman_disable_weight_sync === 'true');
       setLocalReportPartialUsage(settings.spoolman_report_partial_usage !== 'false');
-      setLocalDisableFilamentWarnings(settings.disable_filament_warnings === 'true');
       setIsInitialized(true);
     }
   }, [settings]);
@@ -64,8 +62,7 @@ export function SpoolmanSettings() {
       (settings.spoolman_url || '') !== localUrl ||
       (settings.spoolman_sync_mode || 'auto') !== localSyncMode ||
       (settings.spoolman_disable_weight_sync === 'true') !== localDisableWeightSync ||
-      (settings.spoolman_report_partial_usage !== 'false') !== localReportPartialUsage ||
-      (settings.disable_filament_warnings === 'true') !== localDisableFilamentWarnings;
+      (settings.spoolman_report_partial_usage !== 'false') !== localReportPartialUsage;
 
     if (hasChanges) {
       const timeoutId = setTimeout(() => {
@@ -74,7 +71,7 @@ export function SpoolmanSettings() {
       return () => clearTimeout(timeoutId);
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [localEnabled, localUrl, localSyncMode, localDisableWeightSync, localReportPartialUsage, localDisableFilamentWarnings, isInitialized]);
+  }, [localEnabled, localUrl, localSyncMode, localDisableWeightSync, localReportPartialUsage, isInitialized]);
 
   // Save mutation
   const saveMutation = useMutation({
@@ -85,12 +82,11 @@ export function SpoolmanSettings() {
         spoolman_sync_mode: localSyncMode,
         spoolman_disable_weight_sync: localDisableWeightSync ? 'true' : 'false',
         spoolman_report_partial_usage: localReportPartialUsage ? 'true' : 'false',
-        disable_filament_warnings: localDisableFilamentWarnings ? 'true' : 'false',
       }),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
-      queryClient.invalidateQueries({ queryKey: ['settings'] });
+      queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
       showToast(t('settings.toast.settingsSaved'));
     },
   });
@@ -238,25 +234,6 @@ export function SpoolmanSettings() {
           </button>
         </div>
 
-        {/* Disable Filament Warnings toggle - applies to both modes */}
-        <div className="flex items-center justify-between">
-          <div>
-            <p className="text-white">{t('settings.disableFilamentWarnings')}</p>
-            <p className="text-sm text-bambu-gray">
-              {t('settings.disableFilamentWarningsDesc')}
-            </p>
-          </div>
-          <label className="relative inline-flex items-center cursor-pointer">
-            <input
-              type="checkbox"
-              checked={localDisableFilamentWarnings}
-              onChange={(e) => setLocalDisableFilamentWarnings(e.target.checked)}
-              className="sr-only peer"
-            />
-            <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
-          </label>
-        </div>
-
         {/* Built-in Inventory details */}
         {!localEnabled && (
           <div className="p-3 bg-bambu-green/5 border border-bambu-green/20 rounded-lg">
@@ -267,6 +244,7 @@ export function SpoolmanSettings() {
                   <li>{t('settings.builtInFeatureRfid')}</li>
                   <li>{t('settings.builtInFeatureUsage')}</li>
                   <li>{t('settings.builtInFeatureCatalog')}</li>
+                  <li>{t('settings.builtInFeatureThirdParty')}</li>
                 </ul>
               </div>
             </div>

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

+ 26 - 9
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',
@@ -1162,14 +1164,13 @@ export default {
     // Filament Tracking Mode
     filamentTracking: 'Filament-Verfolgung',
     filamentTrackingDesc: 'Wählen Sie, wie Sie Ihre Filamentspulen verfolgen möchten. Sie können das integrierte Inventar oder einen externen Spoolman-Server verwenden.',
-    disableFilamentWarnings: 'Filament-Warnungen deaktivieren',
-    disableFilamentWarningsDesc: 'Keine Warnungen über unzureichendes Filament beim Drucken oder Einreihen anzeigen',
     trackingModeBuiltIn: 'Integriertes Inventar',
     trackingModeBuiltInDesc: 'RFID-Erkennung und Verbrauchserfassung inklusive',
     trackingModeSpoolmanDesc: 'Externer Filament-Management-Server',
     builtInFeatureRfid: 'Erkennt automatisch Bambu Lab RFID-Spulen im AMS',
     builtInFeatureUsage: 'Erfasst den Filamentverbrauch pro Druck',
     builtInFeatureCatalog: 'Spulen, Farben und K-Faktor-Profile verwalten',
+    builtInFeatureThirdParty: 'Drittanbieter-Spulen können Inventarspulen zugewiesen werden',
     // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'URL Ihres Spoolman-Servers (z.B. http://localhost:7912)',
@@ -2485,9 +2486,6 @@ export default {
     unassignSpool: 'Zuweisung aufheben',
     assignSuccess: 'Spule zugewiesen und AMS-Slot konfiguriert',
     assignFailed: 'Spulenzuweisung fehlgeschlagen',
-    assignMismatchTitle: 'Material stimmt nicht überein',
-    assignMismatchMessage: 'Das Material der ausgewählten Spule ({{spoolMaterial}}) stimmt nicht mit dem AMS-Profil ({{trayMaterial}}) für {{location}} überein. Trotzdem zuweisen?',
-    assignMismatchConfirm: 'Trotzdem zuweisen',
     selectSpool: 'Wählen Sie eine Spule für diesen Slot',
     assigned: 'Zugewiesen',
     assigning: 'Wird zugewiesen...',
@@ -2501,6 +2499,7 @@ export default {
     spoolArchived: 'Spule archiviert',
     spoolRestored: 'Spule wiederhergestellt',
     deleteConfirm: 'Möchten Sie diese Spule wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
+    archiveConfirm: 'Möchten Sie diese Spule wirklich archivieren?',
     advancedSettings: 'Erweiterte Einstellungen',
     filamentInfoTab: 'Filament-Info',
     paProfileTab: 'PA-Profil',
@@ -2651,10 +2650,6 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: 'Linke Düse',
     rightNozzleTooltip: 'Rechte Düse',
-    insufficientFilamentTitle: 'Nicht genug Filament',
-    insufficientFilamentMessage: 'Einige zugewiesene Spulen haben weniger Filament als dieser Druck benoetigt:',
-    insufficientFilamentLine: '{{printer}} - {{slot}}: benoetigt {{required}}g, verbleibend {{remaining}}g',
-    printAnyway: 'Trotzdem drucken',
   },
 
   // Backup
@@ -3213,15 +3208,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

+ 26 - 9
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',
@@ -1162,14 +1164,13 @@ export default {
     // Filament Tracking Mode
     filamentTracking: 'Filament Tracking',
     filamentTrackingDesc: 'Choose how to track your filament spools. You can use the built-in inventory or connect an external Spoolman server.',
-    disableFilamentWarnings: 'Disable filament warnings',
-    disableFilamentWarningsDesc: 'Don\'t show warnings about insufficient filament when printing or queueing',
     trackingModeBuiltIn: 'Built-in Inventory',
     trackingModeBuiltInDesc: 'RFID auto-matching and usage tracking included',
     trackingModeSpoolmanDesc: 'External filament management server',
     builtInFeatureRfid: 'Automatically detects Bambu Lab RFID spools in AMS',
     builtInFeatureUsage: 'Tracks filament consumption per print',
     builtInFeatureCatalog: 'Manage spools, colors, and K-factor profiles',
+    builtInFeatureThirdParty: 'Third-party spools can be assigned to inventory spools',
     // Spoolman settings
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'URL of your Spoolman server (e.g., http://localhost:7912)',
@@ -2485,9 +2486,6 @@ export default {
     unassignSpool: 'Unassign',
     assignSuccess: 'Spool assigned and AMS slot configured',
     assignFailed: 'Failed to assign spool',
-    assignMismatchTitle: 'Material mismatch',
-    assignMismatchMessage: 'The selected spool material ({{spoolMaterial}}) does not match the AMS profile ({{trayMaterial}}) for {{location}}. Assign anyway?',
-    assignMismatchConfirm: 'Assign Anyway',
     selectSpool: 'Select a spool to assign to this slot',
     assigned: 'Assigned',
     assigning: 'Assigning...',
@@ -2501,6 +2499,7 @@ export default {
     spoolArchived: 'Spool archived',
     spoolRestored: 'Spool restored',
     deleteConfirm: 'Are you sure you want to delete this spool? This cannot be undone.',
+    archiveConfirm: 'Are you sure you want to archive this spool?',
     advancedSettings: 'Advanced Settings',
     // Tabs
     filamentInfoTab: 'Filament Info',
@@ -2655,10 +2654,6 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: 'Left nozzle',
     rightNozzleTooltip: 'Right nozzle',
-    insufficientFilamentTitle: 'Not enough filament',
-    insufficientFilamentMessage: 'Some assigned spools have less filament remaining than this print needs:',
-    insufficientFilamentLine: '{{printer}} - {{slot}}: needs {{required}}g, remaining {{remaining}}g',
-    printAnyway: 'Print anyway',
   },
 
   // Backup
@@ -3218,15 +3213,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

+ 26 - 9
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',
@@ -1158,14 +1160,13 @@ export default {
     // Filament Tracking Mode
     filamentTracking: 'Suivi de Filament',
     filamentTrackingDesc: 'Choisissez comment suivre vos bobines. Utilisez l\'inventaire intégré ou connectez un serveur Spoolman.',
-    disableFilamentWarnings: 'Désactiver les avertissements de filament',
-    disableFilamentWarningsDesc: 'Ne pas afficher les avertissements de filament insuffisant lors de l\'impression ou de la mise en file d\'attente',
     trackingModeBuiltIn: 'Inventaire Intégré',
     trackingModeBuiltInDesc: 'Correspondance RFID et suivi de consommation inclus',
     trackingModeSpoolmanDesc: 'Serveur de gestion externe',
     builtInFeatureRfid: 'Détecte auto les bobines RFID Bambu Lab dans l\'AMS',
     builtInFeatureUsage: 'Suit la consommation par impression',
     builtInFeatureCatalog: 'Gère bobines, couleurs et profils facteur K',
+    builtInFeatureThirdParty: 'Les bobines tierces peuvent être assignées aux bobines d\'inventaire',
     // Spoolman settings
     spoolmanUrl: 'URL Spoolman',
     spoolmanUrlHint: 'URL de votre serveur Spoolman (ex: http://localhost:7912)',
@@ -2481,9 +2482,6 @@ export default {
     unassignSpool: 'Désassigner',
     assignSuccess: 'Bobine assignée et slot AMS configuré',
     assignFailed: 'Échec assignation',
-    assignMismatchTitle: 'Matériau non correspondant',
-    assignMismatchMessage: 'Le matériau de la bobine sélectionnée ({{spoolMaterial}}) ne correspond pas au profil AMS ({{trayMaterial}}) pour {{location}}. Assigner quand même ?',
-    assignMismatchConfirm: 'Assigner quand même',
     selectSpool: 'Choisir une bobine pour ce slot',
     assigned: 'Assigné',
     assigning: 'Assignation...',
@@ -2497,6 +2495,7 @@ export default {
     spoolArchived: 'Bobine archivée',
     spoolRestored: 'Bobine restaurée',
     deleteConfirm: 'Supprimer définitivement cette bobine ?',
+    archiveConfirm: 'Voulez-vous vraiment archiver cette bobine ?',
     advancedSettings: 'Paramètres Avancés',
     // Tabs
     filamentInfoTab: 'Infos Filament',
@@ -2651,10 +2650,6 @@ export default {
     rightNozzle: 'D',
     leftNozzleTooltip: 'Buse gauche',
     rightNozzleTooltip: 'Buse droite',
-    insufficientFilamentTitle: 'Filament insuffisant',
-    insufficientFilamentMessage: 'Certaines bobines assignées ont moins de filament restant que nécessaire pour cette impression :',
-    insufficientFilamentLine: '{{printer}} - {{slot}} : nécessite {{required}}g, restant {{remaining}}g',
-    printAnyway: 'Imprimer quand même',
   },
 
   // Backup
@@ -3214,15 +3209,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 - 18
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',
@@ -1092,17 +1094,6 @@ export default {
       turnOn: 'Accendi',
       turnOff: 'Spegni',
     },
-    // Filament Tracking Mode
-    filamentTracking: 'Tracciamento filamento',
-    filamentTrackingDesc: 'Scegli come tracciare le tue bobine di filamento. Puoi utilizzare l\'inventario integrato o collegare un server Spoolman esterno.',
-    disableFilamentWarnings: 'Disabilita avvisi filamento',
-    disableFilamentWarningsDesc: 'Non mostrare avvisi per filamento insufficiente durante la stampa o l\'accodamento',
-    trackingModeBuiltIn: 'Inventario integrato',
-    trackingModeBuiltInDesc: 'Corrispondenza RFID automatica e tracciamento utilizzo inclusi',
-    trackingModeSpoolmanDesc: 'Server di gestione filamenti esterno',
-    builtInFeatureRfid: 'Rileva automaticamente le bobine RFID Bambu Lab nell\'AMS',
-    builtInFeatureUsage: 'Traccia il consumo di filamento per stampa',
-    builtInFeatureCatalog: 'Gestisci bobine, colori e profili K-factor',
     // Spoolman
     spoolmanEnabled: 'Abilita integrazione Spoolman',
     spoolmanUrl: 'URL Spoolman',
@@ -2314,9 +2305,6 @@ export default {
     unassignSpool: 'Deassegna',
     assignSuccess: 'Bobina assegnata e slot AMS configurato',
     assignFailed: 'Assegnazione bobina fallita',
-    assignMismatchTitle: 'Materiale non corrispondente',
-    assignMismatchMessage: 'Il materiale della bobina selezionata ({{spoolMaterial}}) non corrisponde al profilo AMS ({{trayMaterial}}) per {{location}}. Assegnare comunque?',
-    assignMismatchConfirm: 'Assegna comunque',
     selectSpool: 'Seleziona una bobina da assegnare a questo slot',
     assigned: 'Assegnato',
     assigning: 'Assegnazione...',
@@ -2390,10 +2378,6 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: 'Ugello sinistro',
     rightNozzleTooltip: 'Ugello destro',
-    insufficientFilamentTitle: 'Filamento insufficiente',
-    insufficientFilamentMessage: 'Alcune bobine assegnate hanno meno filamento rimanente di quanto necessario per questa stampa:',
-    insufficientFilamentLine: '{{printer}} - {{slot}}: necessita di {{required}}g, rimanenti {{remaining}}g',
-    printAnyway: 'Stampa comunque',
   },
 
   // Backup
@@ -2750,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',
+  },
 };

+ 26 - 9
frontend/src/i18n/locales/ja.ts

@@ -173,6 +173,8 @@ export default {
     noPrintersConfigured: 'プリンターが設定されていません',
     readyToPrint: '印刷可能',
     external: '外部',
+    extL: 'Ext-L',
+    extR: 'Ext-R',
     deleteArchives: '印刷アーカイブを削除',
     willBeSkipped: 'スキップされます',
     name: '名前',
@@ -1402,14 +1404,13 @@ export default {
     // フィラメント追跡モード
     filamentTracking: 'フィラメント追跡',
     filamentTrackingDesc: 'フィラメントスプールの追跡方法を選択してください。内蔵インベントリまたは外部Spoolmanサーバーを使用できます。',
-    disableFilamentWarnings: 'フィラメント警告を無効化',
-    disableFilamentWarningsDesc: '印刷またはキュー追加時にフィラメント不足の警告を表示しない',
     trackingModeBuiltIn: '内蔵インベントリ',
     trackingModeBuiltInDesc: 'RFID自動検出と使用量追跡を含む',
     trackingModeSpoolmanDesc: '外部フィラメント管理サーバー',
     builtInFeatureRfid: 'AMS内のBambu Lab RFIDスプールを自動検出',
     builtInFeatureUsage: 'プリントごとのフィラメント消費量を追跡',
     builtInFeatureCatalog: 'スプール、カラー、K値プロファイルを管理',
+    builtInFeatureThirdParty: 'サードパーティ製スプールをインベントリスプールに割り当て可能',
     // Spoolman設定
     spoolmanUrl: 'Spoolman URL',
     spoolmanUrlHint: 'Spoolmanサーバーのurl(例:http://localhost:7912)',
@@ -2416,9 +2417,6 @@ export default {
     unassignSpool: '割り当て解除',
     assignSuccess: 'スプールを割り当て、AMSスロットを設定しました',
     assignFailed: 'スプールの割り当てに失敗しました',
-    assignMismatchTitle: '素材が一致しません',
-    assignMismatchMessage: '選択したスプールの素材({{spoolMaterial}})が、{{location}} のAMSプロファイル({{trayMaterial}})と一致しません。割り当てを続行しますか?',
-    assignMismatchConfirm: '続行する',
     selectSpool: 'このスロットに割り当てるスプールを選択',
     assigned: '割り当て済み',
     assigning: '割り当て中...',
@@ -2432,6 +2430,7 @@ export default {
     spoolArchived: 'スプールをアーカイブしました',
     spoolRestored: 'スプールを復元しました',
     deleteConfirm: 'このスプールを削除しますか?この操作は元に戻せません。',
+    archiveConfirm: 'このスプールをアーカイブしますか?',
     advancedSettings: '詳細設定',
     filamentInfoTab: 'フィラメント情報',
     paProfileTab: 'PAプロファイル',
@@ -2571,10 +2570,6 @@ export default {
     rightNozzle: 'R',
     leftNozzleTooltip: '左ノズル',
     rightNozzleTooltip: '右ノズル',
-    insufficientFilamentTitle: 'フィラメントが不足しています',
-    insufficientFilamentMessage: '割り当てられたスプールの一部は、この印刷に必要な量より残量が少ないです:',
-    insufficientFilamentLine: '{{printer}} - {{slot}}: 必要 {{required}}g、残り {{remaining}}g',
-    printAnyway: 'それでも印刷',
   },
   backup: {
     restoreBackup: 'バックアップの復元',
@@ -2929,15 +2924,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

+ 5 - 0
frontend/src/pages/ArchivesPage.tsx

@@ -1707,6 +1707,11 @@ function ArchiveListRow({
         <div className="col-span-4">
           <div className="flex items-center gap-2">
             <p className="text-white text-sm truncate">{archive.print_name || archive.filename}</p>
+            {(archive.status === 'failed' || archive.status === 'aborted') && (
+              <span className="px-1.5 py-0.5 rounded text-[10px] leading-tight bg-status-error/80 text-white flex-shrink-0">
+                {archive.status === 'aborted' ? t('archives.card.cancelled') : t('archives.card.failed')}
+              </span>
+            )}
             {archive.timelapse_path && (
               <span title={t('archives.list.hasTimelapse')}>
                 <Film className="w-3.5 h-3.5 text-bambu-green flex-shrink-0" />

+ 23 - 6
frontend/src/pages/InventoryPage.tsx

@@ -11,6 +11,7 @@ import { api } from '../api/client';
 import type { InventorySpool, SpoolAssignment } from '../api/client';
 import { Button } from '../components/Button';
 import { SpoolFormModal } from '../components/SpoolFormModal';
+import { ConfirmModal } from '../components/ConfirmModal';
 import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
 import { useToast } from '../contexts/ToastContext';
 import { resolveSpoolColorName } from '../utils/colors';
@@ -303,6 +304,7 @@ export default function InventoryPage() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const [formModal, setFormModal] = useState<{ spool?: InventorySpool | null } | null>(null);
+  const [confirmAction, setConfirmAction] = useState<{ type: 'delete' | 'archive'; spoolId: number } | null>(null);
 
   // Filter state
   const [archiveFilter, setArchiveFilter] = useState<ArchiveFilter>('active');
@@ -947,7 +949,7 @@ export default function InventoryPage() {
                               </button>
                             ) : (
                               <button
-                                onClick={() => archiveMutation.mutate(spool.id)}
+                                onClick={() => setConfirmAction({ type: 'archive', spoolId: spool.id })}
                                 className="p-1.5 text-bambu-gray hover:text-yellow-400 rounded transition-colors"
                                 title={t('inventory.archive')}
                               >
@@ -955,11 +957,7 @@ export default function InventoryPage() {
                               </button>
                             )}
                             <button
-                              onClick={() => {
-                                if (confirm(t('inventory.deleteConfirm'))) {
-                                  deleteMutation.mutate(spool.id);
-                                }
-                              }}
+                              onClick={() => setConfirmAction({ type: 'delete', spoolId: spool.id })}
                               className="p-1.5 text-bambu-gray hover:text-red-400 rounded transition-colors"
                               title={t('common.delete')}
                             >
@@ -1056,6 +1054,25 @@ export default function InventoryPage() {
         />
       )}
 
+      {/* Confirm Modal (delete / archive) */}
+      {confirmAction && (
+        <ConfirmModal
+          title={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}
+          message={confirmAction.type === 'delete' ? t('inventory.deleteConfirm') : t('inventory.archiveConfirm')}
+          confirmText={confirmAction.type === 'delete' ? t('common.delete') : t('inventory.archive')}
+          variant={confirmAction.type === 'delete' ? 'danger' : 'warning'}
+          onConfirm={() => {
+            if (confirmAction.type === 'delete') {
+              deleteMutation.mutate(confirmAction.spoolId);
+            } else {
+              archiveMutation.mutate(confirmAction.spoolId);
+            }
+            setConfirmAction(null);
+          }}
+          onCancel={() => setConfirmAction(null)}
+        />
+      )}
+
       {/* Column Config Modal */}
       <ColumnConfigModal
         isOpen={showColumnModal}

+ 227 - 183
frontend/src/pages/PrintersPage.tsx

@@ -1388,6 +1388,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,
@@ -1473,6 +1511,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<{
@@ -1520,8 +1561,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) {
@@ -1586,18 +1627,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({
@@ -2244,7 +2287,7 @@ function PrinterCard({
                 </button>
               )}
               {/* Firmware Version Badge */}
-              {firmwareInfo?.current_version && firmwareInfo?.latest_version && (
+              {checkPrinterFirmware && firmwareInfo?.current_version && firmwareInfo?.latest_version ? (
                 <button
                   onClick={() => setShowFirmwareModal(true)}
                   className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs hover:opacity-80 transition-opacity ${
@@ -2261,7 +2304,11 @@ function PrinterCard({
                   {firmwareInfo.update_available ? <Download className="w-3 h-3" /> : <CheckCircle className="w-3 h-3" />}
                   {firmwareInfo.current_version}
                 </button>
-              )}
+              ) : status?.firmware_version ? (
+                <span className="flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-bambu-dark-tertiary/50 text-bambu-gray">
+                  {status.firmware_version}
+                </span>
+              ) : null}
             </div>
           )}
         </div>
@@ -2662,7 +2709,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);
@@ -2887,7 +2934,7 @@ function PrinterCard({
                                             });
                                           } : undefined,
                                         }}
-                                        inventory={(() => {
+                                        inventory={spoolmanEnabled ? undefined : (() => {
                                           const assignment = onGetAssignment?.(printer.id, ams.id, slotIdx);
                                           return {
                                             assignedSpool: assignment?.spool ? {
@@ -2919,6 +2966,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,
                                           }),
                                         }}
                                       >
@@ -2932,6 +2982,7 @@ function PrinterCard({
                                             amsId: ams.id,
                                             trayId: slotIdx,
                                             trayCount: ams.tray.length,
+                                            extruderId: mappedExtruderId,
                                           }),
                                         }}
                                       >
@@ -2949,7 +3000,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) => {
@@ -3121,7 +3172,7 @@ function PrinterCard({
                                         });
                                       } : undefined,
                                     }}
-                                    inventory={(() => {
+                                    inventory={spoolmanEnabled ? undefined : (() => {
                                       const assignment = onGetAssignment?.(printer.id, ams.id, htSlotId);
                                       return {
                                         assignedSpool: assignment?.spool ? {
@@ -3153,6 +3204,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,
                                       }),
                                     }}
                                   >
@@ -3166,6 +3220,7 @@ function PrinterCard({
                                         amsId: ams.id,
                                         trayId: htSlotId,
                                         trayCount: ams.tray.length,
+                                        extruderId: mappedExtruderId,
                                       }),
                                     }}
                                   >
@@ -3208,138 +3263,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={spoolmanEnabled ? undefined : (() => {
+                                        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>
@@ -3978,6 +4057,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] });
@@ -4132,43 +4212,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 - 0
frontend/src/pages/SettingsPage.tsx

@@ -285,6 +285,7 @@ export function SettingsPage() {
   const { data: updateCheck, refetch: refetchUpdateCheck, isRefetching: isCheckingUpdate } = useQuery({
     queryKey: ['updateCheck'],
     queryFn: api.checkForUpdates,
+    enabled: settings?.check_updates !== false,
     staleTime: 5 * 60 * 1000,
   });
 

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

+ 10 - 1
install/start_bambuddy.bat

@@ -20,8 +20,17 @@ REM    start_bambuddy.bat reset      Clean all & fresh start
 REM    set PORT=9000 & start_bambuddy.bat   Change port
 REM ============================================
 
-set "ROOT=%~dp0"
+REM Resolve ROOT based on the script location (more reliable than %CD%).
+set "SCRIPT_DIR=%~dp0"
+if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
+for %%I in ("%SCRIPT_DIR%") do set "SCRIPT_DIR_NAME=%%~nxI"
+if /I "%SCRIPT_DIR_NAME%"=="install" (
+    set "ROOT=%SCRIPT_DIR%\.."
+) else (
+    set "ROOT=%SCRIPT_DIR%"
+)
 if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
+cd /d "%ROOT%"
 
 set "PORTABLE=%ROOT%\.portable"
 set "PYTHON_DIR=%PORTABLE%\python"

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-C9JxyacH.js


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-OqmBOPoC.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-C9JxyacH.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-OqmBOPoC.css">
   </head>
   <body>
     <div id="root"></div>

+ 1 - 0
update_website_wiki.sh

@@ -21,6 +21,7 @@ git commit -m "Updated website"
 git push
 
 cd ../spoolbuddy-wiki
+git pull
 git add .
 git commit -m "Updated Wiki"
 git push

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