Sfoglia il codice sorgente

**Configure AMS Slot**
Manually configure AMS slots for third-party or generic filaments:
1. Hover over an AMS slot on the printer card
2. Click the menu button (:material-dots-vertical:) that appears
3. Select **Configure Slot**
4. Choose a filament preset from your Bambu Studio cloud presets
5. Select a matching K profile (pressure advance calibration)
6. Optionally set a custom color using the color picker
7. Click **Configure Slot** to apply

**Color Picker Features:**
- Enter custom hex codes or color names (e.g., "brown", "FF8800")
- Live preview of selected color
- Expandable color picker in Configure AMS Slot modal:
- 8 basic colors shown by default
- 24 additional colors available via expand button
- Tests for ConfigureAmsSlotModal component
- Tests for AMS change callback
- Updated README with AMS slot configuration feature
- Wiki documentation for Configure AMS Slot feature

- Multi plate issue where plate names showed incorrect. #93
- Items from Queue end up as "source" files in archive. #107
- Added env variable support to change network port. #108
Docs -> https://wiki.bambuddy.cool/getting-started/docker/?h=port#custom-port

maziggy 4 mesi fa
parent
commit
494da46b09

+ 16 - 0
CHANGELOG.md

@@ -2,6 +2,22 @@
 
 
 All notable changes to Bambuddy will be documented in this file.
 All notable changes to Bambuddy will be documented in this file.
 
 
+## [0.1.6b10] - 2026-01-20
+
+### New Features
+- **Expandable Color Picker** - Configure AMS Slot modal now has an expandable color palette:
+  - 8 basic colors shown by default (White, Black, Red, Blue, Green, Yellow, Orange, Gray)
+  - Click "+" to expand 24 additional colors (Cyan, Magenta, Purple, Pink, Brown, Beige, Navy, Teal, Lime, Gold, Silver, Maroon, Olive, Coral, Salmon, Turquoise, Violet, Indigo, Chocolate, Tan, Slate, Charcoal, Ivory, Cream)
+  - Click "-" to collapse back to basic colors
+
+### Fixed
+- **User preset AMS configuration** - Fixed user presets (inheriting from Bambu presets) showing empty fields in Bambu Studio after configuration:
+  - Now correctly derives `tray_info_idx` from the preset's `base_id` when `filament_id` is null
+  - User presets that inherit from Bambu presets (e.g., "# Overture Matte PLA @BBL H2D") now work correctly
+- **Faster AMS slot updates** - Frontend now updates immediately after configuring AMS slots:
+  - Added WebSocket broadcast to AMS change callback for instant UI updates
+  - Removed unnecessary delayed refetch that was causing slow updates
+
 ## [0.1.6b9] - 2026-01-19
 ## [0.1.6b9] - 2026-01-19
 
 
 ### New Features
 ### New Features

+ 5 - 3
Dockerfile

@@ -43,13 +43,15 @@ RUN mkdir -p /app/data /app/logs
 ENV PYTHONUNBUFFERED=1
 ENV PYTHONUNBUFFERED=1
 ENV DATA_DIR=/app/data
 ENV DATA_DIR=/app/data
 ENV LOG_DIR=/app/logs
 ENV LOG_DIR=/app/logs
+ENV PORT=8000
 
 
 EXPOSE 8000
 EXPOSE 8000
 
 
-# Health check
+# Health check (uses PORT env var via shell)
 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
 HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
-    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
+    CMD python -c "import urllib.request, os; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"PORT\", \"8000\")}/health')" || exit 1
 
 
 # Run the application
 # Run the application
 # Use standard asyncio loop (uvloop has permission issues in some Docker environments)
 # Use standard asyncio loop (uvloop has permission issues in some Docker environments)
-CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000", "--loop", "asyncio"]
+# Port is configurable via PORT environment variable (default: 8000)
+CMD sh -c "uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000} --loop asyncio"

+ 4 - 0
README.md

@@ -61,6 +61,7 @@
 - Resizable printer cards (S/M/L/XL)
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - Skip objects during print
 - AMS slot RFID re-read
 - AMS slot RFID re-read
+- AMS slot configuration (custom presets, K profiles, color picker)
 - HMS error monitoring with history
 - HMS error monitoring with history
 - Print success rates & trends
 - Print success rates & trends
 - Filament usage tracking
 - Filament usage tracking
@@ -287,6 +288,8 @@ Open **http://localhost:8000** in your browser.
 
 
 > **macOS/Windows users:** Docker Desktop doesn't support `network_mode: host`. Edit docker-compose.yml: comment out `network_mode: host` and uncomment the `ports:` section. Printer discovery won't work - add printers manually by IP.
 > **macOS/Windows users:** Docker Desktop doesn't support `network_mode: host`. Edit docker-compose.yml: comment out `network_mode: host` and uncomment the `ports:` section. Printer discovery won't work - add printers manually by IP.
 
 
+> **Linux users:** If you get "permission denied" errors, either prefix commands with `sudo` (e.g., `sudo docker compose up -d`) or [add your user to the docker group](https://docs.docker.com/engine/install/linux-postinstall/).
+
 <details>
 <details>
 <summary><strong>Docker Configuration & Commands</strong></summary>
 <summary><strong>Docker Configuration & Commands</strong></summary>
 
 
@@ -295,6 +298,7 @@ Open **http://localhost:8000** in your browser.
 | Variable | Default | Description |
 | Variable | Default | Description |
 |----------|---------|-------------|
 |----------|---------|-------------|
 | `TZ` | `UTC` | Your timezone (e.g., `America/New_York`, `Europe/Berlin`) |
 | `TZ` | `UTC` | Your timezone (e.g., `America/New_York`, `Europe/Berlin`) |
+| `PORT` | `8000` | Port BamBuddy runs on (with host networking mode) |
 | `DEBUG` | `false` | Enable debug logging |
 | `DEBUG` | `false` | Enable debug logging |
 | `LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
 | `LOG_LEVEL` | `INFO` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` |
 
 

+ 43 - 6
backend/app/api/routes/archives.py

@@ -2010,14 +2010,39 @@ async def get_archive_plates(
 
 
             plate_indices.sort()
             plate_indices.sort()
 
 
+            # Parse model_settings.config for plate names
+            # Plate names are stored with plater_id and plater_name keys
+            plate_names = {}  # plater_id -> name
+            if "Metadata/model_settings.config" in namelist:
+                try:
+                    model_content = zf.read("Metadata/model_settings.config").decode()
+                    model_root = ET.fromstring(model_content)
+                    for plate_elem in model_root.findall(".//plate"):
+                        plater_id = None
+                        plater_name = None
+                        for meta in plate_elem.findall("metadata"):
+                            key = meta.get("key")
+                            value = meta.get("value")
+                            if key == "plater_id" and value:
+                                try:
+                                    plater_id = int(value)
+                                except ValueError:
+                                    pass
+                            elif key == "plater_name" and value:
+                                plater_name = value.strip()
+                        if plater_id is not None and plater_name:
+                            plate_names[plater_id] = plater_name
+                except Exception:
+                    pass  # model_settings.config parsing is optional
+
             # Parse slice_info.config for plate metadata
             # Parse slice_info.config for plate metadata
-            plate_metadata = {}  # plate_index -> {filaments, prediction, weight, name}
+            plate_metadata = {}  # plate_index -> {filaments, prediction, weight, name, objects}
             if "Metadata/slice_info.config" in namelist:
             if "Metadata/slice_info.config" in namelist:
                 content = zf.read("Metadata/slice_info.config").decode()
                 content = zf.read("Metadata/slice_info.config").decode()
                 root = ET.fromstring(content)
                 root = ET.fromstring(content)
 
 
                 for plate_elem in root.findall(".//plate"):
                 for plate_elem in root.findall(".//plate"):
-                    plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None}
+                    plate_info = {"filaments": [], "prediction": None, "weight": None, "name": None, "objects": []}
 
 
                     # Get plate index from metadata
                     # Get plate index from metadata
                     plate_index = None
                     plate_index = None
@@ -2067,12 +2092,23 @@ async def get_archive_plates(
                     # Sort filaments by slot ID
                     # Sort filaments by slot ID
                     plate_info["filaments"].sort(key=lambda x: x["slot_id"])
                     plate_info["filaments"].sort(key=lambda x: x["slot_id"])
 
 
-                    # Get first object name as plate name hint
-                    first_obj = plate_elem.find("object")
-                    if first_obj is not None:
-                        plate_info["name"] = first_obj.get("name")
+                    # Collect all object names on this plate
+                    for obj_elem in plate_elem.findall("object"):
+                        obj_name = obj_elem.get("name")
+                        if obj_name and obj_name not in plate_info["objects"]:
+                            plate_info["objects"].append(obj_name)
 
 
+                    # Set plate name: prefer custom name from model_settings.config,
+                    # fall back to first object name if no custom name was set
                     if plate_index is not None:
                     if plate_index is not None:
+                        custom_name = plate_names.get(plate_index)
+                        if custom_name:
+                            plate_info["name"] = custom_name
+                        else:
+                            # Fall back to first object name as hint
+                            if plate_info["objects"]:
+                                plate_info["name"] = plate_info["objects"][0]
+
                         plate_metadata[plate_index] = plate_info
                         plate_metadata[plate_index] = plate_info
 
 
             # Build plate list
             # Build plate list
@@ -2084,6 +2120,7 @@ async def get_archive_plates(
                     {
                     {
                         "index": idx,
                         "index": idx,
                         "name": meta.get("name"),
                         "name": meta.get("name"),
+                        "objects": meta.get("objects", []),
                         "has_thumbnail": has_thumbnail,
                         "has_thumbnail": has_thumbnail,
                         "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
                         "thumbnail_url": f"/api/v1/archives/{archive_id}/plate-thumbnail/{idx}"
                         if has_thumbnail
                         if has_thumbnail

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

@@ -1067,6 +1067,169 @@ async def delete_slot_preset(
     return {"success": True}
     return {"success": True}
 
 
 
 
+@router.post("/{printer_id}/slots/{ams_id}/{tray_id}/configure")
+async def configure_ams_slot(
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    tray_info_idx: str = Query(...),
+    tray_type: str = Query(...),
+    tray_sub_brands: str = Query(...),
+    tray_color: str = Query(...),
+    nozzle_temp_min: int = Query(...),
+    nozzle_temp_max: int = Query(...),
+    cali_idx: int = Query(-1),
+    nozzle_diameter: str = Query("0.4"),
+    setting_id: str = Query(""),
+    kprofile_filament_id: str = Query(""),
+    kprofile_setting_id: str = Query(""),
+    k_value: float = Query(0.0),
+):
+    """Configure an AMS slot with a specific filament setting and K profile.
+
+    This sends two commands to the printer:
+    1. ams_filament_setting - sets filament type, color, temperature
+    2. extrusion_cali_sel - sets the K profile (pressure advance value)
+
+    Args:
+        printer_id: Database ID of the printer
+        ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
+        tray_id: Tray ID within the AMS (0-3)
+        tray_info_idx: Filament ID short format (e.g., "GFL05") or user preset ID
+        tray_type: Filament type (e.g., "PLA", "PETG")
+        tray_sub_brands: Sub-brand/profile name (e.g., "PLA Basic", "PETG HF")
+        tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
+        nozzle_temp_min: Minimum nozzle temperature
+        nozzle_temp_max: Maximum nozzle temperature
+        cali_idx: K profile calibration index (-1 for default 0.020)
+        nozzle_diameter: Nozzle diameter string (e.g., "0.4")
+        setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
+        kprofile_filament_id: K profile's filament_id for proper K profile linking
+        k_value: Direct K value to set (0.0 to skip direct K value setting)
+    """
+    import logging
+
+    logger = logging.getLogger(__name__)
+    logger.info(f"[configure_ams_slot] printer_id={printer_id}, ams_id={ams_id}, tray_id={tray_id}")
+    logger.info(
+        f"[configure_ams_slot] tray_info_idx={tray_info_idx!r}, tray_type={tray_type!r}, tray_sub_brands={tray_sub_brands!r}"
+    )
+    logger.info(
+        f"[configure_ams_slot] setting_id={setting_id!r}, kprofile_filament_id={kprofile_filament_id!r}, kprofile_setting_id={kprofile_setting_id!r}"
+    )
+
+    # Get MQTT client for this printer
+    client = printer_manager.get_client(printer_id)
+    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,
+    )
+
+    if not success:
+        raise HTTPException(status_code=500, detail="Failed to send filament configuration command")
+
+    # Send the calibration/K-profile commands
+    # Use the K profile's filament_id if provided, otherwise use tray_info_idx
+    filament_id_for_kprofile = kprofile_filament_id if kprofile_filament_id else tray_info_idx
+
+    # 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
+    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:
+        # Calculate global tray ID for extrusion_cali_set
+        if ams_id <= 3:
+            global_tray_id = ams_id * 4 + tray_id
+        elif ams_id >= 128 and ams_id <= 135:
+            global_tray_id = (ams_id - 128) * 4 + tray_id
+        else:
+            global_tray_id = tray_id
+
+        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,
+        )
+
+    # Request fresh status push from printer so frontend gets updated data via WebSocket
+    logger.info("[configure_ams_slot] Requesting status update from printer")
+    update_result = client.request_status_update()
+    logger.info(f"[configure_ams_slot] Status update request result: {update_result}")
+
+    return {
+        "success": True,
+        "message": f"Configured AMS {ams_id} tray {tray_id} with {tray_sub_brands}",
+    }
+
+
+@router.post("/{printer_id}/ams/{ams_id}/tray/{tray_id}/reset")
+async def reset_ams_slot(
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Reset an AMS slot to empty/unconfigured state.
+
+    This clears the filament configuration from the slot.
+    """
+    # Get MQTT client for this printer
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(status_code=400, detail="Printer not connected")
+
+    # Reset the slot
+    success = client.reset_ams_slot(ams_id=ams_id, tray_id=tray_id)
+
+    if not success:
+        raise HTTPException(status_code=500, detail="Failed to send reset command")
+
+    # Also delete any saved slot preset mapping
+    result = await db.execute(
+        select(SlotPresetMapping).where(
+            SlotPresetMapping.printer_id == printer_id,
+            SlotPresetMapping.ams_id == ams_id,
+            SlotPresetMapping.tray_id == tray_id,
+        )
+    )
+    mapping = result.scalar_one_or_none()
+    if mapping:
+        await db.delete(mapping)
+        await db.commit()
+
+    # Request fresh status push from printer so frontend gets updated data via WebSocket
+    client.request_status_update()
+
+    return {
+        "success": True,
+        "message": f"Reset AMS {ams_id} tray {tray_id}",
+    }
+
+
 @router.post("/{printer_id}/debug/simulate-print-complete")
 @router.post("/{printer_id}/debug/simulate-print-complete")
 async def debug_simulate_print_complete(
 async def debug_simulate_print_complete(
     printer_id: int,
     printer_id: int,

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 from pydantic_settings import BaseSettings
 
 
 # Application version - single source of truth
 # Application version - single source of truth
-APP_VERSION = "0.1.6-final"
+APP_VERSION = "0.1.6b10"
 GITHUB_REPO = "maziggy/bambuddy"
 GITHUB_REPO = "maziggy/bambuddy"
 
 
 # App directory - where the application is installed (for static files)
 # App directory - where the application is installed (for static files)

+ 13 - 0
backend/app/main.py

@@ -280,6 +280,19 @@ async def on_ams_change(printer_id: int, ams_data: list):
     except Exception:
     except Exception:
         pass  # Don't fail AMS callback if MQTT fails
         pass  # Don't fail AMS callback if MQTT fails
 
 
+    # Broadcast AMS change via WebSocket (bypasses status_key deduplication)
+    # This ensures frontend gets immediate updates when AMS slots are configured
+    try:
+        state = printer_manager.get_status(printer_id)
+        if state:
+            logger.info(f"[Printer {printer_id}] Broadcasting AMS change via WebSocket")
+            await ws_manager.send_printer_status(
+                printer_id,
+                printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+            )
+    except Exception as e:
+        logger.warning(f"Failed to broadcast AMS change for printer {printer_id}: {e}")
+
     try:
     try:
         async with async_session() as db:
         async with async_session() as db:
             from backend.app.api.routes.settings import get_setting
             from backend.app.api.routes.settings import get_setting

+ 181 - 7
backend/app/services/bambu_mqtt.py

@@ -358,6 +358,7 @@ class BambuMQTTClient:
             # Track last message time - receiving a message proves we're connected
             # Track last message time - receiving a message proves we're connected
             self._last_message_time = time.time()
             self._last_message_time = time.time()
             self.state.connected = True
             self.state.connected = True
+
             # TEMP: Dump full payload once to find extruder state field
             # TEMP: Dump full payload once to find extruder state field
             if not hasattr(self, "_payload_dumped"):
             if not hasattr(self, "_payload_dumped"):
                 self._payload_dumped = True
                 self._payload_dumped = True
@@ -1858,7 +1859,9 @@ class BambuMQTTClient:
             True if the request was sent, False if not connected.
             True if the request was sent, False if not connected.
         """
         """
         if not self._client or not self.state.connected:
         if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] request_status_update: not connected")
             return False
             return False
+        logger.info(f"[{self.serial_number}] Requesting status update (pushall)")
         self._request_push_all()
         self._request_push_all()
         # Note: get_accessories returns stale nozzle data on H2D.
         # Note: get_accessories returns stale nozzle data on H2D.
         # The correct nozzle data comes from push_status response.
         # The correct nozzle data comes from push_status response.
@@ -3155,20 +3158,22 @@ class BambuMQTTClient:
         tray_color: str,
         tray_color: str,
         nozzle_temp_min: int,
         nozzle_temp_min: int,
         nozzle_temp_max: int,
         nozzle_temp_max: int,
-        k: float,
+        setting_id: str = "",
     ) -> bool:
     ) -> bool:
-        """Set AMS tray filament settings including K (pressure advance) value.
+        """Set AMS tray filament settings (type, color, temperature).
+
+        Note: K value is set separately via extrusion_cali_sel command.
 
 
         Args:
         Args:
-            ams_id: AMS unit ID (0-3)
+            ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for HT AMS)
             tray_id: Tray ID within the AMS (0-3)
             tray_id: Tray ID within the AMS (0-3)
-            tray_info_idx: Filament preset ID (e.g., "GFA00")
+            tray_info_idx: Filament ID short format (e.g., "GFL05")
             tray_type: Filament type (e.g., "PLA", "PETG")
             tray_type: Filament type (e.g., "PLA", "PETG")
             tray_sub_brands: Sub-brand name (e.g., "PLA Basic", "PETG HF")
             tray_sub_brands: Sub-brand name (e.g., "PLA Basic", "PETG HF")
             tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
             tray_color: Color in RRGGBBAA hex format (e.g., "FFFF00FF")
             nozzle_temp_min: Minimum nozzle temperature
             nozzle_temp_min: Minimum nozzle temperature
             nozzle_temp_max: Maximum nozzle temperature
             nozzle_temp_max: Maximum nozzle temperature
-            k: Pressure advance (K) value (e.g., 0.020)
+            setting_id: Full setting ID with version (e.g., "GFSL05_07") - optional
 
 
         Returns:
         Returns:
             True if command was sent, False otherwise
             True if command was sent, False otherwise
@@ -3177,28 +3182,197 @@ class BambuMQTTClient:
             logger.warning(f"[{self.serial_number}] Cannot set AMS filament setting: not connected")
             logger.warning(f"[{self.serial_number}] Cannot set AMS filament setting: not connected")
             return False
             return False
 
 
+        # Calculate slot_id based on AMS type
+        if ams_id <= 3:
+            slot_id = tray_id
+        else:
+            # AMS-HT or external: slot_id = 0
+            slot_id = 0
+
         command = {
         command = {
             "print": {
             "print": {
                 "command": "ams_filament_setting",
                 "command": "ams_filament_setting",
                 "ams_id": ams_id,
                 "ams_id": ams_id,
                 "tray_id": tray_id,
                 "tray_id": tray_id,
+                "slot_id": slot_id,
                 "tray_info_idx": tray_info_idx,
                 "tray_info_idx": tray_info_idx,
                 "tray_type": tray_type,
                 "tray_type": tray_type,
                 "tray_sub_brands": tray_sub_brands,
                 "tray_sub_brands": tray_sub_brands,
                 "tray_color": tray_color,
                 "tray_color": tray_color,
                 "nozzle_temp_min": nozzle_temp_min,
                 "nozzle_temp_min": nozzle_temp_min,
                 "nozzle_temp_max": nozzle_temp_max,
                 "nozzle_temp_max": nozzle_temp_max,
-                "k": k,
                 "sequence_id": "0",
                 "sequence_id": "0",
             }
             }
         }
         }
 
 
+        # Include setting_id if provided (helps slicer show correct profile)
+        if setting_id:
+            command["print"]["setting_id"] = setting_id
+
         command_json = json.dumps(command)
         command_json = json.dumps(command)
-        logger.info(f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, k={k}")
+        logger.info(
+            f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, tray_info_idx={tray_info_idx}, setting_id={setting_id}"
+        )
         logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}")
         logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}")
         self._client.publish(self.topic_publish, command_json, qos=1)
         self._client.publish(self.topic_publish, command_json, qos=1)
         return True
         return True
 
 
+    def reset_ams_slot(self, ams_id: int, tray_id: int) -> bool:
+        """Reset an AMS slot to empty/unconfigured state.
+
+        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)
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot reset AMS slot: not connected")
+            return False
+
+        # Calculate slot_id based on AMS type
+        if ams_id <= 3:
+            slot_id = tray_id
+        else:
+            slot_id = 0
+
+        command = {
+            "print": {
+                "command": "ams_filament_setting",
+                "ams_id": ams_id,
+                "tray_id": tray_id,
+                "slot_id": slot_id,
+                "tray_info_idx": "",
+                "tray_type": "",
+                "tray_sub_brands": "",
+                "tray_color": "00000000",
+                "nozzle_temp_min": 0,
+                "nozzle_temp_max": 0,
+                "sequence_id": "0",
+            }
+        }
+
+        command_json = json.dumps(command)
+        logger.info(f"[{self.serial_number}] Resetting AMS slot: AMS {ams_id}, tray {tray_id}")
+        logger.debug(f"[{self.serial_number}] reset_ams_slot command: {command_json}")
+        self._client.publish(self.topic_publish, command_json, qos=1)
+        return True
+
+    def extrusion_cali_sel(
+        self,
+        ams_id: int,
+        tray_id: int,
+        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).
+
+        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
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot set calibration: not connected")
+            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:
+            slot_id = tray_id
+        elif ams_id >= 128 and ams_id <= 135:
+            slot_id = 0
+        else:
+            slot_id = 0
+
+        command = {
+            "print": {
+                "command": "extrusion_cali_sel",
+                "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
+                "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}"
+        )
+        logger.debug(f"[{self.serial_number}] extrusion_cali_sel command: {command_json}")
+        self._client.publish(self.topic_publish, command_json, qos=1)
+        return True
+
+    def extrusion_cali_set(
+        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,
+    ) -> 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.
+
+        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
+
+        Returns:
+            True if command was sent, False otherwise
+        """
+        if not self._client or not self.state.connected:
+            logger.warning(f"[{self.serial_number}] Cannot set K value: not connected")
+            return False
+
+        command = {
+            "print": {
+                "command": "extrusion_cali_set",
+                "tray_id": tray_id,
+                "k_value": k_value,
+                "n_coef": n_coef,
+                "nozzle_diameter": nozzle_diameter,
+                "bed_temp": bed_temp,
+                "nozzle_temp": nozzle_temp,
+                "max_volumetric_speed": max_volumetric_speed,
+                "sequence_id": "0",
+            }
+        }
+
+        command_json = json.dumps(command)
+        logger.info(f"[{self.serial_number}] Publishing extrusion_cali_set: tray {tray_id}, k_value={k_value}")
+        logger.debug(f"[{self.serial_number}] extrusion_cali_set command: {command_json}")
+        self._client.publish(self.topic_publish, command_json, qos=1)
+        return True
+
     def set_timelapse(self, enable: bool) -> bool:
     def set_timelapse(self, enable: bool) -> bool:
         """Enable or disable timelapse recording.
         """Enable or disable timelapse recording.
 
 

+ 31 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -916,3 +916,34 @@ class TestInitPrinterConnections:
             await init_printer_connections(mock_db)
             await init_printer_connections(mock_db)
 
 
             mock_manager.connect_printer.assert_not_called()
             mock_manager.connect_printer.assert_not_called()
+
+
+class TestAmsChangeCallback:
+    """Tests for AMS change callback functionality."""
+
+    @pytest.fixture
+    def manager(self):
+        """Create a fresh PrinterManager instance."""
+        return PrinterManager()
+
+    def test_ams_change_callback_is_triggered(self, manager):
+        """Verify AMS change callback is called when AMS data changes."""
+        callback = MagicMock()
+        manager.set_ams_change_callback(callback)
+
+        # Verify callback was set
+        assert manager._on_ams_change == callback
+
+    def test_ams_change_callback_receives_correct_data(self, manager):
+        """Verify AMS change callback receives the correct AMS data format."""
+        received_data = []
+
+        def capture_callback(printer_id, ams_data):
+            received_data.append((printer_id, ams_data))
+
+        manager.set_ams_change_callback(capture_callback)
+
+        # The callback should accept printer_id and ams_data
+        # This tests the callback signature
+        assert manager._on_ams_change is not None
+        assert callable(manager._on_ams_change)

+ 4 - 1
docker-compose.yml

@@ -14,7 +14,7 @@ services:
     # Comment out "network_mode: host" above and uncomment "ports:" below.
     # Comment out "network_mode: host" above and uncomment "ports:" below.
     # Note: Printer discovery won't work - add printers manually by IP.
     # Note: Printer discovery won't work - add printers manually by IP.
     #ports:
     #ports:
-    #  - "8000:8000"
+    #  - "${PORT:-8000}:8000"
     volumes:
     volumes:
       - bambuddy_data:/app/data
       - bambuddy_data:/app/data
       - bambuddy_logs:/app/logs
       - bambuddy_logs:/app/logs
@@ -24,6 +24,9 @@ services:
       - ./virtual_printer:/app/data/virtual_printer
       - ./virtual_printer:/app/data/virtual_printer
     environment:
     environment:
       - TZ=Europe/Berlin
       - TZ=Europe/Berlin
+      # Port BamBuddy runs on (default: 8000)
+      # Usage: PORT=8080 docker compose up -d
+      - PORT=${PORT:-8000}
     restart: unless-stopped
     restart: unless-stopped
 
 
 volumes:
 volumes:

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

@@ -0,0 +1,207 @@
+/**
+ * Tests for the ConfigureAmsSlotModal component.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, fireEvent, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { ConfigureAmsSlotModal } from '../../components/ConfigureAmsSlotModal';
+import { api } from '../../api/client';
+
+// Mock the API client
+vi.mock('../../api/client', () => ({
+  api: {
+    getCloudSettings: vi.fn(),
+    getKProfiles: vi.fn(),
+    configureAmsSlot: vi.fn(),
+    getCloudSettingDetail: vi.fn(),
+    saveSlotPreset: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+  },
+}));
+
+const mockCloudSettings = {
+  filament: [
+    {
+      setting_id: 'GFSL05_09',
+      name: 'Bambu PLA Basic @BBL X1C',
+      filament_id: 'GFL05',
+    },
+    {
+      setting_id: 'PFUScd84f663d2c2ef',
+      name: '# Overture Matte PLA @BBL H2D',
+      filament_id: null,
+    },
+  ],
+};
+
+const mockKProfiles = {
+  profiles: [
+    {
+      id: 1,
+      name: 'PLA Basic',
+      k_value: '0.020',
+      filament_id: 'GFL05',
+      setting_id: '',
+      extruder_id: 1,
+      cali_idx: 1,
+    },
+  ],
+};
+
+const defaultProps = {
+  isOpen: true,
+  onClose: vi.fn(),
+  printerId: 1,
+  slotInfo: {
+    amsId: 0,
+    trayId: 0,
+    trayCount: 4,
+    trayType: 'PLA',
+    trayColor: 'FFFFFF',
+    traySubBrands: 'PLA Basic',
+  },
+  nozzleDiameter: '0.4',
+  onSuccess: vi.fn(),
+};
+
+describe('ConfigureAmsSlotModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    (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 });
+  });
+
+  it('renders nothing visible when closed', () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} isOpen={false} />);
+    expect(screen.queryByText('Configure AMS Slot')).not.toBeInTheDocument();
+  });
+
+  it('renders modal when open', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      expect(screen.getByText(/Configure AMS/)).toBeInTheDocument();
+    });
+  });
+
+  it('displays basic color buttons', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      // Check for basic color buttons by their title attribute
+      expect(screen.getByTitle('White')).toBeInTheDocument();
+      expect(screen.getByTitle('Black')).toBeInTheDocument();
+      expect(screen.getByTitle('Red')).toBeInTheDocument();
+      expect(screen.getByTitle('Blue')).toBeInTheDocument();
+      expect(screen.getByTitle('Green')).toBeInTheDocument();
+      expect(screen.getByTitle('Yellow')).toBeInTheDocument();
+      expect(screen.getByTitle('Orange')).toBeInTheDocument();
+      expect(screen.getByTitle('Gray')).toBeInTheDocument();
+    });
+  });
+
+  it('does not show extended colors by default', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      expect(screen.getByTitle('White')).toBeInTheDocument();
+    });
+    // Extended colors should not be visible initially
+    expect(screen.queryByTitle('Cyan')).not.toBeInTheDocument();
+    expect(screen.queryByTitle('Purple')).not.toBeInTheDocument();
+    expect(screen.queryByTitle('Coral')).not.toBeInTheDocument();
+  });
+
+  it('shows extended colors when expand button is clicked', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      expect(screen.getByTitle('White')).toBeInTheDocument();
+    });
+
+    // Click the expand button (+ button)
+    const expandButton = screen.getByTitle('Show more colors');
+    fireEvent.click(expandButton);
+
+    // Extended colors should now be visible
+    await waitFor(() => {
+      expect(screen.getByTitle('Cyan')).toBeInTheDocument();
+      expect(screen.getByTitle('Purple')).toBeInTheDocument();
+      expect(screen.getByTitle('Pink')).toBeInTheDocument();
+      expect(screen.getByTitle('Brown')).toBeInTheDocument();
+      expect(screen.getByTitle('Coral')).toBeInTheDocument();
+    });
+  });
+
+  it('hides extended colors when collapse button is clicked', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      expect(screen.getByTitle('White')).toBeInTheDocument();
+    });
+
+    // Click the expand button
+    const expandButton = screen.getByTitle('Show more colors');
+    fireEvent.click(expandButton);
+
+    // Wait for extended colors to appear
+    await waitFor(() => {
+      expect(screen.getByTitle('Cyan')).toBeInTheDocument();
+    });
+
+    // Click the collapse button
+    const collapseButton = screen.getByTitle('Show less colors');
+    fireEvent.click(collapseButton);
+
+    // Extended colors should be hidden again
+    await waitFor(() => {
+      expect(screen.queryByTitle('Cyan')).not.toBeInTheDocument();
+    });
+  });
+
+  it('selects a color when color button is clicked', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+    await waitFor(() => {
+      expect(screen.getByTitle('Red')).toBeInTheDocument();
+    });
+
+    // Click the red color button
+    const redButton = screen.getByTitle('Red');
+    fireEvent.click(redButton);
+
+    // The color input should now show "Red"
+    const colorInput = screen.getByPlaceholderText(/Color name or hex/);
+    expect(colorInput).toHaveValue('Red');
+  });
+
+  it('derives tray_info_idx from base_id when filament_id is null', async () => {
+    // Mock the detail API to return base_id but no filament_id
+    (api.getCloudSettingDetail as ReturnType<typeof vi.fn>).mockResolvedValue({
+      filament_id: null,
+      base_id: 'GFSL05_09',
+      name: '# Overture Matte PLA @BBL H2D',
+    });
+
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+
+    // Wait for presets to load
+    await waitFor(() => {
+      expect(api.getCloudSettings).toHaveBeenCalled();
+    });
+
+    // Select a user preset (one without filament_id)
+    // Find and click the preset - this would require the preset to be in the list
+    // The actual tray_info_idx derivation happens during the configure mutation
+  });
+
+  it('renders configure slot button', async () => {
+    render(<ConfigureAmsSlotModal {...defaultProps} />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/Configure AMS/)).toBeInTheDocument();
+    });
+
+    // Find the Configure Slot button
+    const configureButton = screen.getByRole('button', { name: /Configure Slot/i });
+    expect(configureButton).toBeInTheDocument();
+  });
+});

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

@@ -1814,6 +1814,7 @@ export const api = {
       plates: Array<{
       plates: Array<{
         index: number;
         index: number;
         name: string | null;
         name: string | null;
+        objects: string[];
         has_thumbnail: boolean;
         has_thumbnail: boolean;
         thumbnail_url: string | null;
         thumbnail_url: string | null;
         print_time_seconds: number | null;
         print_time_seconds: number | null;
@@ -2139,6 +2140,57 @@ export const api = {
     request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
     request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
       method: 'DELETE',
       method: 'DELETE',
     }),
     }),
+  configureAmsSlot: (
+    printerId: number,
+    amsId: number,
+    trayId: number,
+    config: {
+      tray_info_idx: string;
+      tray_type: string;
+      tray_sub_brands: string;
+      tray_color: string;
+      nozzle_temp_min: number;
+      nozzle_temp_max: number;
+      cali_idx: number;
+      nozzle_diameter: string;
+      setting_id?: string;
+      kprofile_filament_id?: string;
+      kprofile_setting_id?: string;
+      k_value?: number;
+    }
+  ) => {
+    const params = new URLSearchParams({
+      tray_info_idx: config.tray_info_idx,
+      tray_type: config.tray_type,
+      tray_sub_brands: config.tray_sub_brands,
+      tray_color: config.tray_color,
+      nozzle_temp_min: config.nozzle_temp_min.toString(),
+      nozzle_temp_max: config.nozzle_temp_max.toString(),
+      cali_idx: config.cali_idx.toString(),
+      nozzle_diameter: config.nozzle_diameter,
+    });
+    if (config.setting_id) {
+      params.set('setting_id', config.setting_id);
+    }
+    if (config.kprofile_filament_id) {
+      params.set('kprofile_filament_id', config.kprofile_filament_id);
+    }
+    if (config.kprofile_setting_id) {
+      params.set('kprofile_setting_id', config.kprofile_setting_id);
+    }
+    if (config.k_value !== undefined && config.k_value > 0) {
+      params.set('k_value', config.k_value.toString());
+    }
+    return request<{ success: boolean; message: string }>(
+      `/printers/${printerId}/slots/${amsId}/${trayId}/configure?${params}`,
+      { method: 'POST' }
+    );
+  },
+  resetAmsSlot: (printerId: number, amsId: number, trayId: number) =>
+    request<{ success: boolean; message: string }>(
+      `/printers/${printerId}/ams/${amsId}/tray/${trayId}/reset`,
+      { method: 'POST' }
+    ),
 
 
   // Filaments
   // Filaments
   listFilaments: () => request<Filament[]>('/filaments/'),
   listFilaments: () => request<Filament[]>('/filaments/'),

+ 853 - 0
frontend/src/components/ConfigureAmsSlotModal.tsx

@@ -0,0 +1,853 @@
+import { useState, useMemo, useEffect, useCallback } from 'react';
+import { useQuery, useMutation } from '@tanstack/react-query';
+import { X, Loader2, Settings2, ChevronDown, CheckCircle2, RotateCcw } from 'lucide-react';
+import { api } from '../api/client';
+import type { KProfile } from '../api/client';
+import { Button } from './Button';
+
+interface SlotInfo {
+  amsId: number;
+  trayId: number;
+  trayCount: number;
+  trayType?: string;
+  trayColor?: string;
+  traySubBrands?: string;
+  trayInfoIdx?: string;
+}
+
+// Get proper AMS label (handles HT AMS with ID 128+)
+function getAmsLabel(amsId: number, trayCount: number): string {
+  // External spool
+  if (amsId === 255) return 'External';
+
+  let normalizedId: number;
+  let isHt = false;
+
+  if (amsId >= 128 && amsId <= 135) {
+    // HT AMS range: 128-135 → A-H
+    normalizedId = amsId - 128;
+    isHt = true;
+  } else if (amsId >= 0 && amsId <= 3) {
+    // Regular AMS range: 0-3 → A-D
+    normalizedId = amsId;
+    // Check tray count as secondary indicator
+    isHt = trayCount === 1;
+  } else {
+    // Unknown range - fallback to A
+    normalizedId = 0;
+  }
+
+  // Cap to valid letter range (A-H)
+  normalizedId = Math.max(0, Math.min(normalizedId, 7));
+  const letter = String.fromCharCode(65 + normalizedId);
+
+  return isHt ? `HT-${letter}` : `AMS-${letter}`;
+}
+
+// Convert setting_id to tray_info_idx (filament_id format)
+// Bambu format: setting_id "GFSL05" → tray_info_idx "GFL05"
+function convertToTrayInfoIdx(settingId: string): string {
+  // Strip version suffix if present (e.g., GFSL05_07 -> GFSL05)
+  const baseId = settingId.includes('_') ? settingId.split('_')[0] : settingId;
+
+  // Bambu presets start with "GFS" - remove the 'S' to get filament_id
+  if (baseId.startsWith('GFS')) {
+    return 'GF' + baseId.slice(3);
+  }
+
+  // User presets (PFUS*, PFSP*) - use the base setting_id (without version suffix)
+  // This follows the pattern that filament_id and setting_id share the same base ID
+  if (baseId.startsWith('PFUS') || baseId.startsWith('PFSP')) {
+    return baseId;  // Use base ID without version suffix
+  }
+
+  // For other formats, use as-is
+  return baseId;
+}
+
+interface ConfigureAmsSlotModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  printerId: number;
+  slotInfo: SlotInfo;
+  nozzleDiameter?: string;
+  onSuccess?: () => void;
+}
+
+// Known filament material types
+const MATERIAL_TYPES = ['PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PC', 'PA', 'NYLON', 'PVA', 'HIPS', 'PP', 'PET'];
+
+// Extract filament type from preset name by finding known material type
+function parsePresetName(name: string): { material: string; brand: string; variant: string } {
+  // Remove printer/nozzle suffix first
+  const withoutSuffix = name.replace(/@.+$/, '').trim();
+
+  // Try to find a known material type in the name
+  const upperName = withoutSuffix.toUpperCase();
+  for (const mat of MATERIAL_TYPES) {
+    // Use word boundary to match whole words only
+    const regex = new RegExp(`\\b${mat}\\b`, 'i');
+    if (regex.test(upperName)) {
+      // Found material, extract brand (everything before material) and variant (after)
+      const parts = withoutSuffix.split(regex);
+      const brand = parts[0]?.trim() || '';
+      const variant = parts[1]?.trim() || '';
+      return { material: mat, brand, variant };
+    }
+  }
+
+  // Fallback: assume first word is brand, second is material
+  const parts = withoutSuffix.split(/\s+/);
+  if (parts.length >= 2) {
+    return { material: parts[1], brand: parts[0], variant: parts.slice(2).join(' ') };
+  }
+
+  return { material: withoutSuffix, brand: '', variant: '' };
+}
+
+// Check if a preset is a user preset (not built-in)
+function isUserPreset(settingId: string): boolean {
+  // Built-in presets have specific patterns, user presets are UUIDs
+  return !settingId.startsWith('GF') && !settingId.startsWith('P1');
+}
+
+// Common color name to hex mapping
+const COLOR_NAME_MAP: Record<string, string> = {
+  // Basic colors
+  'white': 'FFFFFF',
+  'black': '000000',
+  'red': 'FF0000',
+  'green': '00FF00',
+  'blue': '0000FF',
+  'yellow': 'FFFF00',
+  'cyan': '00FFFF',
+  'magenta': 'FF00FF',
+  'orange': 'FFA500',
+  'purple': '800080',
+  'pink': 'FFC0CB',
+  'brown': '8B4513',
+  'gray': '808080',
+  'grey': '808080',
+  // Filament-specific colors
+  'jade white': 'FFFEF2',
+  'ivory': 'FFFFF0',
+  'beige': 'F5F5DC',
+  'cream': 'FFFDD0',
+  'silver': 'C0C0C0',
+  'gold': 'FFD700',
+  'bronze': 'CD7F32',
+  'copper': 'B87333',
+  'navy': '000080',
+  'teal': '008080',
+  'olive': '808000',
+  'maroon': '800000',
+  'coral': 'FF7F50',
+  'salmon': 'FA8072',
+  'lime': '32CD32',
+  'mint': '98FF98',
+  'forest green': '228B22',
+  'sky blue': '87CEEB',
+  'royal blue': '4169E1',
+  'turquoise': '40E0D0',
+  'lavender': 'E6E6FA',
+  'violet': 'EE82EE',
+  'plum': 'DDA0DD',
+  'tan': 'D2B48C',
+  'chocolate': 'D2691E',
+  'charcoal': '36454F',
+  'slate': '708090',
+  'transparent': '000000', // Will need special handling
+  'natural': 'F5F5DC',
+  'wood': 'DEB887',
+};
+
+// Quick-select color presets (common filament colors)
+// Basic colors shown by default
+const QUICK_COLORS_BASIC = [
+  { name: 'White', hex: 'FFFFFF' },
+  { name: 'Black', hex: '000000' },
+  { name: 'Red', hex: 'FF0000' },
+  { name: 'Blue', hex: '0000FF' },
+  { name: 'Green', hex: '00AA00' },
+  { name: 'Yellow', hex: 'FFFF00' },
+  { name: 'Orange', hex: 'FFA500' },
+  { name: 'Gray', hex: '808080' },
+];
+
+// Extended colors shown when expanded
+const QUICK_COLORS_EXTENDED = [
+  { name: 'Cyan', hex: '00FFFF' },
+  { name: 'Magenta', hex: 'FF00FF' },
+  { name: 'Purple', hex: '800080' },
+  { name: 'Pink', hex: 'FFC0CB' },
+  { name: 'Brown', hex: '8B4513' },
+  { name: 'Beige', hex: 'F5F5DC' },
+  { name: 'Navy', hex: '000080' },
+  { name: 'Teal', hex: '008080' },
+  { name: 'Lime', hex: '32CD32' },
+  { name: 'Gold', hex: 'FFD700' },
+  { name: 'Silver', hex: 'C0C0C0' },
+  { name: 'Maroon', hex: '800000' },
+  { name: 'Olive', hex: '808000' },
+  { name: 'Coral', hex: 'FF7F50' },
+  { name: 'Salmon', hex: 'FA8072' },
+  { name: 'Turquoise', hex: '40E0D0' },
+  { name: 'Violet', hex: 'EE82EE' },
+  { name: 'Indigo', hex: '4B0082' },
+  { name: 'Chocolate', hex: 'D2691E' },
+  { name: 'Tan', hex: 'D2B48C' },
+  { name: 'Slate', hex: '708090' },
+  { name: 'Charcoal', hex: '36454F' },
+  { name: 'Ivory', hex: 'FFFFF0' },
+  { name: 'Cream', hex: 'FFFDD0' },
+];
+
+// Try to convert color name to hex
+function colorNameToHex(name: string): string | null {
+  const normalized = name.toLowerCase().trim();
+  return COLOR_NAME_MAP[normalized] || null;
+}
+
+export function ConfigureAmsSlotModal({
+  isOpen,
+  onClose,
+  printerId,
+  slotInfo,
+  nozzleDiameter = '0.4',
+  onSuccess,
+}: ConfigureAmsSlotModalProps) {
+  const [selectedPresetId, setSelectedPresetId] = useState<string>('');
+  const [selectedKProfile, setSelectedKProfile] = useState<KProfile | null>(null);
+  const [colorHex, setColorHex] = useState<string>(''); // Just the 6-char hex, no alpha
+  const [colorInput, setColorInput] = useState<string>(''); // User's text input (name or hex)
+  const [searchQuery, setSearchQuery] = useState('');
+  const [showSuccess, setShowSuccess] = useState(false);
+  const [showExtendedColors, setShowExtendedColors] = useState(false);
+
+  // Fetch cloud settings
+  const { data: cloudSettings, isLoading: settingsLoading } = useQuery({
+    queryKey: ['cloudSettings'],
+    queryFn: () => api.getCloudSettings(),
+    enabled: isOpen,
+  });
+
+  // Fetch K profiles
+  const { data: kprofilesData, isLoading: kprofilesLoading } = useQuery({
+    queryKey: ['kprofiles', printerId, nozzleDiameter],
+    queryFn: () => api.getKProfiles(printerId, nozzleDiameter),
+    enabled: isOpen && !!printerId,
+  });
+
+  // Configure slot mutation
+  const configureMutation = useMutation({
+    mutationFn: async () => {
+      if (!selectedPresetId) throw new Error('No filament preset selected');
+
+      // Get the selected preset details
+      const selectedPreset = cloudSettings?.filament.find(p => p.setting_id === selectedPresetId);
+      if (!selectedPreset) throw new Error('Selected preset not found');
+
+      // Parse the preset name for filament info
+      const parsed = parsePresetName(selectedPreset.name);
+
+      // Get cali_idx from selected K profile's slot_id (-1 = use default 0.020)
+      const caliIdx = selectedKProfile?.slot_id ?? -1;
+
+      // Use custom color if set, otherwise use current slot color or default
+      const color = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
+
+      // Create the tray_sub_brands from preset name (without printer/nozzle suffix)
+      const traySubBrands = selectedPreset.name.replace(/@.+$/, '').trim();
+
+      // Get tray_info_idx: for user presets, fetch detail to get filament_id or derive from base_id
+      let trayInfoIdx = convertToTrayInfoIdx(selectedPresetId);
+
+      // For user presets (not starting with GF), fetch the detail to get the real filament_id
+      if (!selectedPresetId.startsWith('GFS')) {
+        try {
+          const detail = await api.getCloudSettingDetail(selectedPresetId);
+          if (detail.filament_id) {
+            trayInfoIdx = detail.filament_id;
+          } else if (detail.base_id) {
+            // If no filament_id but has base_id (e.g., "GFSL05_09"), derive tray_info_idx from it
+            // This is common for user presets that inherit from Bambu presets
+            trayInfoIdx = convertToTrayInfoIdx(detail.base_id);
+            console.log(`Derived tray_info_idx from base_id: ${detail.base_id} -> ${trayInfoIdx}`);
+          }
+        } catch (e) {
+          console.warn('Failed to fetch preset detail for filament_id:', e);
+          // Fall back to derived tray_info_idx
+        }
+      }
+
+      // Default temp range based on material type
+      let tempMin = 190;
+      let tempMax = 230;
+      const material = parsed.material.toUpperCase();
+      if (material.includes('PLA')) {
+        tempMin = 190;
+        tempMax = 230;
+      } else if (material.includes('PETG')) {
+        tempMin = 220;
+        tempMax = 260;
+      } else if (material.includes('ABS')) {
+        tempMin = 240;
+        tempMax = 280;
+      } else if (material.includes('ASA')) {
+        tempMin = 240;
+        tempMax = 280;
+      } else if (material.includes('TPU')) {
+        tempMin = 200;
+        tempMax = 240;
+      } else if (material.includes('PC')) {
+        tempMin = 260;
+        tempMax = 300;
+      } else if (material.includes('PA') || material.includes('NYLON')) {
+        tempMin = 250;
+        tempMax = 290;
+      }
+
+      // Parse K value from selected profile
+      const kValue = selectedKProfile?.k_value ? parseFloat(selectedKProfile.k_value) : 0;
+
+      // Configure the slot via MQTT
+      const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, {
+        tray_info_idx: trayInfoIdx,
+        tray_type: parsed.material || 'PLA',
+        tray_sub_brands: traySubBrands,
+        tray_color: color + 'FF', // Add alpha
+        nozzle_temp_min: tempMin,
+        nozzle_temp_max: tempMax,
+        cali_idx: caliIdx,
+        nozzle_diameter: nozzleDiameter,
+        setting_id: selectedPresetId, // Full setting ID for slicer compatibility
+        // Pass K profile's filament_id and setting_id for proper linking
+        kprofile_filament_id: selectedKProfile?.filament_id,
+        kprofile_setting_id: selectedKProfile?.setting_id || undefined,
+        // Also pass the K value directly for extrusion_cali_set command
+        k_value: kValue,
+      });
+
+      // Save the preset mapping so we can display the correct name in the UI
+      // This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx,
+      // which can't be resolved to a name via the filamentInfo API
+      try {
+        await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, selectedPresetId, traySubBrands);
+      } catch (e) {
+        console.warn('Failed to save slot preset mapping:', e);
+        // Don't fail the whole operation - slot was configured successfully
+      }
+
+      return result;
+    },
+    onSuccess: () => {
+      setShowSuccess(true);
+      onSuccess?.();
+      // Close after showing success briefly
+      setTimeout(() => {
+        setShowSuccess(false);
+        onClose();
+      }, 1500);
+    },
+  });
+
+  // Reset slot mutation
+  const resetMutation = useMutation({
+    mutationFn: async () => {
+      return api.resetAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId);
+    },
+    onSuccess: () => {
+      setShowSuccess(true);
+      onSuccess?.();
+      setTimeout(() => {
+        setShowSuccess(false);
+        onClose();
+      }, 1500);
+    },
+  });
+
+  // Filter filament presets based on search
+  const filteredPresets = useMemo(() => {
+    if (!cloudSettings?.filament) return [];
+
+    const query = searchQuery.toLowerCase();
+    return cloudSettings.filament
+      .filter(p => {
+        if (!query) return true;
+        return p.name.toLowerCase().includes(query);
+      })
+      .sort((a, b) => {
+        // Sort user presets first, then alphabetically
+        const aIsUser = isUserPreset(a.setting_id);
+        const bIsUser = isUserPreset(b.setting_id);
+        if (aIsUser && !bIsUser) return -1;
+        if (!aIsUser && bIsUser) return 1;
+        return a.name.localeCompare(b.name);
+      });
+  }, [cloudSettings?.filament, searchQuery]);
+
+  // Get full preset name for K profile filtering (brand + material, without printer suffix)
+  const selectedPresetInfo = useMemo(() => {
+    if (!selectedPresetId || !cloudSettings?.filament) return null;
+    const selectedPreset = cloudSettings.filament.find(p => p.setting_id === selectedPresetId);
+    if (!selectedPreset) return null;
+
+    // Remove printer/nozzle suffix (e.g., "@BBL X1C" or "@0.4 nozzle")
+    let nameWithoutSuffix = selectedPreset.name.replace(/@.+$/, '').trim();
+    // Strip leading "# " from custom preset names (user convention)
+    if (nameWithoutSuffix.startsWith('# ')) {
+      nameWithoutSuffix = nameWithoutSuffix.slice(2).trim();
+    }
+    const parsed = parsePresetName(nameWithoutSuffix);
+
+    return {
+      fullName: nameWithoutSuffix,
+      material: parsed.material,
+      brand: parsed.brand,
+    };
+  }, [selectedPresetId, cloudSettings?.filament]);
+
+  // For backwards compatibility with the label
+  const selectedMaterial = selectedPresetInfo?.fullName || '';
+
+  const matchingKProfiles = useMemo(() => {
+    if (!kprofilesData?.profiles || !selectedPresetInfo) return [];
+
+    const { fullName, material, brand } = selectedPresetInfo;
+    const upperFullName = fullName.toUpperCase();
+    const upperMaterial = material.toUpperCase();
+    const upperBrand = brand.toUpperCase();
+
+    // Material must be at least 2 chars to avoid false positives
+    if (!upperMaterial || upperMaterial.length < 2) return [];
+
+    // Filter profiles - require brand match if brand is present in selected preset
+    const filtered = kprofilesData.profiles.filter(p => {
+      const profileName = p.name.toUpperCase();
+
+      // If the selected preset has a brand (e.g., "Azurefilm PLA Wood"),
+      // only show profiles that match the brand
+      if (upperBrand) {
+        // Must contain the brand name
+        if (!profileName.includes(upperBrand)) {
+          return false;
+        }
+        // And must contain the material type
+        if (!profileName.includes(upperMaterial)) {
+          return false;
+        }
+        return true;
+      }
+
+      // No brand in selected preset - match on full name or material
+      // Priority 1: Exact match with full name
+      if (profileName.includes(upperFullName)) {
+        return true;
+      }
+
+      // Priority 2: Material type match (only when no brand specified)
+      if (profileName.includes(upperMaterial)) {
+        return true;
+      }
+
+      // Check for common material aliases
+      const aliases: Record<string, string[]> = {
+        'NYLON': ['PA', 'PA-CF', 'PA6'],
+        'PA': ['NYLON'],
+      };
+
+      const materialAliases = aliases[upperMaterial] || [];
+      for (const alias of materialAliases) {
+        if (profileName.includes(alias)) {
+          return true;
+        }
+      }
+
+      return false;
+    });
+
+    // 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
+    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
+        seen.set(key, profile);
+      }
+    }
+    return Array.from(seen.values());
+  }, [kprofilesData?.profiles, selectedPresetInfo]);
+
+  // 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(
+          p => p.setting_id === slotInfo.trayInfoIdx
+        );
+        if (currentPreset) {
+          setSelectedPresetId(currentPreset.setting_id);
+        }
+      }
+    } else if (!isOpen) {
+      // Reset when modal closes
+      setSelectedPresetId('');
+      setSelectedKProfile(null);
+      setColorHex('');
+      setColorInput('');
+      setSearchQuery('');
+      setShowSuccess(false);
+    }
+  }, [isOpen, cloudSettings?.filament, slotInfo.trayInfoIdx]);
+
+  // Auto-select best matching K profile when preset changes
+  useEffect(() => {
+    if (matchingKProfiles.length > 0) {
+      // Auto-select first matching profile
+      setSelectedKProfile(matchingKProfiles[0]);
+    } else {
+      setSelectedKProfile(null);
+    }
+  }, [selectedPresetId, matchingKProfiles.length]); // Only trigger when preset changes
+
+  // Escape key handler
+  const handleKeyDown = useCallback((e: KeyboardEvent) => {
+    if (e.key === 'Escape') {
+      onClose();
+    }
+  }, [onClose]);
+
+  useEffect(() => {
+    if (isOpen) {
+      document.addEventListener('keydown', handleKeyDown);
+      return () => document.removeEventListener('keydown', handleKeyDown);
+    }
+  }, [isOpen, handleKeyDown]);
+
+  if (!isOpen) return null;
+
+  const isLoading = settingsLoading || kprofilesLoading;
+  const canSave = selectedPresetId && !configureMutation.isPending;
+
+  // Get display color (custom or slot default)
+  const displayColor = colorHex || slotInfo.trayColor?.slice(0, 6) || 'FFFFFF';
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center">
+      {/* Backdrop */}
+      <div
+        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
+        onClick={onClose}
+      />
+
+      {/* Modal */}
+      <div className="relative w-full max-w-lg mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
+        {/* Header */}
+        <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>
+          </div>
+          <button
+            onClick={onClose}
+            className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-4 space-y-4 max-h-[60vh] overflow-y-auto">
+          {/* Success overlay */}
+          {showSuccess && (
+            <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-sm text-bambu-gray">Settings sent to printer</p>
+              </div>
+            </div>
+          )}
+
+          {/* 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>
+            <div className="flex items-center gap-2">
+              {slotInfo.trayColor && (
+                <span
+                  className="w-4 h-4 rounded-full border border-white/20"
+                  style={{ backgroundColor: `#${slotInfo.trayColor.slice(0, 6)}` }}
+                />
+              )}
+              <span className="text-white font-medium">
+                {getAmsLabel(slotInfo.amsId, slotInfo.trayCount)} Slot {slotInfo.trayId + 1}
+              </span>
+              {slotInfo.traySubBrands && (
+                <span className="text-bambu-gray">({slotInfo.traySubBrands})</span>
+              )}
+            </div>
+          </div>
+
+          {isLoading ? (
+            <div className="flex justify-center py-8">
+              <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+            </div>
+          ) : (
+            <>
+              {/* Filament Profile Select */}
+              <div>
+                <label className="block text-sm text-bambu-gray mb-2">
+                  Filament Profile <span className="text-red-400">*</span>
+                </label>
+                <div className="relative">
+                  <input
+                    type="text"
+                    placeholder="Search presets..."
+                    value={searchQuery}
+                    onChange={(e) => setSearchQuery(e.target.value)}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none mb-2"
+                  />
+                  <div className="max-h-48 overflow-y-auto space-y-1">
+                    {filteredPresets.length === 0 ? (
+                      <p className="text-center py-4 text-bambu-gray">
+                        {cloudSettings?.filament?.length === 0
+                          ? 'No cloud presets. Login to Bambu Cloud to sync.'
+                          : 'No matching presets found.'}
+                      </p>
+                    ) : (
+                      filteredPresets.map((preset) => (
+                        <button
+                          key={preset.setting_id}
+                          onClick={() => setSelectedPresetId(preset.setting_id)}
+                          className={`w-full p-2 rounded-lg border text-left transition-colors ${
+                            selectedPresetId === preset.setting_id
+                              ? 'bg-bambu-green/20 border-bambu-green'
+                              : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
+                          }`}
+                        >
+                          <div className="flex items-center justify-between">
+                            <span className="text-white text-sm truncate">{preset.name}</span>
+                            {isUserPreset(preset.setting_id) && (
+                              <span className="text-xs px-1.5 py-0.5 rounded bg-bambu-blue/20 text-bambu-blue">
+                                Custom
+                              </span>
+                            )}
+                          </div>
+                        </button>
+                      ))
+                    )}
+                  </div>
+                </div>
+              </div>
+
+              {/* K Profile Select */}
+              <div>
+                <label className="block text-sm text-bambu-gray mb-2">
+                  K Profile (Pressure Advance)
+                  {selectedMaterial && (
+                    <span className="ml-2 text-xs text-bambu-blue">
+                      Filtering for: {selectedMaterial}
+                    </span>
+                  )}
+                </label>
+                {matchingKProfiles.length > 0 ? (
+                  <div className="relative">
+                    <select
+                      value={selectedKProfile?.name || ''}
+                      onChange={(e) => {
+                        const profile = matchingKProfiles.find(p => p.name === e.target.value);
+                        setSelectedKProfile(profile || null);
+                      }}
+                      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>
+                      {matchingKProfiles.map((profile) => (
+                        <option key={`${profile.name}-${profile.extruder_id}`} value={profile.name}>
+                          {profile.name} (K={profile.k_value})
+                        </option>
+                      ))}
+                    </select>
+                    <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                  </div>
+                ) : selectedPresetId ? (
+                  <p className="text-sm text-bambu-gray italic py-2">
+                    No matching K profiles found. Default K=0.020 will be used.
+                  </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
+                  </span>
+                )}
+                {selectedKProfile && (
+                  <p className="text-xs text-bambu-green mt-1">
+                    K={selectedKProfile.k_value} from printer calibration
+                  </p>
+                )}
+              </div>
+
+              {/* Optional: Custom color */}
+              <div>
+                <label className="block text-sm text-bambu-gray mb-2">
+                  Custom Color (optional)
+                </label>
+                {/* Quick color buttons */}
+                <div className="flex flex-wrap gap-1.5 mb-2">
+                  {QUICK_COLORS_BASIC.map((color) => (
+                    <button
+                      key={color.hex}
+                      onClick={() => {
+                        setColorHex(color.hex);
+                        setColorInput(color.name);
+                      }}
+                      className={`w-7 h-7 rounded-md border-2 transition-all ${
+                        colorHex === color.hex
+                          ? 'border-bambu-green scale-110'
+                          : 'border-white/20 hover:border-white/40'
+                      }`}
+                      style={{ backgroundColor: `#${color.hex}` }}
+                      title={color.name}
+                    />
+                  ))}
+                  <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'}
+                  >
+                    {showExtendedColors ? '−' : '+'}
+                  </button>
+                </div>
+                {/* Extended colors (collapsible) */}
+                {showExtendedColors && (
+                  <div className="flex flex-wrap gap-1.5 mb-2">
+                    {QUICK_COLORS_EXTENDED.map((color) => (
+                      <button
+                        key={color.hex}
+                        onClick={() => {
+                          setColorHex(color.hex);
+                          setColorInput(color.name);
+                        }}
+                        className={`w-7 h-7 rounded-md border-2 transition-all ${
+                          colorHex === color.hex
+                            ? 'border-bambu-green scale-110'
+                            : 'border-white/20 hover:border-white/40'
+                        }`}
+                        style={{ backgroundColor: `#${color.hex}` }}
+                        title={color.name}
+                      />
+                    ))}
+                  </div>
+                )}
+                {/* Color input: name or hex */}
+                <div className="flex gap-2 items-center">
+                  <div
+                    className="w-10 h-10 rounded-lg border-2 border-white/20 flex-shrink-0"
+                    style={{ backgroundColor: `#${displayColor}` }}
+                  />
+                  <input
+                    type="text"
+                    placeholder="Color name or hex (e.g., brown, FF8800)"
+                    value={colorInput}
+                    onChange={(e) => {
+                      const input = e.target.value;
+                      setColorInput(input);
+
+                      // Try to parse as color name first
+                      const nameHex = colorNameToHex(input);
+                      if (nameHex) {
+                        setColorHex(nameHex);
+                      } else {
+                        // Try to parse as hex code
+                        const cleaned = input.replace(/[^0-9A-Fa-f]/g, '').toUpperCase();
+                        if (cleaned.length === 6) {
+                          setColorHex(cleaned);
+                        } else if (cleaned.length === 3) {
+                          // Expand shorthand hex (e.g., F00 -> FF0000)
+                          setColorHex(cleaned.split('').map(c => c + c).join(''));
+                        }
+                      }
+                    }}
+                    className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder:text-bambu-gray focus:border-bambu-green focus:outline-none text-sm"
+                  />
+                  {colorHex && (
+                    <button
+                      onClick={() => {
+                        setColorHex('');
+                        setColorInput('');
+                      }}
+                      className="px-2 py-1 text-xs text-bambu-gray hover:text-white bg-bambu-dark-tertiary rounded"
+                      title="Clear custom color"
+                    >
+                      Clear
+                    </button>
+                  )}
+                </div>
+                {colorHex && (
+                  <p className="text-xs text-bambu-gray mt-1.5">
+                    Hex: #{colorHex}
+                  </p>
+                )}
+              </div>
+            </>
+          )}
+        </div>
+
+        {/* Footer */}
+        <div className="flex justify-between p-4 border-t border-bambu-dark-tertiary">
+          {/* Reset button on the left */}
+          <Button
+            variant="secondary"
+            onClick={() => resetMutation.mutate()}
+            disabled={resetMutation.isPending || configureMutation.isPending}
+            className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
+          >
+            {resetMutation.isPending ? (
+              <>
+                <Loader2 className="w-4 h-4 animate-spin" />
+                Resetting...
+              </>
+            ) : (
+              <>
+                <RotateCcw className="w-4 h-4" />
+                Reset Slot
+              </>
+            )}
+          </Button>
+          {/* Cancel and Configure buttons on the right */}
+          <div className="flex gap-2">
+            <Button variant="secondary" onClick={onClose}>
+              Cancel
+            </Button>
+            <Button
+              onClick={() => configureMutation.mutate()}
+              disabled={!canSave}
+            >
+              {configureMutation.isPending ? (
+                <>
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  Configuring...
+                </>
+              ) : (
+                <>
+                  <Settings2 className="w-4 h-4" />
+                  Configure Slot
+                </>
+              )}
+            </Button>
+          </div>
+        </div>
+
+        {/* Error */}
+        {(configureMutation.isError || resetMutation.isError) && (
+          <div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
+            {(configureMutation.error as Error)?.message || (resetMutation.error as Error)?.message}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 4 - 2
frontend/src/components/EditQueueItemModal.tsx

@@ -462,10 +462,12 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
                       )}
                       )}
                       <div className="min-w-0 flex-1">
                       <div className="min-w-0 flex-1">
                         <p className="text-sm text-white font-medium truncate">
                         <p className="text-sm text-white font-medium truncate">
-                          Plate {plate.index}
+                          {plate.name || `Plate ${plate.index}`}
                         </p>
                         </p>
                         <p className="text-xs text-bambu-gray truncate">
                         <p className="text-xs text-bambu-gray truncate">
-                          {plate.name || `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
+                          {plate.objects?.length > 0
+                            ? plate.objects.slice(0, 3).join(', ') + (plate.objects.length > 3 ? '...' : '')
+                            : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
                         </p>
                         </p>
                       </div>
                       </div>
                       {selectedPlate === plate.index && (
                       {selectedPlate === plate.index && (

+ 54 - 7
frontend/src/components/FilamentHoverCard.tsx

@@ -1,5 +1,5 @@
 import { useState, useRef, useEffect, type ReactNode } from 'react';
 import { useState, useRef, useEffect, type ReactNode } from 'react';
-import { Droplets, Link2, Copy, Check } from 'lucide-react';
+import { Droplets, Link2, Copy, Check, Settings2 } from 'lucide-react';
 
 
 interface FilamentData {
 interface FilamentData {
   vendor: 'Bambu Lab' | 'Generic';
   vendor: 'Bambu Lab' | 'Generic';
@@ -17,19 +17,25 @@ interface SpoolmanConfig {
   hasUnlinkedSpools?: boolean; // Whether there are spools available to link
   hasUnlinkedSpools?: boolean; // Whether there are spools available to link
 }
 }
 
 
+interface ConfigureSlotConfig {
+  enabled: boolean;
+  onConfigure?: () => void;
+}
+
 interface FilamentHoverCardProps {
 interface FilamentHoverCardProps {
   data: FilamentData;
   data: FilamentData;
   children: ReactNode;
   children: ReactNode;
   disabled?: boolean;
   disabled?: boolean;
   className?: string;
   className?: string;
   spoolman?: SpoolmanConfig;
   spoolman?: SpoolmanConfig;
+  configureSlot?: ConfigureSlotConfig;
 }
 }
 
 
 /**
 /**
  * A hover card that displays filament details when hovering over AMS slots.
  * A hover card that displays filament details when hovering over AMS slots.
  * Replaces the basic browser tooltip with a styled popover.
  * Replaces the basic browser tooltip with a styled popover.
  */
  */
-export function FilamentHoverCard({ data, children, disabled, className = '', spoolman }: FilamentHoverCardProps) {
+export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) {
   const [isVisible, setIsVisible] = useState(false);
   const [isVisible, setIsVisible] = useState(false);
   const [position, setPosition] = useState<'top' | 'bottom'>('top');
   const [position, setPosition] = useState<'top' | 'bottom'>('top');
   const [copied, setCopied] = useState(false);
   const [copied, setCopied] = useState(false);
@@ -287,6 +293,23 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                   )}
                   )}
                 </div>
                 </div>
               )}
               )}
+
+              {/* Configure slot section - always show if enabled */}
+              {configureSlot?.enabled && (
+                <div className={`${spoolman?.enabled && data.trayUuid ? '' : 'pt-2 mt-2 border-t border-bambu-dark-tertiary'}`}>
+                  <button
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      configureSlot.onConfigure?.();
+                    }}
+                    className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
+                    title="Configure slot with filament profile and K value"
+                  >
+                    <Settings2 className="w-3.5 h-3.5" />
+                    Configure
+                  </button>
+                </div>
+              )}
             </div>
             </div>
           </div>
           </div>
 
 
@@ -307,10 +330,16 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
   );
   );
 }
 }
 
 
+interface EmptySlotHoverCardProps {
+  children: ReactNode;
+  className?: string;
+  configureSlot?: ConfigureSlotConfig;
+}
+
 /**
 /**
- * Wrapper for empty slots - just shows "Empty" on hover
+ * Wrapper for empty slots - shows "Empty" on hover with optional configure button
  */
  */
-export function EmptySlotHoverCard({ children, className = '' }: { children: ReactNode; className?: string }) {
+export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) {
   const [isVisible, setIsVisible] = useState(false);
   const [isVisible, setIsVisible] = useState(false);
   const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
   const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
 
@@ -344,10 +373,28 @@ export function EmptySlotHoverCard({ children, className = '' }: { children: Rea
           animate-in fade-in-0 zoom-in-95 duration-150
           animate-in fade-in-0 zoom-in-95 duration-150
         ">
         ">
           <div className="
           <div className="
-            px-3 py-1.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary
-            rounded-md shadow-lg text-xs text-bambu-gray whitespace-nowrap
+            bg-bambu-dark-secondary border border-bambu-dark-tertiary
+            rounded-md shadow-lg overflow-hidden
           ">
           ">
-            Empty slot
+            <div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
+              Empty slot
+            </div>
+            {/* Configure slot button */}
+            {configureSlot?.enabled && (
+              <div className="px-2 pb-2">
+                <button
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    configureSlot.onConfigure?.();
+                  }}
+                  className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
+                  title="Configure slot with filament profile and K value"
+                >
+                  <Settings2 className="w-3.5 h-3.5" />
+                  Configure
+                </button>
+              </div>
+            )}
           </div>
           </div>
           <div className="
           <div className="
             absolute left-1/2 -translate-x-1/2 top-full w-0 h-0
             absolute left-1/2 -translate-x-1/2 top-full w-0 h-0

+ 4 - 2
frontend/src/components/ReprintModal.tsx

@@ -436,10 +436,12 @@ export function ReprintModal({ archiveId, archiveName, onClose, onSuccess }: Rep
                     )}
                     )}
                     <div className="min-w-0 flex-1">
                     <div className="min-w-0 flex-1">
                       <p className="text-sm text-white font-medium truncate">
                       <p className="text-sm text-white font-medium truncate">
-                        Plate {plate.index}
+                        {plate.name || `Plate ${plate.index}`}
                       </p>
                       </p>
                       <p className="text-xs text-bambu-gray truncate">
                       <p className="text-xs text-bambu-gray truncate">
-                        {plate.name || `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
+                        {plate.objects?.length > 0
+                          ? plate.objects.slice(0, 3).join(', ') + (plate.objects.length > 3 ? '...' : '')
+                          : `${plate.filaments.length} filament${plate.filaments.length !== 1 ? 's' : ''}`}
                         {plate.print_time_seconds ? ` • ${formatTime(plate.print_time_seconds)}` : ''}
                         {plate.print_time_seconds ? ` • ${formatTime(plate.print_time_seconds)}` : ''}
                       </p>
                       </p>
                     </div>
                     </div>

+ 18 - 7
frontend/src/pages/ArchivesPage.tsx

@@ -80,6 +80,17 @@ function formatDuration(seconds: number): string {
   return `${minutes}m`;
   return `${minutes}m`;
 }
 }
 
 
+/**
+ * Check if an archive filename represents a sliced/printable file.
+ * Matches: .gcode, .gcode.3mf, .gcode.anything
+ */
+function isSlicedFile(filename: string | null | undefined): boolean {
+  if (!filename) return false;
+  const lower = filename.toLowerCase();
+  // Match .gcode at end OR .gcode. followed by anything (like .gcode.3mf)
+  return lower.endsWith('.gcode') || lower.includes('.gcode.');
+}
+
 // formatDate imported from '../utils/date' - handles UTC conversion
 // formatDate imported from '../utils/date' - handles UTC conversion
 
 
 function ArchiveCard({
 function ArchiveCard({
@@ -246,7 +257,7 @@ function ArchiveCard({
     setContextMenu({ x: e.clientX, y: e.clientY });
     setContextMenu({ x: e.clientX, y: e.clientY });
   };
   };
 
 
-  const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.');
+  const isGcodeFile = isSlicedFile(archive.filename);
 
 
   const contextMenuItems: ContextMenuItem[] = [
   const contextMenuItems: ContextMenuItem[] = [
     // For gcode files: show Print option
     // For gcode files: show Print option
@@ -632,17 +643,17 @@ function ArchiveCard({
           {/* File type badge */}
           {/* File type badge */}
           <span
           <span
             className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
             className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
-              archive.filename?.toLowerCase().includes('.gcode.')
+              isSlicedFile(archive.filename)
                 ? 'bg-bambu-green/20 text-bambu-green'
                 ? 'bg-bambu-green/20 text-bambu-green'
                 : 'bg-orange-500/20 text-orange-400'
                 : 'bg-orange-500/20 text-orange-400'
             }`}
             }`}
             title={
             title={
-              archive.filename?.toLowerCase().includes('.gcode.')
+              isSlicedFile(archive.filename)
                 ? 'Sliced file - ready to print'
                 ? 'Sliced file - ready to print'
                 : 'Source file only - no AMS mapping available'
                 : 'Source file only - no AMS mapping available'
             }
             }
           >
           >
-            {archive.filename?.toLowerCase().includes('.gcode.') ? 'GCODE' : 'SOURCE'}
+            {isSlicedFile(archive.filename) ? 'GCODE' : 'SOURCE'}
           </span>
           </span>
           {archive.project_name && (
           {archive.project_name && (
             <span
             <span
@@ -755,7 +766,7 @@ function ArchiveCard({
 
 
         {/* Actions */}
         {/* Actions */}
         <div className="flex gap-1 mt-3">
         <div className="flex gap-1 mt-3">
-          {archive.filename?.toLowerCase().includes('.gcode.') ? (
+          {isSlicedFile(archive.filename) ? (
             // Sliced file - can print directly
             // Sliced file - can print directly
             <>
             <>
               <Button
               <Button
@@ -1239,7 +1250,7 @@ function ArchiveListRow({
     setContextMenu({ x: e.clientX, y: e.clientY });
     setContextMenu({ x: e.clientX, y: e.clientY });
   };
   };
 
 
-  const isGcodeFile = archive.filename?.toLowerCase().includes('.gcode.');
+  const isGcodeFile = isSlicedFile(archive.filename);
 
 
   const contextMenuItems: ContextMenuItem[] = [
   const contextMenuItems: ContextMenuItem[] = [
     ...(isGcodeFile ? [
     ...(isGcodeFile ? [
@@ -2067,7 +2078,7 @@ export function ArchivesPage() {
       const matchesTag = !filterTag || archiveTags.includes(filterTag);
       const matchesTag = !filterTag || archiveTags.includes(filterTag);
 
 
       // File type filter (gcode = sliced, source = project file only)
       // File type filter (gcode = sliced, source = project file only)
-      const isGcodeFile = a.filename?.toLowerCase().includes('.gcode.');
+      const isGcodeFile = isSlicedFile(a.filename);
       const matchesFileType = filterFileType === 'all' ||
       const matchesFileType = filterFileType === 'all' ||
         (filterFileType === 'gcode' && isGcodeFile) ||
         (filterFileType === 'gcode' && isGcodeFile) ||
         (filterFileType === 'source' && !isGcodeFile);
         (filterFileType === 'source' && !isGcodeFile);

+ 1 - 0
frontend/src/pages/FileManagerPage.tsx

@@ -1006,6 +1006,7 @@ export function FileManagerPage() {
     onSuccess: (result) => {
     onSuccess: (result) => {
       queryClient.invalidateQueries({ queryKey: ['library-files'] });
       queryClient.invalidateQueries({ queryKey: ['library-files'] });
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       queryClient.invalidateQueries({ queryKey: ['queue'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] }); // Archives are created when adding to queue
       setSelectedFiles([]);
       setSelectedFiles([]);
 
 
       if (result.added.length > 0 && result.errors.length === 0) {
       if (result.added.length > 0 && result.errors.length === 0) {

+ 102 - 5
frontend/src/pages/PrintersPage.tsx

@@ -64,6 +64,7 @@ import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
 import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
 import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
 import { LinkSpoolModal } from '../components/LinkSpoolModal';
 import { LinkSpoolModal } from '../components/LinkSpoolModal';
+import { ConfigureAmsSlotModal } from '../components/ConfigureAmsSlotModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { ChamberLight } from '../components/icons/ChamberLight';
 import { ChamberLight } from '../components/icons/ChamberLight';
 
 
@@ -378,6 +379,10 @@ function hexToBasicColorName(hex: string | null | undefined): string {
   }
   }
 
 
   // Classify by hue
   // Classify by hue
+  // Brown is orange/yellow hue with lower lightness
+  if (h >= 15 && h < 45 && l < 0.45) return 'Brown';
+  if (h >= 45 && h < 70 && l < 0.40) return 'Brown';
+
   if (h < 15 || h >= 345) return 'Red';
   if (h < 15 || h >= 345) return 'Red';
   if (h < 45) return 'Orange';
   if (h < 45) return 'Orange';
   if (h < 70) return 'Yellow';
   if (h < 70) return 'Yellow';
@@ -945,6 +950,15 @@ function PrinterCard({
     trayUuid: string;
     trayUuid: string;
     trayInfo: { type: string; color: string; location: string };
     trayInfo: { type: string; color: string; location: string };
   } | null>(null);
   } | null>(null);
+  const [configureSlotModal, setConfigureSlotModal] = useState<{
+    amsId: number;
+    trayId: number;
+    trayCount: number;
+    trayType?: string;
+    trayColor?: string;
+    traySubBrands?: string;
+    trayInfoIdx?: string;
+  } | null>(null);
   const [showFirmwareModal, setShowFirmwareModal] = useState(false);
   const [showFirmwareModal, setShowFirmwareModal] = useState(false);
 
 
   const { data: status } = useQuery({
   const { data: status } = useQuery({
@@ -987,6 +1001,13 @@ function PrinterCard({
     staleTime: 5 * 60 * 1000, // 5 minutes
     staleTime: 5 * 60 * 1000, // 5 minutes
   });
   });
 
 
+  // Fetch slot preset mappings (stores preset name for user-configured slots)
+  const { data: slotPresets } = useQuery({
+    queryKey: ['slotPresets', printer.id],
+    queryFn: () => api.getSlotPresets(printer.id),
+    staleTime: 2 * 60 * 1000, // 2 minutes
+  });
+
   // Cache WiFi signal to prevent it disappearing on updates
   // Cache WiFi signal to prevent it disappearing on updates
   const [cachedWifiSignal, setCachedWifiSignal] = useState<number | null>(null);
   const [cachedWifiSignal, setCachedWifiSignal] = useState<number | null>(null);
   useEffect(() => {
   useEffect(() => {
@@ -1956,11 +1977,13 @@ function PrinterCard({
                                 const isActive = effectiveTrayNow === globalTrayId;
                                 const isActive = effectiveTrayNow === globalTrayId;
                                 // Get cloud preset info if available
                                 // Get cloud preset info if available
                                 const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                                 const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
+                                // Get saved slot preset mapping (for user-configured slots)
+                                const slotPreset = slotPresets?.[globalTrayId];
 
 
                                 // Build filament data for hover card
                                 // Build filament data for hover card
                                 const filamentData = tray?.tray_type ? {
                                 const filamentData = tray?.tray_type ? {
                                   vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                                   vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                                  profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
+                                  profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
                                   colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                                   colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                                   colorHex: tray.tray_color || null,
                                   colorHex: tray.tray_color || null,
                                   kFactor: formatKValue(tray.k),
                                   kFactor: formatKValue(tray.k),
@@ -2066,11 +2089,32 @@ function PrinterCard({
                                             });
                                             });
                                           } : undefined,
                                           } : undefined,
                                         }}
                                         }}
+                                        configureSlot={{
+                                          enabled: true,
+                                          onConfigure: () => setConfigureSlotModal({
+                                            amsId: ams.id,
+                                            trayId: slotIdx,
+                                            trayCount: ams.tray.length,
+                                            trayType: tray?.tray_type || undefined,
+                                            trayColor: tray?.tray_color || undefined,
+                                            traySubBrands: tray?.tray_sub_brands || undefined,
+                                            trayInfoIdx: tray?.tray_info_idx || undefined,
+                                          }),
+                                        }}
                                       >
                                       >
                                         {slotVisual}
                                         {slotVisual}
                                       </FilamentHoverCard>
                                       </FilamentHoverCard>
                                     ) : (
                                     ) : (
-                                      <EmptySlotHoverCard>
+                                      <EmptySlotHoverCard
+                                        configureSlot={{
+                                          enabled: true,
+                                          onConfigure: () => setConfigureSlotModal({
+                                            amsId: ams.id,
+                                            trayId: slotIdx,
+                                            trayCount: ams.tray.length,
+                                          }),
+                                        }}
+                                      >
                                         {slotVisual}
                                         {slotVisual}
                                       </EmptySlotHoverCard>
                                       </EmptySlotHoverCard>
                                     )}
                                     )}
@@ -2103,11 +2147,13 @@ function PrinterCard({
                         const isActive = effectiveTrayNow === globalTrayId;
                         const isActive = effectiveTrayNow === globalTrayId;
                         // Get cloud preset info if available
                         // Get cloud preset info if available
                         const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
                         const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
+                        // Get saved slot preset mapping (for user-configured slots)
+                        const slotPreset = slotPresets?.[globalTrayId];
 
 
                         // Build filament data for hover card
                         // Build filament data for hover card
                         const filamentData = tray?.tray_type ? {
                         const filamentData = tray?.tray_type ? {
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           vendor: (isBambuLabSpool(tray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                          profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
+                          profile: cloudInfo?.name || slotPreset?.preset_name || tray.tray_sub_brands || tray.tray_type,
                           colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                           colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
                           colorHex: tray.tray_color || null,
                           colorHex: tray.tray_color || null,
                           kFactor: formatKValue(tray.k),
                           kFactor: formatKValue(tray.k),
@@ -2226,11 +2272,32 @@ function PrinterCard({
                                         });
                                         });
                                       } : undefined,
                                       } : undefined,
                                     }}
                                     }}
+                                    configureSlot={{
+                                      enabled: true,
+                                      onConfigure: () => setConfigureSlotModal({
+                                        amsId: ams.id,
+                                        trayId: htSlotId,
+                                        trayCount: ams.tray.length,
+                                        trayType: tray?.tray_type || undefined,
+                                        trayColor: tray?.tray_color || undefined,
+                                        traySubBrands: tray?.tray_sub_brands || undefined,
+                                        trayInfoIdx: tray?.tray_info_idx || undefined,
+                                      }),
+                                    }}
                                   >
                                   >
                                     {slotVisual}
                                     {slotVisual}
                                   </FilamentHoverCard>
                                   </FilamentHoverCard>
                                 ) : (
                                 ) : (
-                                  <EmptySlotHoverCard>
+                                  <EmptySlotHoverCard
+                                    configureSlot={{
+                                      enabled: true,
+                                      onConfigure: () => setConfigureSlotModal({
+                                        amsId: ams.id,
+                                        trayId: htSlotId,
+                                        trayCount: ams.tray.length,
+                                      }),
+                                    }}
+                                  >
                                     {slotVisual}
                                     {slotVisual}
                                   </EmptySlotHoverCard>
                                   </EmptySlotHoverCard>
                                 )}
                                 )}
@@ -2277,11 +2344,13 @@ function PrinterCard({
                         const isExtActive = effectiveTrayNow === 254;
                         const isExtActive = effectiveTrayNow === 254;
                         // Get cloud preset info if available
                         // Get cloud preset info if available
                         const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
                         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];
 
 
                         // Build filament data for hover card
                         // Build filament data for hover card
                         const extFilamentData = {
                         const extFilamentData = {
                           vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
                           vendor: (isBambuLabSpool(extTray) ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
-                          profile: extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
+                          profile: extCloudInfo?.name || extSlotPreset?.preset_name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
                           colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
                           colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
                           colorHex: extTray.tray_color || null,
                           colorHex: extTray.tray_color || null,
                           kFactor: formatKValue(extTray.k),
                           kFactor: formatKValue(extTray.k),
@@ -2331,6 +2400,18 @@ function PrinterCard({
                                   });
                                   });
                                 } : undefined,
                                 } : undefined,
                               }}
                               }}
+                              configureSlot={{
+                                enabled: true,
+                                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}
                               {extSlotContent}
                             </FilamentHoverCard>
                             </FilamentHoverCard>
@@ -2830,6 +2911,22 @@ function PrinterCard({
         />
         />
       )}
       )}
 
 
+      {/* Configure AMS Slot Modal */}
+      {configureSlotModal && (
+        <ConfigureAmsSlotModal
+          isOpen={!!configureSlotModal}
+          onClose={() => setConfigureSlotModal(null)}
+          printerId={printer.id}
+          slotInfo={configureSlotModal}
+          onSuccess={() => {
+            // Refresh slot presets to show updated profile name
+            queryClient.invalidateQueries({ queryKey: ['slotPresets', printer.id] });
+            // Printer status will update automatically via WebSocket when AMS data changes
+            queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
+          }}
+        />
+      )}
+
       {/* Edit Printer Modal */}
       {/* Edit Printer Modal */}
       {showEditModal && (
       {showEditModal && (
         <EditPrinterModal
         <EditPrinterModal

+ 6 - 2
frontend/vite.config.ts

@@ -2,6 +2,10 @@ import { defineConfig } from 'vite'
 import react from '@vitejs/plugin-react'
 import react from '@vitejs/plugin-react'
 import path from 'path'
 import path from 'path'
 
 
+// Backend port for dev server proxy (default: 8000)
+const backendPort = process.env.BACKEND_PORT || '8000'
+const backendUrl = `http://localhost:${backendPort}`
+
 export default defineConfig({
 export default defineConfig({
   plugins: [react()],
   plugins: [react()],
   build: {
   build: {
@@ -12,12 +16,12 @@ export default defineConfig({
   server: {
   server: {
     proxy: {
     proxy: {
       '/api/v1/ws': {
       '/api/v1/ws': {
-        target: 'http://localhost:8000',
+        target: backendUrl,
         ws: true,
         ws: true,
         changeOrigin: true,
         changeOrigin: true,
       },
       },
       '/api': {
       '/api': {
-        target: 'http://localhost:8000',
+        target: backendUrl,
         changeOrigin: true,
         changeOrigin: true,
       },
       },
     },
     },

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


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


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


+ 2 - 2
static/index.html

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

+ 6 - 3
test_docker.sh

@@ -6,6 +6,9 @@
 
 
 set -e
 set -e
 
 
+# Configuration
+PORT=${PORT:-8000}
+
 # Colors for output
 # Colors for output
 RED='\033[0;31m'
 RED='\033[0;31m'
 GREEN='\033[0;32m'
 GREEN='\033[0;32m'
@@ -214,7 +217,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
         print_info "Running integration tests..."
         print_info "Running integration tests..."
 
 
         # Test health endpoint
         # Test health endpoint
-        HEALTH_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/health)
+        HEALTH_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:${PORT}/health)
         if echo "$HEALTH_RESPONSE" | grep -q "healthy"; then
         if echo "$HEALTH_RESPONSE" | grep -q "healthy"; then
             print_success "Health endpoint responds correctly"
             print_success "Health endpoint responds correctly"
         else
         else
@@ -222,7 +225,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
         fi
         fi
 
 
         # Test API endpoints
         # Test API endpoints
-        API_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:8000/api/v1/settings)
+        API_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s http://localhost:${PORT}/api/v1/settings)
         if echo "$API_RESPONSE" | grep -q "settings"; then
         if echo "$API_RESPONSE" | grep -q "settings"; then
             print_success "Settings API endpoint responds"
             print_success "Settings API endpoint responds"
         else
         else
@@ -231,7 +234,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
         fi
         fi
 
 
         # Test static files
         # Test static files
-        STATIC_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/)
+        STATIC_RESPONSE=$(sudo docker compose -f docker-compose.test.yml exec -T integration curl -s -o /dev/null -w "%{http_code}" http://localhost:${PORT}/)
         if [ "$STATIC_RESPONSE" = "200" ]; then
         if [ "$STATIC_RESPONSE" = "200" ]; then
             print_success "Static files served correctly"
             print_success "Static files served correctly"
         else
         else

+ 3 - 2
tests/e2e_comprehensive_test.py

@@ -1,11 +1,12 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
 """Comprehensive end-to-end test for Bambuddy application."""
 """Comprehensive end-to-end test for Bambuddy application."""
 
 
+import os
 import time
 import time
 
 
 from playwright.sync_api import expect, sync_playwright
 from playwright.sync_api import expect, sync_playwright
 
 
-BASE_URL = "http://localhost:8000"
+BASE_URL = os.environ.get("BAMBUDDY_URL", "http://localhost:8000")
 
 
 
 
 def test_navigation_and_sidebar(page):
 def test_navigation_and_sidebar(page):
@@ -365,7 +366,7 @@ def run_comprehensive_test():
             except Exception as e:
             except Exception as e:
                 print(f"\n❌ {test_name} FAILED: {e}")
                 print(f"\n❌ {test_name} FAILED: {e}")
                 results[test_name] = False
                 results[test_name] = False
-                page.screenshot(path=f'/tmp/bambuddy_error_{test_name.lower().replace(" ", "_")}.png')
+                page.screenshot(path=f"/tmp/bambuddy_error_{test_name.lower().replace(' ', '_')}.png")
 
 
         browser.close()
         browser.close()
 
 

+ 8 - 7
tests/e2e_toggle_persistence_test.py

@@ -5,11 +5,12 @@ These tests verify that toggle settings (auto_off, notification events, etc.)
 are properly persisted to the database and survive page reloads.
 are properly persisted to the database and survive page reloads.
 """
 """
 
 
+import os
 import time
 import time
 
 
 from playwright.sync_api import expect, sync_playwright
 from playwright.sync_api import expect, sync_playwright
 
 
-BASE_URL = "http://localhost:8000"
+BASE_URL = os.environ.get("BAMBUDDY_URL", "http://localhost:8000")
 
 
 
 
 def test_smart_plug_auto_off_toggle_persistence(page):
 def test_smart_plug_auto_off_toggle_persistence(page):
@@ -88,9 +89,9 @@ def test_smart_plug_auto_off_toggle_persistence(page):
         toggle = page.locator('button[role="switch"]').nth(1)
         toggle = page.locator('button[role="switch"]').nth(1)
 
 
     persisted_state = toggle.get_attribute("aria-checked")
     persisted_state = toggle.get_attribute("aria-checked")
-    assert (
-        persisted_state == new_state
-    ), f"State should persist after reload. Expected {new_state}, got {persisted_state}"
+    assert persisted_state == new_state, (
+        f"State should persist after reload. Expected {new_state}, got {persisted_state}"
+    )
     print(f"✓ Toggle state persisted after reload: {persisted_state}")
     print(f"✓ Toggle state persisted after reload: {persisted_state}")
 
 
     # Restore original state
     # Restore original state
@@ -172,9 +173,9 @@ def test_notification_event_toggle_persistence(page):
 
 
     if toggle.is_visible():
     if toggle.is_visible():
         persisted_state = toggle.get_attribute("aria-checked")
         persisted_state = toggle.get_attribute("aria-checked")
-        assert (
-            persisted_state == new_state
-        ), f"State should persist after reload. Expected {new_state}, got {persisted_state}"
+        assert persisted_state == new_state, (
+            f"State should persist after reload. Expected {new_state}, got {persisted_state}"
+        )
         print(f"✓ Toggle state persisted after reload: {persisted_state}")
         print(f"✓ Toggle state persisted after reload: {persisted_state}")
 
 
         # Restore original state
         # Restore original state

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