Browse Source

- Refactored printer card's AMS section for better experience

maziggy 4 months ago
parent
commit
ccdb084f23

+ 102 - 27
backend/app/api/routes/cloud.py

@@ -5,33 +5,36 @@ Handles authentication and profile management with Bambu Cloud.
 """
 
 import json
+import logging
 from pathlib import Path
 from typing import Literal
 
-from fastapi import APIRouter, HTTPException, Depends
-from sqlalchemy.ext.asyncio import AsyncSession
+from fastapi import APIRouter, Body, Depends, HTTPException
 from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.database import get_db
 from backend.app.models.settings import Settings
-from backend.app.services.bambu_cloud import (
-    get_cloud_service,
-    BambuCloudError,
-    BambuCloudAuthError,
-)
 from backend.app.schemas.cloud import (
+    CloudAuthStatus,
+    CloudDevice,
     CloudLoginRequest,
-    CloudVerifyRequest,
     CloudLoginResponse,
-    CloudAuthStatus,
     CloudTokenRequest,
-    SlicerSettingsResponse,
+    CloudVerifyRequest,
     SlicerSetting,
-    CloudDevice,
     SlicerSettingCreate,
-    SlicerSettingUpdate,
     SlicerSettingDeleteResponse,
+    SlicerSettingsResponse,
+    SlicerSettingUpdate,
 )
+from backend.app.services.bambu_cloud import (
+    BambuCloudAuthError,
+    BambuCloudError,
+    get_cloud_service,
+)
+
+logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/cloud", tags=["cloud"])
 
@@ -43,9 +46,7 @@ CLOUD_EMAIL_KEY = "bambu_cloud_email"
 
 async def get_stored_token(db: AsyncSession) -> tuple[str | None, str | None]:
     """Get stored cloud token and email from database."""
-    result = await db.execute(
-        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY]))
-    )
+    result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
     settings = {s.key: s.value for s in result.scalars().all()}
     return settings.get(CLOUD_TOKEN_KEY), settings.get(CLOUD_EMAIL_KEY)
 
@@ -64,9 +65,7 @@ async def store_token(db: AsyncSession, token: str, email: str) -> None:
 
 async def clear_token(db: AsyncSession) -> None:
     """Clear stored cloud token and email."""
-    result = await db.execute(
-        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY]))
-    )
+    result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
     for setting in result.scalars().all():
         await db.delete(setting)
     await db.commit()
@@ -213,14 +212,16 @@ async def get_slicer_settings(
 
             parsed = []
             for s in all_settings:
-                parsed.append(SlicerSetting(
-                    setting_id=s.get("setting_id", s.get("id", "")),
-                    name=s.get("name", "Unknown"),
-                    type=our_type,
-                    version=s.get("version"),
-                    user_id=s.get("user_id"),
-                    updated_time=s.get("updated_time"),
-                ))
+                parsed.append(
+                    SlicerSetting(
+                        setting_id=s.get("setting_id", s.get("id", "")),
+                        name=s.get("name", "Unknown"),
+                        type=our_type,
+                        version=s.get("version"),
+                        user_id=s.get("user_id"),
+                        updated_time=s.get("updated_time"),
+                    )
+                )
             setattr(result, our_type, parsed)
 
         return result
@@ -258,6 +259,80 @@ async def get_setting_detail(setting_id: str, db: AsyncSession = Depends(get_db)
         raise HTTPException(status_code=500, detail=str(e))
 
 
+# Cache for filament preset info (setting_id -> {name, k})
+_filament_cache: dict[str, dict] = {}
+_filament_cache_time: float = 0
+FILAMENT_CACHE_TTL = 300  # 5 minutes
+
+
+@router.post("/filament-info")
+async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession = Depends(get_db)):
+    """
+    Get filament preset info (name and K value) for multiple setting IDs.
+
+    Used to enrich AMS tray tooltips with cloud preset data.
+    """
+    import time
+
+    logger.info(f"get_filament_info called with {len(setting_ids)} IDs: {setting_ids}")
+
+    global _filament_cache, _filament_cache_time
+
+    # Clear stale cache
+    if time.time() - _filament_cache_time > FILAMENT_CACHE_TTL:
+        _filament_cache = {}
+        _filament_cache_time = time.time()
+
+    token, _ = await get_stored_token(db)
+    if not token:
+        logger.info("get_filament_info: Not authenticated, returning empty")
+        # Return empty results if not authenticated (graceful degradation)
+        return {}
+
+    cloud = get_cloud_service()
+    cloud.set_token(token)
+
+    if not cloud.is_authenticated:
+        return {}
+
+    result = {}
+    for setting_id in setting_ids:
+        if not setting_id:
+            continue
+
+        # Check cache first
+        if setting_id in _filament_cache:
+            result[setting_id] = _filament_cache[setting_id]
+            continue
+
+        try:
+            data = await cloud.get_setting_detail(setting_id)
+            setting = data.get("setting", {})
+
+            # Extract name (e.g., "Bambu PLA Basic Jade White")
+            name = data.get("name", "")
+
+            # Extract K value (pressure_advance)
+            k_value = setting.get("pressure_advance")
+            if k_value is not None:
+                try:
+                    k_value = float(k_value)
+                except (ValueError, TypeError):
+                    k_value = None
+
+            info = {"name": name, "k": k_value}
+            _filament_cache[setting_id] = info
+            result[setting_id] = info
+
+        except Exception as e:
+            logger.warning(f"Failed to get cloud preset {setting_id}: {e}")
+            # Cache the failure to avoid repeated requests
+            _filament_cache[setting_id] = {"name": "", "k": None}
+            result[setting_id] = {"name": "", "k": None}
+
+    return result
+
+
 @router.get("/devices", response_model=list[CloudDevice])
 async def get_devices(db: AsyncSession = Depends(get_db)):
     """
@@ -425,7 +500,7 @@ def _load_fields(preset_type: str) -> dict:
     if not file_path.exists():
         raise HTTPException(status_code=404, detail=f"Field definitions not found for: {preset_type}")
 
-    with open(file_path, "r") as f:
+    with open(file_path) as f:
         data = json.load(f)
 
     _fields_cache[preset_type] = data

+ 31 - 3
backend/app/api/routes/printers.py

@@ -169,6 +169,16 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
     ams_exists = False
     raw_data = state.raw_data or {}
 
+    # Build K-profile lookup map: cali_idx -> k_value
+    # This allows looking up the calibrated K value for each AMS slot
+    kprofile_map: dict[int, float] = {}
+    for kp in state.kprofiles or []:
+        if kp.slot_id is not None and kp.k_value:
+            try:
+                kprofile_map[kp.slot_id] = float(kp.k_value)
+            except (ValueError, TypeError):
+                pass
+
     if "ams" in raw_data and isinstance(raw_data["ams"], list):
         ams_exists = True
         for ams_data in raw_data["ams"]:
@@ -184,6 +194,13 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
                 tray_uuid = tray_data.get("tray_uuid", "")
                 if tray_uuid in ("", "00000000000000000000000000000000"):
                     tray_uuid = None
+
+                # Get K value: first try tray's k field, then lookup from K-profiles
+                k_value = tray_data.get("k")
+                cali_idx = tray_data.get("cali_idx")
+                if k_value is None and cali_idx is not None and cali_idx in kprofile_map:
+                    k_value = kprofile_map[cali_idx]
+
                 trays.append(
                     AMSTray(
                         id=tray_data.get("id", 0),
@@ -193,7 +210,8 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
                         tray_id_name=tray_data.get("tray_id_name"),
                         tray_info_idx=tray_data.get("tray_info_idx"),
                         remain=tray_data.get("remain", 0),
-                        k=tray_data.get("k"),
+                        k=k_value,
+                        cali_idx=cali_idx,
                         tag_uid=tag_uid,
                         tray_uuid=tray_uuid,
                         nozzle_temp_min=tray_data.get("nozzle_temp_min"),
@@ -239,13 +257,23 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
         vt_tray_uuid = vt_data.get("tray_uuid", "")
         if vt_tray_uuid in ("", "00000000000000000000000000000000"):
             vt_tray_uuid = None
+
+        # Get K value: first try tray's k field, then lookup from K-profiles
+        vt_k_value = vt_data.get("k")
+        vt_cali_idx = vt_data.get("cali_idx")
+        if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
+            vt_k_value = kprofile_map[vt_cali_idx]
+
         vt_tray = AMSTray(
             id=254,  # Virtual tray ID
             tray_color=vt_data.get("tray_color"),
             tray_type=vt_data.get("tray_type"),
             tray_sub_brands=vt_data.get("tray_sub_brands"),
+            tray_id_name=vt_data.get("tray_id_name"),
+            tray_info_idx=vt_data.get("tray_info_idx"),
             remain=vt_data.get("remain", 0),
-            k=vt_data.get("k"),
+            k=vt_k_value,
+            cali_idx=vt_cali_idx,
             tag_uid=vt_tag_uid,
             tray_uuid=vt_tray_uuid,
             nozzle_temp_min=vt_data.get("nozzle_temp_min"),
@@ -977,7 +1005,7 @@ async def debug_simulate_print_complete(
         "timelapse_was_active": False,
     }
 
-    logger.info(f"[DEBUG] Simulating print complete for printer {printer_id}, archive {archive.id}")
+    logger.info(f"Simulating print complete for printer {printer_id}, archive {archive.id}")
 
     # Call the actual on_print_complete handler
     await on_print_complete(printer_id, data)

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

@@ -1,4 +1,5 @@
 from datetime import datetime
+
 from pydantic import BaseModel, Field
 
 
@@ -54,7 +55,8 @@ class AMSTray(BaseModel):
     tray_id_name: str | None = None  # Bambu filament ID like "A00-Y2" (can decode to color)
     tray_info_idx: str | None = None  # Filament preset ID like "GFA00"
     remain: int = 0
-    k: float | None = None  # Pressure advance value
+    k: float | None = None  # Pressure advance value (from tray or K-profile lookup)
+    cali_idx: int | None = None  # Calibration index for K-profile lookup
     tag_uid: str | None = None  # RFID tag UID (any tag)
     tray_uuid: str | None = None  # Bambu Lab spool UUID (32-char hex)
     nozzle_temp_min: int | None = None  # Min nozzle temperature
@@ -76,6 +78,7 @@ class NozzleInfoResponse(BaseModel):
 
 class PrintOptionsResponse(BaseModel):
     """AI detection and print options from xcam data."""
+
     # Core AI detectors
     spaghetti_detector: bool = False
     print_halt: bool = False

+ 39 - 31
backend/app/services/bambu_mqtt.py

@@ -733,9 +733,9 @@ class BambuMQTTClient:
                     parsed_tray_now = raw_tray_now if raw_tray_now is not None else 255
 
                 # H2D dual-nozzle printers report only slot number (0-3), not global tray ID
-                # Use pending_tray_target from our load command tracking for disambiguation
+                # Use active_extruder + ams_extruder_map to determine which AMS the slot belongs to
                 if parsed_tray_now >= 0 and parsed_tray_now <= 3:
-                    # Check if we have a pending target that matches this slot
+                    # First, check if we have a pending target that matches this slot
                     pending_target = self.state.pending_tray_target
                     if pending_target is not None:
                         pending_slot = pending_target % 4
@@ -758,28 +758,45 @@ class BambuMQTTClient:
                             # Clear pending target since it's stale
                             self.state.pending_tray_target = None
                     else:
-                        # No pending target - check if we already have a resolved global ID
-                        # that matches this slot (from a previous successful disambiguation)
-                        current_tray = self.state.tray_now
-                        if current_tray > 3 and current_tray != 255 and (current_tray % 4) == parsed_tray_now:
-                            # Current tray_now is already a valid global ID that matches this slot
-                            # Keep it (don't overwrite with raw slot number)
-                            logger.debug(
-                                f"[{self.serial_number}] H2D tray_now: keeping existing global ID {current_tray} "
-                                f"(matches incoming slot {parsed_tray_now})"
+                        # No pending target - use active_extruder + ams_extruder_map to disambiguate
+                        # Find which AMS is connected to the active extruder
+                        active_ext = self.state.active_extruder  # 0=right, 1=left
+                        ams_map = self.state.ams_extruder_map  # {ams_id: extruder_id}
+
+                        # Find the AMS connected to the active extruder
+                        active_ams_id = None
+                        for ams_id_str, ext_id in ams_map.items():
+                            if ext_id == active_ext:
+                                try:
+                                    active_ams_id = int(ams_id_str)
+                                except ValueError:
+                                    pass
+                                break
+
+                        if active_ams_id is not None:
+                            # Calculate global tray ID using the active AMS
+                            global_tray_id = active_ams_id * 4 + parsed_tray_now
+                            logger.info(
+                                f"[{self.serial_number}] H2D tray_now disambiguation: "
+                                f"slot {parsed_tray_now} + active_extruder {active_ext} -> AMS {active_ams_id} -> global ID {global_tray_id}"
                             )
+                            self.state.tray_now = global_tray_id
                         else:
-                            # No pending target and no valid existing global ID
-                            # For H2D with multiple AMS units, we can't reliably determine which AMS
-                            # the slot belongs to without a pending_tray_target from our load command.
-                            # Use slot number as-is - this may be incorrect for multi-AMS setups,
-                            # but it's better than guessing wrong based on unreliable heuristics.
-                            # The user can load filament via our API to get correct tracking.
-                            logger.warning(
-                                f"[{self.serial_number}] H2D tray_now: no pending target, "
-                                f"using slot {parsed_tray_now} as global ID (may be incorrect for multi-AMS)"
-                            )
-                            self.state.tray_now = parsed_tray_now
+                            # No AMS found for active extruder - check if we already have a resolved global ID
+                            current_tray = self.state.tray_now
+                            if current_tray > 3 and current_tray != 255 and (current_tray % 4) == parsed_tray_now:
+                                # Current tray_now is already a valid global ID that matches this slot
+                                logger.debug(
+                                    f"[{self.serial_number}] H2D tray_now: keeping existing global ID {current_tray} "
+                                    f"(matches incoming slot {parsed_tray_now})"
+                                )
+                            else:
+                                # Fallback: use slot as-is
+                                logger.warning(
+                                    f"[{self.serial_number}] H2D tray_now: no ams_extruder_map for active_extruder {active_ext}, "
+                                    f"using slot {parsed_tray_now} as global ID (may be incorrect for multi-AMS)"
+                                )
+                                self.state.tray_now = parsed_tray_now
                 else:
                     # tray_now > 3 means it's already a global ID, or 255 means unloaded
                     # Note: Do NOT clear pending_tray_target on tray_now=255 here.
@@ -806,15 +823,6 @@ class BambuMQTTClient:
 
         # Extract ams_extruder_map from each AMS unit's info field
         # According to OpenBambuAPI: info field bit 8 indicates which extruder (0=right, 1=left)
-        # Log AMS unit fields once to discover available fields
-        if not hasattr(self, "_ams_fields_logged") and ams_list:
-            first_unit = ams_list[0]
-            logger.info(f"[{self.serial_number}] AMS unit fields: {sorted(first_unit.keys())}")
-            for ams_unit in ams_list:
-                ams_id = ams_unit.get("id")
-                unit_info = {k: v for k, v in ams_unit.items() if k != "tray"}
-                logger.info(f"[{self.serial_number}] AMS {ams_id} data: {unit_info}")
-            self._ams_fields_logged = True
 
         ams_extruder_map = {}
         for ams_unit in ams_list:

+ 39 - 1
backend/app/services/printer_manager.py

@@ -338,6 +338,15 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
     vt_tray = None
     raw_data = state.raw_data or {}
 
+    # Build K-profile lookup map: cali_idx -> k_value
+    kprofile_map: dict[int, float] = {}
+    for kp in state.kprofiles or []:
+        if kp.slot_id is not None and kp.k_value:
+            try:
+                kprofile_map[kp.slot_id] = float(kp.k_value)
+            except (ValueError, TypeError):
+                pass
+
     if "ams" in raw_data and isinstance(raw_data["ams"], list):
         for ams_data in raw_data["ams"]:
             trays = []
@@ -348,16 +357,28 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
                 tray_uuid = tray.get("tray_uuid")
                 if tray_uuid in ("", "00000000000000000000000000000000"):
                     tray_uuid = None
+
+                # Get K value: first try tray's k field, then lookup from K-profiles
+                k_value = tray.get("k")
+                cali_idx = tray.get("cali_idx")
+                if k_value is None and cali_idx is not None and cali_idx in kprofile_map:
+                    k_value = kprofile_map[cali_idx]
+
                 trays.append(
                     {
                         "id": tray.get("id", 0),
                         "tray_color": tray.get("tray_color"),
                         "tray_type": tray.get("tray_type"),
                         "tray_sub_brands": tray.get("tray_sub_brands"),
+                        "tray_id_name": tray.get("tray_id_name"),
+                        "tray_info_idx": tray.get("tray_info_idx"),
                         "remain": tray.get("remain", 0),
-                        "k": tray.get("k"),
+                        "k": k_value,
+                        "cali_idx": cali_idx,
                         "tag_uid": tag_uid,
                         "tray_uuid": tray_uuid,
+                        "nozzle_temp_min": tray.get("nozzle_temp_min"),
+                        "nozzle_temp_max": tray.get("nozzle_temp_max"),
                     }
                 )
             # Prefer humidity_raw (actual percentage) over humidity (index 1-5)
@@ -396,13 +417,30 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None) ->
         vt_tag_uid = vt_data.get("tag_uid")
         if vt_tag_uid in ("", "0000000000000000"):
             vt_tag_uid = None
+        vt_tray_uuid = vt_data.get("tray_uuid")
+        if vt_tray_uuid in ("", "00000000000000000000000000000000"):
+            vt_tray_uuid = None
+
+        # Get K value for vt_tray
+        vt_k_value = vt_data.get("k")
+        vt_cali_idx = vt_data.get("cali_idx")
+        if vt_k_value is None and vt_cali_idx is not None and vt_cali_idx in kprofile_map:
+            vt_k_value = kprofile_map[vt_cali_idx]
+
         vt_tray = {
             "id": 254,
             "tray_color": vt_data.get("tray_color"),
             "tray_type": vt_data.get("tray_type"),
             "tray_sub_brands": vt_data.get("tray_sub_brands"),
+            "tray_id_name": vt_data.get("tray_id_name"),
+            "tray_info_idx": vt_data.get("tray_info_idx"),
             "remain": vt_data.get("remain", 0),
+            "k": vt_k_value,
+            "cali_idx": vt_cali_idx,
             "tag_uid": vt_tag_uid,
+            "tray_uuid": vt_tray_uuid,
+            "nozzle_temp_min": vt_data.get("nozzle_temp_min"),
+            "nozzle_temp_max": vt_data.get("nozzle_temp_max"),
         }
 
     # Get ams_extruder_map from raw_data (populated by MQTT handler from AMS info field)

+ 1299 - 0
frontend/mockups/ams-redesign.html

@@ -0,0 +1,1299 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>AMS Section Redesign Mockup</title>
+  <link rel="preconnect" href="https://fonts.googleapis.com">
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
+  <style>
+    :root {
+      /* Match actual Bambuddy dark theme */
+      --bg-page: #121218;
+      --bg-card: #1a1a22;
+      --bg-section: #22222a;
+      --bg-input: #2a2a32;
+      --border-color: #333340;
+      --text-primary: #ffffff;
+      --text-secondary: #9ca3af;
+      --text-muted: #6b7280;
+      --bambu-green: #00ae42;
+      --bambu-green-bg: rgba(0, 174, 66, 0.2);
+      --humidity-good: #00ae42;
+      --humidity-fair: #f59e0b;
+      --humidity-bad: #ef4444;
+    }
+
+    * {
+      margin: 0;
+      padding: 0;
+      box-sizing: border-box;
+    }
+
+    body {
+      font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
+      background: var(--bg-page);
+      color: var(--text-primary);
+      min-height: 100vh;
+      padding: 24px;
+    }
+
+    .page-header {
+      margin-bottom: 24px;
+    }
+
+    .page-title {
+      font-size: 18px;
+      font-weight: 600;
+      color: var(--text-primary);
+      margin-bottom: 4px;
+    }
+
+    .page-subtitle {
+      font-size: 13px;
+      color: var(--text-muted);
+    }
+
+    /* Printer Card - matches actual app */
+    .printer-card {
+      background: var(--bg-card);
+      border: 1px solid var(--border-color);
+      border-radius: 12px;
+      padding: 16px;
+      width: 340px;
+    }
+
+    /* Card Header */
+    .card-header {
+      display: flex;
+      align-items: flex-start;
+      gap: 12px;
+      margin-bottom: 8px;
+    }
+
+    .printer-image {
+      width: 56px;
+      height: 56px;
+      border-radius: 8px;
+      background: var(--bg-section);
+      flex-shrink: 0;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      overflow: hidden;
+    }
+
+    .printer-image svg {
+      width: 40px;
+      height: 40px;
+      color: var(--text-muted);
+    }
+
+    .printer-details {
+      flex: 1;
+      min-width: 0;
+    }
+
+    .printer-name-row {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+
+    .printer-name {
+      font-size: 18px;
+      font-weight: 600;
+      color: var(--text-primary);
+    }
+
+    .menu-btn {
+      width: 24px;
+      height: 24px;
+      border: none;
+      background: transparent;
+      color: var(--text-secondary);
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      border-radius: 4px;
+    }
+
+    .menu-btn:hover {
+      background: var(--bg-section);
+    }
+
+    .printer-model {
+      font-size: 14px;
+      color: var(--text-secondary);
+      margin-top: 2px;
+    }
+
+    /* Badges row */
+    .badges-row {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 6px;
+      margin-bottom: 16px;
+    }
+
+    .badge {
+      display: inline-flex;
+      align-items: center;
+      gap: 4px;
+      padding: 4px 8px;
+      border-radius: 9999px;
+      font-size: 12px;
+      font-weight: 500;
+    }
+
+    .badge-green {
+      background: var(--bambu-green-bg);
+      color: var(--bambu-green);
+    }
+
+    .badge svg {
+      width: 12px;
+      height: 12px;
+    }
+
+    /* Status Section */
+    .status-section {
+      background: var(--bg-section);
+      border-radius: 8px;
+      padding: 12px;
+      margin-bottom: 12px;
+    }
+
+    .status-row {
+      display: flex;
+      gap: 12px;
+    }
+
+    .cover-placeholder {
+      width: 72px;
+      height: 72px;
+      border-radius: 8px;
+      background: var(--bg-input);
+      flex-shrink: 0;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
+    .cover-placeholder svg {
+      width: 32px;
+      height: 32px;
+      color: var(--text-muted);
+    }
+
+    .status-info {
+      flex: 1;
+      min-width: 0;
+    }
+
+    .status-label {
+      font-size: 14px;
+      color: var(--text-secondary);
+      margin-bottom: 2px;
+    }
+
+    .status-value {
+      font-size: 14px;
+      color: var(--text-primary);
+      margin-bottom: 8px;
+    }
+
+    .progress-bar {
+      height: 8px;
+      background: var(--bg-input);
+      border-radius: 4px;
+      margin-bottom: 8px;
+    }
+
+    .progress-fill {
+      height: 100%;
+      background: var(--bambu-green);
+      border-radius: 4px;
+    }
+
+    .ready-text {
+      font-size: 12px;
+      color: var(--text-secondary);
+    }
+
+    /* Temperature Grid */
+    .temp-grid {
+      display: grid;
+      grid-template-columns: repeat(3, 1fr);
+      gap: 8px;
+      margin-bottom: 12px;
+    }
+
+    .temp-card {
+      background: var(--bg-section);
+      border-radius: 8px;
+      padding: 8px;
+      text-align: center;
+    }
+
+    .temp-icon {
+      width: 16px;
+      height: 16px;
+      margin: 0 auto 4px;
+    }
+
+    .temp-label {
+      font-size: 11px;
+      color: var(--text-secondary);
+      margin-bottom: 2px;
+    }
+
+    .temp-value {
+      font-size: 14px;
+      color: var(--text-primary);
+      font-weight: 500;
+    }
+
+    /* AMS Section */
+    .ams-section {
+      margin-top: 12px;
+    }
+
+    /* Current stacked layout */
+    .ams-stacked {
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
+    }
+
+    .ams-row {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      background: var(--bg-section);
+      border-radius: 8px;
+      padding: 8px 10px;
+    }
+
+    .ams-icon-wrapper {
+      flex-shrink: 0;
+    }
+
+    .filament-info {
+      flex: 1;
+      min-width: 0;
+    }
+
+    .ams-label {
+      font-size: 11px;
+      font-weight: 500;
+      color: var(--text-muted);
+    }
+
+    .filament-types {
+      font-size: 10px;
+      color: var(--text-secondary);
+      margin-top: 1px;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .filament-fills {
+      font-size: 9px;
+      color: var(--text-muted);
+      margin-top: 1px;
+    }
+
+    .ams-stats {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      flex-shrink: 0;
+    }
+
+    .stat {
+      display: flex;
+      align-items: center;
+      gap: 3px;
+      font-size: 11px;
+      font-weight: 500;
+    }
+
+    .stat svg {
+      width: 12px;
+      height: 12px;
+    }
+
+    .stat-good { color: var(--humidity-good); }
+    .stat-fair { color: var(--humidity-fair); }
+    .stat-bad { color: var(--humidity-bad); }
+    .stat-neutral { color: var(--text-secondary); }
+
+    /* Smart plug section */
+    .smart-plug-section {
+      margin-top: 16px;
+      padding-top: 16px;
+      border-top: 1px solid var(--border-color);
+    }
+
+    .plug-row {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+
+    .plug-icon {
+      width: 16px;
+      height: 16px;
+      color: var(--text-secondary);
+    }
+
+    .plug-name {
+      font-size: 14px;
+      color: var(--text-primary);
+    }
+
+    .plug-badge {
+      font-size: 11px;
+      padding: 2px 6px;
+      border-radius: 4px;
+      font-weight: 500;
+    }
+
+    .plug-badge.on {
+      background: var(--bambu-green-bg);
+      color: var(--bambu-green);
+    }
+
+    .plug-power {
+      font-size: 12px;
+      color: #facc15;
+      font-weight: 500;
+    }
+
+    .plug-controls {
+      margin-left: auto;
+      display: flex;
+      align-items: center;
+      gap: 6px;
+    }
+
+    .plug-btn {
+      font-size: 11px;
+      padding: 4px 8px;
+      border-radius: 4px;
+      border: none;
+      cursor: pointer;
+      font-weight: 500;
+    }
+
+    .plug-btn.on {
+      background: var(--bambu-green-bg);
+      color: var(--bambu-green);
+    }
+
+    .plug-btn.off {
+      background: var(--bg-input);
+      color: var(--text-secondary);
+    }
+
+    .auto-off-toggle {
+      display: flex;
+      align-items: center;
+      gap: 4px;
+      font-size: 11px;
+      color: var(--text-secondary);
+    }
+
+    .toggle-switch {
+      width: 32px;
+      height: 18px;
+      background: var(--bg-input);
+      border-radius: 9px;
+      position: relative;
+    }
+
+    .toggle-switch.active {
+      background: var(--bambu-green);
+    }
+
+    .toggle-switch::after {
+      content: '';
+      position: absolute;
+      width: 14px;
+      height: 14px;
+      background: white;
+      border-radius: 50%;
+      top: 2px;
+      left: 2px;
+      transition: transform 0.2s;
+    }
+
+    .toggle-switch.active::after {
+      transform: translateX(14px);
+    }
+
+    .plug-footer {
+      margin-top: 8px;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+
+    .plug-ip {
+      font-size: 11px;
+      color: var(--text-muted);
+    }
+
+    .plug-actions {
+      margin-left: auto;
+      display: flex;
+      gap: 4px;
+    }
+
+    .action-btn {
+      width: 28px;
+      height: 28px;
+      border-radius: 4px;
+      border: none;
+      background: var(--bg-input);
+      color: var(--text-secondary);
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+
+    .action-btn svg {
+      width: 14px;
+      height: 14px;
+    }
+
+    /* Comparison layout */
+    .comparison {
+      display: flex;
+      gap: 32px;
+      flex-wrap: wrap;
+      align-items: flex-start;
+    }
+
+    .comparison-section {
+      display: flex;
+      flex-direction: column;
+    }
+
+    .section-label {
+      display: inline-block;
+      font-size: 11px;
+      font-weight: 600;
+      padding: 4px 10px;
+      border-radius: 4px;
+      margin-bottom: 12px;
+      text-transform: uppercase;
+      letter-spacing: 0.5px;
+      width: fit-content;
+    }
+
+    .section-label.current {
+      background: var(--text-muted);
+      color: var(--bg-page);
+    }
+
+    .section-label.new {
+      background: var(--bambu-green);
+      color: white;
+    }
+
+    /* NEW 2-Column Grid Layout */
+    .ams-grid {
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 8px;
+    }
+
+    .ams-card {
+      background: var(--bg-section);
+      border-radius: 8px;
+      padding: 8px;
+    }
+
+    .ams-card-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      margin-bottom: 6px;
+    }
+
+    .ams-card-left {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+    }
+
+    .ams-card-stats {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+    }
+
+    .ams-card-stats .stat {
+      font-size: 10px;
+    }
+
+    .ams-card-stats .stat svg {
+      width: 10px;
+      height: 10px;
+    }
+
+    .slots-grid {
+      display: grid;
+      grid-template-columns: repeat(4, 1fr);
+      gap: 4px;
+    }
+
+    .slot {
+      background: var(--bg-input);
+      border-radius: 4px;
+      padding: 4px 2px;
+      text-align: center;
+    }
+
+    .slot-color {
+      width: 14px;
+      height: 14px;
+      border-radius: 50%;
+      margin: 0 auto 2px;
+      border: 1px solid rgba(255, 255, 255, 0.1);
+    }
+
+    .slot-color.empty {
+      background: transparent;
+      border: 1px dashed var(--text-muted);
+    }
+
+    .slot-type {
+      font-size: 8px;
+      color: var(--text-muted);
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .slot-fill {
+      font-size: 8px;
+      color: var(--text-muted);
+      opacity: 0.7;
+    }
+
+    /* Row 3: HT + External (half-size) */
+    .ams-row-small {
+      display: grid;
+      grid-template-columns: repeat(4, 1fr);
+      gap: 8px;
+      margin-top: 8px;
+    }
+
+    .ams-card-small {
+      background: var(--bg-section);
+      border-radius: 8px;
+      padding: 6px 8px;
+      display: flex;
+      align-items: center;
+      gap: 6px;
+    }
+
+    .ams-card-small .small-info {
+      flex: 1;
+      min-width: 0;
+    }
+
+    .ams-card-small .ams-label {
+      font-size: 10px;
+    }
+
+    .ams-card-small .slot-type {
+      font-size: 9px;
+    }
+
+    .external-spool {
+      width: 20px;
+      height: 20px;
+      border-radius: 50%;
+      border: 2px solid rgba(255, 255, 255, 0.15);
+      flex-shrink: 0;
+    }
+
+    .note {
+      font-size: 11px;
+      color: var(--text-muted);
+      margin-top: 12px;
+      padding: 8px;
+      background: var(--bg-section);
+      border-radius: 6px;
+    }
+  </style>
+</head>
+<body>
+  <div class="page-header">
+    <h1 class="page-title">AMS Section Redesign</h1>
+    <p class="page-subtitle">Current stacked layout vs. new 2-column grid</p>
+  </div>
+
+  <div class="comparison">
+    <!-- CURRENT LAYOUT -->
+    <div class="comparison-section">
+      <span class="section-label current">Current</span>
+
+      <div class="printer-card">
+        <!-- Header -->
+        <div class="card-header">
+          <div class="printer-image">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+              <rect x="4" y="4" width="16" height="16" rx="2"/>
+              <path d="M4 10h16"/>
+              <path d="M10 10v10"/>
+            </svg>
+          </div>
+          <div class="printer-details">
+            <div class="printer-name-row">
+              <span class="printer-name">H2D-1</span>
+              <button class="menu-btn">
+                <svg viewBox="0 0 24 24" fill="currentColor">
+                  <circle cx="12" cy="5" r="1.5"/>
+                  <circle cx="12" cy="12" r="1.5"/>
+                  <circle cx="12" cy="19" r="1.5"/>
+                </svg>
+              </button>
+            </div>
+            <div class="printer-model">H2D • 0.4mm • 632h</div>
+          </div>
+        </div>
+
+        <!-- Badges -->
+        <div class="badges-row">
+          <span class="badge badge-green">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
+              <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
+            </svg>
+            Connected
+          </span>
+          <span class="badge badge-green">-53dBm</span>
+          <span class="badge badge-green">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M12 9v2m0 4h.01"/>
+              <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
+            </svg>
+            OK
+          </span>
+          <span class="badge badge-green">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
+            </svg>
+            OK
+          </span>
+        </div>
+
+        <!-- Status -->
+        <div class="status-section">
+          <div class="status-row">
+            <div class="cover-placeholder">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+                <rect x="3" y="3" width="18" height="18" rx="2"/>
+                <circle cx="8.5" cy="8.5" r="1.5"/>
+                <path d="M21 15l-5-5L5 21"/>
+              </svg>
+            </div>
+            <div class="status-info">
+              <div class="status-label">Status</div>
+              <div class="status-value">Idle</div>
+              <div class="progress-bar"></div>
+              <div class="ready-text">Ready to print</div>
+            </div>
+          </div>
+        </div>
+
+        <!-- Temperatures -->
+        <div class="temp-grid">
+          <div class="temp-card">
+            <svg class="temp-icon" viewBox="0 0 24 24" fill="none" stroke="#f97316" stroke-width="2">
+              <path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
+            </svg>
+            <div class="temp-label">Left / Right</div>
+            <div class="temp-value">20°C / 19°C</div>
+          </div>
+          <div class="temp-card">
+            <svg class="temp-icon" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2">
+              <path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
+            </svg>
+            <div class="temp-label">Bed</div>
+            <div class="temp-value">20°C</div>
+          </div>
+          <div class="temp-card">
+            <svg class="temp-icon" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2">
+              <path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
+            </svg>
+            <div class="temp-label">Chamber</div>
+            <div class="temp-value">21°C</div>
+          </div>
+        </div>
+
+        <!-- AMS Section - CURRENT STACKED -->
+        <div class="ams-section">
+          <div class="ams-stacked">
+            <!-- AMS-A -->
+            <div class="ams-row">
+              <div class="ams-icon-wrapper">
+                <svg width="56" height="34" viewBox="0 0 52 32" fill="none">
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M4 0C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H48C50.2091 32 52 30.2091 52 28V4C52 1.79086 50.2091 0 48 0H4ZM44 8H8V24H44V8Z" fill="#2F2E33"/>
+                  <rect x="9.5" y="8" width="6" height="16" fill="#e53935"/>
+                  <rect x="18.5" y="8" width="6" height="16" fill="#1e88e5"/>
+                  <rect x="27.5" y="8" width="6" height="16" fill="#43a047"/>
+                  <rect x="36.5" y="8" width="6" height="16" fill="#f5f5f5"/>
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M36.5 16H33.5V18.26C33.5 19.92 32.16 21.26 30.5 21.26C28.84 21.26 27.5 19.92 27.5 18.26V16H24.5V18.26C24.5 19.92 23.16 21.26 21.5 21.26C19.84 21.26 18.5 19.92 18.5 18.26V16H15.5V18.26C15.5 19.92 14.16 21.26 12.5 21.26C10.84 21.26 9.5 19.92 9.5 18.26V16H4V28H48V16H42.5V18.26C42.5 19.92 41.16 21.26 39.5 21.26C37.84 21.26 36.5 19.92 36.5 18.26V16Z" fill="#767676"/>
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M6 9.18C6 6.32 8.32 4 11.18 4H40.82C43.68 4 46 6.32 46 9.18V16H42.5V12.26C42.5 10.6 41.16 9.26 39.5 9.26C37.84 9.26 36.5 10.6 36.5 12.26V16H33.5V12.26C33.5 10.6 32.16 9.26 30.5 9.26C28.84 9.26 27.5 10.6 27.5 12.26V16H24.5V12.26C24.5 10.6 23.16 9.26 21.5 9.26C19.84 9.26 18.5 10.6 18.5 12.26V16H15.5V12.26C15.5 10.6 14.16 9.26 12.5 9.26C10.84 9.26 9.5 10.6 9.5 12.26V16H6V9.18Z" fill="#BFBFBF"/>
+                </svg>
+              </div>
+              <div class="filament-info">
+                <div class="ams-label">AMS-A</div>
+                <div class="filament-types">PLA Basic · PETG HF · PLA Basic · PLA Basic</div>
+                <div class="filament-fills">71% · 50% · 87% · 23%</div>
+              </div>
+              <div class="ams-stats">
+                <div class="stat stat-good">
+                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                  </svg>
+                  21%
+                </div>
+                <div class="stat stat-neutral">20.7°C</div>
+              </div>
+            </div>
+
+            <!-- AMS-B -->
+            <div class="ams-row">
+              <div class="ams-icon-wrapper">
+                <svg width="56" height="34" viewBox="0 0 52 32" fill="none">
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M4 0C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H48C50.2091 32 52 30.2091 52 28V4C52 1.79086 50.2091 0 48 0H4ZM44 8H8V24H44V8Z" fill="#2F2E33"/>
+                  <rect x="9.5" y="8" width="6" height="16" fill="#9c27b0"/>
+                  <rect x="18.5" y="8" width="6" height="16" fill="#ff9800"/>
+                  <rect x="27.5" y="8" width="6" height="16" fill="#fdd835"/>
+                  <rect x="36.5" y="8" width="6" height="16" fill="#212121"/>
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M36.5 16H33.5V18.26C33.5 19.92 32.16 21.26 30.5 21.26C28.84 21.26 27.5 19.92 27.5 18.26V16H24.5V18.26C24.5 19.92 23.16 21.26 21.5 21.26C19.84 21.26 18.5 19.92 18.5 18.26V16H15.5V18.26C15.5 19.92 14.16 21.26 12.5 21.26C10.84 21.26 9.5 19.92 9.5 18.26V16H4V28H48V16H42.5V18.26C42.5 19.92 41.16 21.26 39.5 21.26C37.84 21.26 36.5 19.92 36.5 18.26V16Z" fill="#767676"/>
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M6 9.18C6 6.32 8.32 4 11.18 4H40.82C43.68 4 46 6.32 46 9.18V16H42.5V12.26C42.5 10.6 41.16 9.26 39.5 9.26C37.84 9.26 36.5 10.6 36.5 12.26V16H33.5V12.26C33.5 10.6 32.16 9.26 30.5 9.26C28.84 9.26 27.5 10.6 27.5 12.26V16H24.5V12.26C24.5 10.6 23.16 9.26 21.5 9.26C19.84 9.26 18.5 10.6 18.5 12.26V16H15.5V12.26C15.5 10.6 14.16 9.26 12.5 9.26C10.84 9.26 9.5 10.6 9.5 12.26V16H6V9.18Z" fill="#BFBFBF"/>
+                </svg>
+              </div>
+              <div class="filament-info">
+                <div class="ams-label">AMS-B</div>
+                <div class="filament-types">PETG HF · PLA · PLA-S · PLA-S</div>
+                <div class="filament-fills">45% · 92% · 15% · 68%</div>
+              </div>
+              <div class="ams-stats">
+                <div class="stat stat-good">
+                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                  </svg>
+                  16%
+                </div>
+                <div class="stat stat-neutral">22.7°C</div>
+              </div>
+            </div>
+
+            <!-- AMS-C -->
+            <div class="ams-row">
+              <div class="ams-icon-wrapper">
+                <svg width="56" height="34" viewBox="0 0 52 32" fill="none">
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M4 0C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H48C50.2091 32 52 30.2091 52 28V4C52 1.79086 50.2091 0 48 0H4ZM44 8H8V24H44V8Z" fill="#2F2E33"/>
+                  <rect x="9.5" y="8" width="6" height="16" fill="#00bcd4"/>
+                  <rect x="18.5" y="8" width="6" height="16" fill="#e91e63"/>
+                  <rect x="27.5" y="8" width="6" height="16" fill="#9e9e9e"/>
+                  <rect x="36.5" y="8" width="6" height="16" fill="#f48fb1"/>
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M36.5 16H33.5V18.26C33.5 19.92 32.16 21.26 30.5 21.26C28.84 21.26 27.5 19.92 27.5 18.26V16H24.5V18.26C24.5 19.92 23.16 21.26 21.5 21.26C19.84 21.26 18.5 19.92 18.5 18.26V16H15.5V18.26C15.5 19.92 14.16 21.26 12.5 21.26C10.84 21.26 9.5 19.92 9.5 18.26V16H4V28H48V16H42.5V18.26C42.5 19.92 41.16 21.26 39.5 21.26C37.84 21.26 36.5 19.92 36.5 18.26V16Z" fill="#767676"/>
+                  <path fill-rule="evenodd" clip-rule="evenodd" d="M6 9.18C6 6.32 8.32 4 11.18 4H40.82C43.68 4 46 6.32 46 9.18V16H42.5V12.26C42.5 10.6 41.16 9.26 39.5 9.26C37.84 9.26 36.5 10.6 36.5 12.26V16H33.5V12.26C33.5 10.6 32.16 9.26 30.5 9.26C28.84 9.26 27.5 10.6 27.5 12.26V16H24.5V12.26C24.5 10.6 23.16 9.26 21.5 9.26C19.84 9.26 18.5 10.6 18.5 12.26V16H15.5V12.26C15.5 10.6 14.16 9.26 12.5 9.26C10.84 9.26 9.5 10.6 9.5 12.26V16H6V9.18Z" fill="#BFBFBF"/>
+                </svg>
+              </div>
+              <div class="filament-info">
+                <div class="ams-label">AMS-C</div>
+                <div class="filament-types">PLA-S · PLA-S · PETG · PLA</div>
+                <div class="filament-fills">33% · 78% · 55% · 41%</div>
+              </div>
+              <div class="ams-stats">
+                <div class="stat stat-good">
+                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                  </svg>
+                  16%
+                </div>
+                <div class="stat stat-neutral">22.9°C</div>
+              </div>
+            </div>
+
+            <!-- HT-A -->
+            <div class="ams-row">
+              <div class="ams-icon-wrapper">
+                <svg width="56" height="56" viewBox="0 0 21 21" fill="none">
+                  <rect x="8.3" y="5.2" width="3.8" height="5.1" fill="none" stroke="#666" stroke-dasharray="2 1.5" rx="0.3"/>
+                  <path d="M5.88312 4.68555C5.88312 4.13326 6.33083 3.68555 6.88312 3.68555H13.5059C14.0582 3.68555 14.5059 4.13326 14.5059 4.68555V10.3887H5.88312V4.68555Z" stroke="#6B6B6B"/>
+                  <rect x="3.8725" y="10.3887" width="12.7037" height="7.55371" rx="1.2" stroke="#6B6B6B"/>
+                  <path d="M8.21991 5.65234C8.21991 5.3762 8.44377 5.15234 8.71991 5.15234H11.7288C12.005 5.15234 12.2288 5.3762 12.2288 5.65234V10.3887H8.21991V5.65234Z" stroke="#6B6B6B"/>
+                </svg>
+              </div>
+              <div class="filament-info">
+                <div class="ams-label">HT-A</div>
+                <div class="filament-types">—</div>
+              </div>
+              <div class="ams-stats">
+                <div class="stat stat-fair">
+                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                  </svg>
+                  47%
+                </div>
+                <div class="stat stat-neutral">19.7°C</div>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- Smart Plug -->
+        <div class="smart-plug-section">
+          <div class="plug-row">
+            <svg class="plug-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
+            </svg>
+            <span class="plug-name">bamnbuswitch3</span>
+            <span class="plug-badge on">ON</span>
+            <span class="plug-power">18W</span>
+            <div class="plug-controls">
+              <button class="plug-btn on">On</button>
+              <button class="plug-btn off">Off</button>
+              <div class="auto-off-toggle">
+                Auto-off
+                <div class="toggle-switch"></div>
+              </div>
+            </div>
+          </div>
+          <div class="plug-footer">
+            <span class="plug-ip">192.168.255.133<br/>00488B540200427</span>
+            <div class="plug-actions">
+              <button class="action-btn">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/>
+                </svg>
+              </button>
+              <button class="action-btn">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
+                </svg>
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <div class="note">4 AMS rows + 1 HT row = 5 rows in AMS section</div>
+      </div>
+    </div>
+
+    <!-- NEW LAYOUT -->
+    <div class="comparison-section">
+      <span class="section-label new">New 2-Column</span>
+
+      <div class="printer-card">
+        <!-- Header (same as current) -->
+        <div class="card-header">
+          <div class="printer-image">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+              <rect x="4" y="4" width="16" height="16" rx="2"/>
+              <path d="M4 10h16"/>
+              <path d="M10 10v10"/>
+            </svg>
+          </div>
+          <div class="printer-details">
+            <div class="printer-name-row">
+              <span class="printer-name">H2D-1</span>
+              <button class="menu-btn">
+                <svg viewBox="0 0 24 24" fill="currentColor">
+                  <circle cx="12" cy="5" r="1.5"/>
+                  <circle cx="12" cy="12" r="1.5"/>
+                  <circle cx="12" cy="19" r="1.5"/>
+                </svg>
+              </button>
+            </div>
+            <div class="printer-model">H2D • 0.4mm • 632h</div>
+          </div>
+        </div>
+
+        <!-- Badges (same) -->
+        <div class="badges-row">
+          <span class="badge badge-green">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
+              <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
+            </svg>
+            Connected
+          </span>
+          <span class="badge badge-green">-53dBm</span>
+          <span class="badge badge-green">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M12 9v2m0 4h.01"/>
+              <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
+            </svg>
+            OK
+          </span>
+          <span class="badge badge-green">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
+            </svg>
+            OK
+          </span>
+        </div>
+
+        <!-- Status (same) -->
+        <div class="status-section">
+          <div class="status-row">
+            <div class="cover-placeholder">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
+                <rect x="3" y="3" width="18" height="18" rx="2"/>
+                <circle cx="8.5" cy="8.5" r="1.5"/>
+                <path d="M21 15l-5-5L5 21"/>
+              </svg>
+            </div>
+            <div class="status-info">
+              <div class="status-label">Status</div>
+              <div class="status-value">Idle</div>
+              <div class="progress-bar"></div>
+              <div class="ready-text">Ready to print</div>
+            </div>
+          </div>
+        </div>
+
+        <!-- Temperatures (same) -->
+        <div class="temp-grid">
+          <div class="temp-card">
+            <svg class="temp-icon" viewBox="0 0 24 24" fill="none" stroke="#f97316" stroke-width="2">
+              <path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
+            </svg>
+            <div class="temp-label">Left / Right</div>
+            <div class="temp-value">20°C / 19°C</div>
+          </div>
+          <div class="temp-card">
+            <svg class="temp-icon" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2">
+              <path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
+            </svg>
+            <div class="temp-label">Bed</div>
+            <div class="temp-value">20°C</div>
+          </div>
+          <div class="temp-card">
+            <svg class="temp-icon" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2">
+              <path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z"/>
+            </svg>
+            <div class="temp-label">Chamber</div>
+            <div class="temp-value">21°C</div>
+          </div>
+        </div>
+
+        <!-- AMS Section - NEW 2-COLUMN GRID -->
+        <div class="ams-section">
+          <!-- Row 1-2: Up to 4x AMS -->
+          <div class="ams-grid">
+            <!-- AMS-A -->
+            <div class="ams-card">
+              <div class="ams-card-header">
+                <div class="ams-card-left">
+                  <svg width="36" height="22" viewBox="0 0 36 22" fill="none">
+                    <rect x="1" y="1" width="34" height="20" rx="2" fill="#2F2E33"/>
+                    <rect x="5" y="5" width="4" height="12" fill="#e53935"/>
+                    <rect x="11" y="5" width="4" height="12" fill="#1e88e5"/>
+                    <rect x="17" y="5" width="4" height="12" fill="#43a047"/>
+                    <rect x="23" y="5" width="4" height="12" fill="#f5f5f5"/>
+                    <rect x="29" y="5" width="2" height="12" fill="#767676"/>
+                  </svg>
+                  <span class="ams-label">AMS-A</span>
+                </div>
+                <div class="ams-card-stats">
+                  <div class="stat stat-good">
+                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                    </svg>
+                    21%
+                  </div>
+                  <div class="stat stat-neutral">20.6°</div>
+                </div>
+              </div>
+              <div class="slots-grid">
+                <div class="slot">
+                  <div class="slot-color" style="background: #e53935;"></div>
+                  <div class="slot-type">PLA Basic</div>
+                  <div class="slot-fill">71%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #1e88e5;"></div>
+                  <div class="slot-type">PETG HF</div>
+                  <div class="slot-fill">50%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #43a047;"></div>
+                  <div class="slot-type">PLA</div>
+                  <div class="slot-fill">87%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #f5f5f5;"></div>
+                  <div class="slot-type">PLA Basic</div>
+                  <div class="slot-fill">23%</div>
+                </div>
+              </div>
+            </div>
+
+            <!-- AMS-B -->
+            <div class="ams-card">
+              <div class="ams-card-header">
+                <div class="ams-card-left">
+                  <svg width="36" height="22" viewBox="0 0 36 22" fill="none">
+                    <rect x="1" y="1" width="34" height="20" rx="2" fill="#2F2E33"/>
+                    <rect x="5" y="5" width="4" height="12" fill="#9c27b0"/>
+                    <rect x="11" y="5" width="4" height="12" fill="#ff9800"/>
+                    <rect x="17" y="5" width="4" height="12" fill="#fdd835"/>
+                    <rect x="23" y="5" width="4" height="12" fill="#212121"/>
+                    <rect x="29" y="5" width="2" height="12" fill="#767676"/>
+                  </svg>
+                  <span class="ams-label">AMS-B</span>
+                </div>
+                <div class="ams-card-stats">
+                  <div class="stat stat-good">
+                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                    </svg>
+                    16%
+                  </div>
+                  <div class="stat stat-neutral">20.7°</div>
+                </div>
+              </div>
+              <div class="slots-grid">
+                <div class="slot">
+                  <div class="slot-color" style="background: #9c27b0;"></div>
+                  <div class="slot-type">PETG HF</div>
+                  <div class="slot-fill">45%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #ff9800;"></div>
+                  <div class="slot-type">PLA</div>
+                  <div class="slot-fill">92%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #fdd835;"></div>
+                  <div class="slot-type">PLA-S</div>
+                  <div class="slot-fill">15%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #212121;"></div>
+                  <div class="slot-type">PLA-S</div>
+                  <div class="slot-fill">68%</div>
+                </div>
+              </div>
+            </div>
+
+            <!-- AMS-C -->
+            <div class="ams-card">
+              <div class="ams-card-header">
+                <div class="ams-card-left">
+                  <svg width="36" height="22" viewBox="0 0 36 22" fill="none">
+                    <rect x="1" y="1" width="34" height="20" rx="2" fill="#2F2E33"/>
+                    <rect x="5" y="5" width="4" height="12" fill="#00bcd4"/>
+                    <rect x="11" y="5" width="4" height="12" fill="#e91e63"/>
+                    <rect x="17" y="5" width="4" height="12" fill="#9e9e9e"/>
+                    <rect x="23" y="5" width="4" height="12" fill="#f48fb1"/>
+                    <rect x="29" y="5" width="2" height="12" fill="#767676"/>
+                  </svg>
+                  <span class="ams-label">AMS-C</span>
+                </div>
+                <div class="ams-card-stats">
+                  <div class="stat stat-good">
+                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                    </svg>
+                    17%
+                  </div>
+                  <div class="stat stat-neutral">22.0°</div>
+                </div>
+              </div>
+              <div class="slots-grid">
+                <div class="slot">
+                  <div class="slot-color" style="background: #00bcd4;"></div>
+                  <div class="slot-type">PLA-S</div>
+                  <div class="slot-fill">33%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #e91e63;"></div>
+                  <div class="slot-type">PLA-S</div>
+                  <div class="slot-fill">78%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #9e9e9e;"></div>
+                  <div class="slot-type">PETG</div>
+                  <div class="slot-fill">55%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #f48fb1;"></div>
+                  <div class="slot-type">PLA</div>
+                  <div class="slot-fill">41%</div>
+                </div>
+              </div>
+            </div>
+
+            <!-- AMS-D -->
+            <div class="ams-card">
+              <div class="ams-card-header">
+                <div class="ams-card-left">
+                  <svg width="36" height="22" viewBox="0 0 36 22" fill="none">
+                    <rect x="1" y="1" width="34" height="20" rx="2" fill="#2F2E33"/>
+                    <rect x="5" y="5" width="4" height="12" fill="#8bc34a"/>
+                    <rect x="11" y="5" width="4" height="12" fill="#ff5722"/>
+                    <rect x="17" y="5" width="4" height="12" fill="#607d8b"/>
+                    <rect x="23" y="5" width="4" height="12" fill="#795548"/>
+                    <rect x="29" y="5" width="2" height="12" fill="#767676"/>
+                  </svg>
+                  <span class="ams-label">AMS-D</span>
+                </div>
+                <div class="ams-card-stats">
+                  <div class="stat stat-good">
+                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                    </svg>
+                    9%
+                  </div>
+                  <div class="stat stat-neutral">21.2°</div>
+                </div>
+              </div>
+              <div class="slots-grid">
+                <div class="slot">
+                  <div class="slot-color" style="background: #8bc34a;"></div>
+                  <div class="slot-type">PLA</div>
+                  <div class="slot-fill">88%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #ff5722;"></div>
+                  <div class="slot-type">ABS</div>
+                  <div class="slot-fill">62%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #607d8b;"></div>
+                  <div class="slot-type">PETG</div>
+                  <div class="slot-fill">29%</div>
+                </div>
+                <div class="slot">
+                  <div class="slot-color" style="background: #795548;"></div>
+                  <div class="slot-type">PLA</div>
+                  <div class="slot-fill">95%</div>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <!-- Row 3: HT + External (half-size, 4 across) -->
+          <div class="ams-row-small">
+            <!-- HT-A -->
+            <div class="ams-card-small">
+              <svg width="20" height="20" viewBox="0 0 21 21" fill="none">
+                <rect x="6" y="4" width="9" height="7" rx="1" fill="#2F2E33" stroke="#6B6B6B"/>
+                <rect x="4" y="11" width="13" height="6" rx="1" stroke="#6B6B6B"/>
+                <circle cx="10.5" cy="7.5" r="2" fill="none" stroke="#666" stroke-dasharray="2 1"/>
+              </svg>
+              <div class="small-info">
+                <div class="ams-label">HT-A</div>
+                <div class="slot-type">Empty</div>
+              </div>
+              <div class="ams-card-stats">
+                <div class="stat stat-fair">
+                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                  </svg>
+                  44%
+                </div>
+              </div>
+            </div>
+
+            <!-- HT-B -->
+            <div class="ams-card-small">
+              <svg width="20" height="20" viewBox="0 0 21 21" fill="none">
+                <rect x="6" y="4" width="9" height="7" rx="1" fill="#2F2E33" stroke="#6B6B6B"/>
+                <rect x="4" y="11" width="13" height="6" rx="1" stroke="#6B6B6B"/>
+                <circle cx="10.5" cy="7.5" r="2" fill="#00acc1"/>
+              </svg>
+              <div class="small-info">
+                <div class="ams-label">HT-B</div>
+                <div class="slot-type">PA-CF</div>
+              </div>
+              <div class="ams-card-stats">
+                <div class="stat stat-good">
+                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                    <path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"/>
+                  </svg>
+                  12%
+                </div>
+              </div>
+            </div>
+
+            <!-- External 1 -->
+            <div class="ams-card-small">
+              <div class="external-spool" style="background: #b0bec5;"></div>
+              <div class="small-info">
+                <div class="ams-label">Ext-1</div>
+                <div class="slot-type">PLA</div>
+              </div>
+            </div>
+
+            <!-- External 2 -->
+            <div class="ams-card-small">
+              <div class="external-spool" style="background: #ffeb3b;"></div>
+              <div class="small-info">
+                <div class="ams-label">Ext-2</div>
+                <div class="slot-type">TPU</div>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <!-- Smart Plug (same as current) -->
+        <div class="smart-plug-section">
+          <div class="plug-row">
+            <svg class="plug-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
+            </svg>
+            <span class="plug-name">bamnbuswitch3</span>
+            <span class="plug-badge on">ON</span>
+            <span class="plug-power">18W</span>
+            <div class="plug-controls">
+              <button class="plug-btn on">On</button>
+              <button class="plug-btn off">Off</button>
+              <div class="auto-off-toggle">
+                Auto-off
+                <div class="toggle-switch"></div>
+              </div>
+            </div>
+          </div>
+          <div class="plug-footer">
+            <span class="plug-ip">192.168.255.133<br/>00488B540200427</span>
+            <div class="plug-actions">
+              <button class="action-btn">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/>
+                </svg>
+              </button>
+              <button class="action-btn">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
+                </svg>
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <div class="note">3 rows total: Row 1-2 for 4x AMS, Row 3 for 2x HT + 2x Ext (half-size)</div>
+      </div>
+    </div>
+  </div>
+</body>
+</html>

+ 8 - 2
frontend/src/api/client.ts

@@ -21,7 +21,7 @@ async function request<T>(
     throw new Error(message);
   }
 
-  return response.json();
+  return await response.json();
 }
 
 // Printer types
@@ -55,7 +55,8 @@ export interface AMSTray {
   tray_id_name: string | null;  // Bambu filament ID like "A00-Y2" (can decode to color)
   tray_info_idx: string | null;  // Filament preset ID like "GFA00" - maps to cloud setting_id
   remain: number;
-  k: number | null;  // Pressure advance value
+  k: number | null;  // Pressure advance value (from tray or K-profile lookup)
+  cali_idx: number | null;  // Calibration index for K-profile lookup
   tag_uid: string | null;  // RFID tag UID (any tag)
   tray_uuid: string | null;  // Bambu Lab spool UUID (32-char hex, only valid for Bambu Lab spools)
   nozzle_temp_min: number | null;  // Min nozzle temperature
@@ -1764,6 +1765,11 @@ export const api = {
     request<FieldDefinitionsResponse>(`/cloud/fields/${presetType}`),
   getAllCloudFields: () =>
     request<Record<string, FieldDefinitionsResponse>>('/cloud/fields'),
+  getFilamentInfo: (settingIds: string[]) =>
+    request<Record<string, { name: string; k: number | null }>>('/cloud/filament-info', {
+      method: 'POST',
+      body: JSON.stringify(settingIds),
+    }),
 
   // Smart Plugs
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),

+ 267 - 0
frontend/src/components/FilamentHoverCard.tsx

@@ -0,0 +1,267 @@
+import { useState, useRef, useEffect, type ReactNode } from 'react';
+import { Droplets } from 'lucide-react';
+
+interface FilamentData {
+  vendor: 'Bambu Lab' | 'Generic';
+  profile: string;
+  colorName: string;
+  colorHex: string | null;
+  kFactor: string;
+  fillLevel: number | null; // null = unknown
+}
+
+interface FilamentHoverCardProps {
+  data: FilamentData;
+  children: ReactNode;
+  disabled?: boolean;
+  className?: string;
+}
+
+/**
+ * A hover card that displays filament details when hovering over AMS slots.
+ * Replaces the basic browser tooltip with a styled popover.
+ */
+export function FilamentHoverCard({ data, children, disabled, className = '' }: FilamentHoverCardProps) {
+  const [isVisible, setIsVisible] = useState(false);
+  const [position, setPosition] = useState<'top' | 'bottom'>('top');
+  const triggerRef = useRef<HTMLDivElement>(null);
+  const cardRef = useRef<HTMLDivElement>(null);
+  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  // Calculate position when showing
+  useEffect(() => {
+    if (isVisible && triggerRef.current && cardRef.current) {
+      const triggerRect = triggerRef.current.getBoundingClientRect();
+      const cardHeight = cardRef.current.offsetHeight;
+      const spaceAbove = triggerRect.top;
+      const spaceBelow = window.innerHeight - triggerRect.bottom;
+
+      // Prefer top, but flip to bottom if not enough space
+      if (spaceAbove < cardHeight + 12 && spaceBelow > spaceAbove) {
+        setPosition('bottom');
+      } else {
+        setPosition('top');
+      }
+    }
+  }, [isVisible]);
+
+  const handleMouseEnter = () => {
+    if (disabled) return;
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    // Small delay to prevent flicker on quick mouse movements
+    timeoutRef.current = setTimeout(() => setIsVisible(true), 80);
+  };
+
+  const handleMouseLeave = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
+  };
+
+  // Cleanup timeout on unmount
+  useEffect(() => {
+    return () => {
+      if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    };
+  }, []);
+
+  // Get fill bar color based on percentage
+  const getFillColor = (fill: number): string => {
+    if (fill <= 15) return '#ef4444'; // red
+    if (fill <= 30) return '#f97316'; // orange
+    if (fill <= 50) return '#eab308'; // yellow
+    return '#22c55e'; // green
+  };
+
+  // Determine if color is light (for text contrast on swatch)
+  const isLightColor = (hex: string | null): boolean => {
+    if (!hex) return false;
+    const cleanHex = hex.replace('#', '');
+    const r = parseInt(cleanHex.slice(0, 2), 16);
+    const g = parseInt(cleanHex.slice(2, 4), 16);
+    const b = parseInt(cleanHex.slice(4, 6), 16);
+    const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+    return luminance > 0.6;
+  };
+
+  const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;
+
+  return (
+    <div
+      ref={triggerRef}
+      className={`relative ${className}`}
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
+    >
+      {children}
+
+      {/* Hover Card */}
+      {isVisible && (
+        <div
+          ref={cardRef}
+          className={`
+            absolute left-1/2 -translate-x-1/2 z-50
+            ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}
+            animate-in fade-in-0 zoom-in-95 duration-150
+          `}
+          style={{
+            // Ensure card doesn't go off-screen horizontally
+            maxWidth: 'calc(100vw - 24px)',
+          }}
+        >
+          {/* Card container */}
+          <div className="
+            w-52 bg-bambu-dark-secondary border border-bambu-dark-tertiary
+            rounded-lg shadow-xl overflow-hidden
+            backdrop-blur-sm
+          ">
+            {/* Color swatch header - the hero element */}
+            <div
+              className="h-12 relative overflow-hidden"
+              style={{
+                backgroundColor: colorHex || '#3d3d3d',
+              }}
+            >
+              {/* Subtle gradient overlay for depth */}
+              <div className="absolute inset-0 bg-gradient-to-b from-white/10 to-transparent" />
+
+              {/* Color name on swatch */}
+              <div className={`
+                absolute inset-0 flex items-center justify-center
+                font-semibold text-sm tracking-wide
+                ${isLightColor(colorHex) ? 'text-black/80' : 'text-white/90'}
+              `}>
+                {data.colorName}
+              </div>
+
+              {/* Vendor badge - solid background for visibility on any color */}
+              <div className={`
+                absolute top-1.5 right-1.5 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider
+                ${data.vendor === 'Bambu Lab'
+                  ? 'bg-black/60 text-white'
+                  : 'bg-black/50 text-white/90'}
+              `}>
+                {data.vendor === 'Bambu Lab' ? 'BBL' : 'GEN'}
+              </div>
+            </div>
+
+            {/* Details section */}
+            <div className="p-3 space-y-2.5">
+              {/* Profile name */}
+              <div className="flex items-center justify-between">
+                <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
+                  Profile
+                </span>
+                <span className="text-xs text-white font-semibold truncate max-w-[120px]">
+                  {data.profile}
+                </span>
+              </div>
+
+              {/* K Factor */}
+              <div className="flex items-center justify-between">
+                <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
+                  K Factor
+                </span>
+                <span className="text-xs text-bambu-green font-mono font-bold">
+                  {data.kFactor}
+                </span>
+              </div>
+
+              {/* Fill Level */}
+              <div className="space-y-1">
+                <div className="flex items-center justify-between">
+                  <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium flex items-center gap-1">
+                    <Droplets className="w-3 h-3" />
+                    Fill
+                  </span>
+                  <span className="text-xs text-white font-semibold">
+                    {data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
+                  </span>
+                </div>
+                {/* Fill bar */}
+                <div className="h-1.5 bg-black/40 rounded-full overflow-hidden">
+                  {data.fillLevel !== null ? (
+                    <div
+                      className="h-full rounded-full transition-all duration-300"
+                      style={{
+                        width: `${data.fillLevel}%`,
+                        backgroundColor: getFillColor(data.fillLevel),
+                      }}
+                    />
+                  ) : (
+                    <div className="h-full w-full bg-bambu-gray/30 rounded-full" />
+                  )}
+                </div>
+              </div>
+            </div>
+          </div>
+
+          {/* Arrow pointer */}
+          <div
+            className={`
+              absolute left-1/2 -translate-x-1/2 w-0 h-0
+              border-l-[6px] border-l-transparent
+              border-r-[6px] border-r-transparent
+              ${position === 'top'
+                ? 'top-full border-t-[6px] border-t-bambu-dark-tertiary'
+                : 'bottom-full border-b-[6px] border-b-bambu-dark-tertiary'}
+            `}
+          />
+        </div>
+      )}
+    </div>
+  );
+}
+
+/**
+ * Wrapper for empty slots - just shows "Empty" on hover
+ */
+export function EmptySlotHoverCard({ children, className = '' }: { children: ReactNode; className?: string }) {
+  const [isVisible, setIsVisible] = useState(false);
+  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  const handleMouseEnter = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    timeoutRef.current = setTimeout(() => setIsVisible(true), 80);
+  };
+
+  const handleMouseLeave = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    timeoutRef.current = setTimeout(() => setIsVisible(false), 100);
+  };
+
+  useEffect(() => {
+    return () => {
+      if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    };
+  }, []);
+
+  return (
+    <div
+      className={`relative ${className}`}
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
+    >
+      {children}
+
+      {isVisible && (
+        <div className="
+          absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-50
+          animate-in fade-in-0 zoom-in-95 duration-150
+        ">
+          <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
+          ">
+            Empty slot
+          </div>
+          <div className="
+            absolute left-1/2 -translate-x-1/2 top-full w-0 h-0
+            border-l-[5px] border-l-transparent
+            border-r-[5px] border-r-transparent
+            border-t-[5px] border-t-bambu-dark-tertiary
+          " />
+        </div>
+      )}
+    </div>
+  );
+}

+ 513 - 209
frontend/src/pages/PrintersPage.tsx

@@ -40,6 +40,148 @@ import { MQTTDebugModal } from '../components/MQTTDebugModal';
 import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
+import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
+
+// Bambu Lab color code mapping (color suffix from tray_id_name -> color name)
+// tray_id_name format: "A00-Y2" where Y2 is the color code
+const BAMBU_COLOR_CODES: Record<string, string> = {
+  // Yellows
+  'Y0': 'Yellow',
+  'Y1': 'Savana Yellow',
+  'Y2': 'Sunflower Yellow',
+  'Y3': 'Lemon Yellow',
+  // Oranges
+  'O0': 'Orange',
+  'O1': 'Mandarin Orange',
+  'O2': 'Coral Orange',
+  // Reds
+  'R0': 'Red',
+  'R1': 'Scarlet Red',
+  'R2': 'Magenta',
+  'R3': 'Sakura Pink',
+  'R4': 'Raspberry Red',
+  // Pinks
+  'P0': 'Pink',
+  'P1': 'Sakura Pink',
+  // Purples
+  'V0': 'Purple',
+  'V1': 'Violet',
+  'V2': 'Lilac Purple',
+  // Blues
+  'B0': 'Blue',
+  'B1': 'Sky Blue',
+  'B2': 'Navy Blue',
+  'B3': 'Ice Blue',
+  'B4': 'Cyan',
+  // Greens
+  'G0': 'Green',
+  'G1': 'Grass Green',
+  'G2': 'Lime Green',
+  'G3': 'Mint Green',
+  'G4': 'Olive Green',
+  'G5': 'Jungle Green',
+  'G6': 'Bambu Green',
+  // Browns
+  'N0': 'Brown',
+  'N1': 'Peanut Brown',
+  'N2': 'Coffee Brown',
+  'N3': 'Caramel Brown',
+  // Grays
+  'A0': 'Gray',
+  'A1': 'Charcoal Gray',
+  'A2': 'Silver Gray',
+  'A3': 'Titan Gray',
+  // Blacks
+  'K0': 'Black',
+  'K1': 'Black',
+  // Whites
+  'W0': 'White',
+  'W1': 'Jade White',
+  'W2': 'Ivory White',
+  // Special
+  'T0': 'Transparent',
+  'C0': 'Marble',
+  'X0': 'Bronze',
+  'X1': 'Gold',
+  'X2': 'Silver',
+};
+
+// Get color name from Bambu Lab tray_id_name (e.g., "A00-Y2" -> "Sunflower Yellow")
+function getBambuColorName(trayIdName: string | null | undefined): string | null {
+  if (!trayIdName) return null;
+  // Extract color code after the dash (e.g., "A00-Y2" -> "Y2")
+  const parts = trayIdName.split('-');
+  if (parts.length < 2) return null;
+  const colorCode = parts[1];
+  return BAMBU_COLOR_CODES[colorCode] || null;
+}
+
+// Convert hex color to basic color name
+function hexToBasicColorName(hex: string | null | undefined): string {
+  if (!hex || hex.length < 6) return 'Unknown';
+
+  // Parse RGB from hex (format: RRGGBBAA or RRGGBB)
+  const r = parseInt(hex.substring(0, 2), 16);
+  const g = parseInt(hex.substring(2, 4), 16);
+  const b = parseInt(hex.substring(4, 6), 16);
+
+  // Calculate HSL for better color classification
+  const max = Math.max(r, g, b) / 255;
+  const min = Math.min(r, g, b) / 255;
+  const l = (max + min) / 2;
+
+  let h = 0;
+  let s = 0;
+
+  if (max !== min) {
+    const d = max - min;
+    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+
+    const rNorm = r / 255;
+    const gNorm = g / 255;
+    const bNorm = b / 255;
+
+    if (max === rNorm) {
+      h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
+    } else if (max === gNorm) {
+      h = ((bNorm - rNorm) / d + 2) / 6;
+    } else {
+      h = ((rNorm - gNorm) / d + 4) / 6;
+    }
+  }
+
+  // Convert to degrees
+  h = h * 360;
+
+  // Classify by lightness first
+  if (l < 0.15) return 'Black';
+  if (l > 0.85) return 'White';
+
+  // Low saturation = gray
+  if (s < 0.15) {
+    if (l < 0.4) return 'Dark Gray';
+    if (l > 0.6) return 'Light Gray';
+    return 'Gray';
+  }
+
+  // Classify by hue
+  if (h < 15 || h >= 345) return 'Red';
+  if (h < 45) return 'Orange';
+  if (h < 70) return 'Yellow';
+  if (h < 150) return 'Green';
+  if (h < 200) return 'Cyan';
+  if (h < 260) return 'Blue';
+  if (h < 290) return 'Purple';
+  if (h < 345) return 'Pink';
+
+  return 'Unknown';
+}
+
+// Format K value with 3 decimal places, default to 0.020 if null
+function formatKValue(k: number | null | undefined): string {
+  const value = k ?? 0.020;
+  return value.toFixed(3);
+}
 
 // Nozzle side indicators (Bambu Lab style - square badge with L/R)
 function NozzleBadge({ side }: { side: 'L' | 'R' }) {
@@ -56,88 +198,6 @@ function NozzleBadge({ side }: { side: 'L' | 'R' }) {
   );
 }
 
-// AMS 4-tray device icon with fillable colored spool slots (Bambu Studio style)
-interface AMS4TrayIconProps {
-  colors: (string | null)[]; // Array of 4 colors (hex) or null for empty
-  className?: string;
-}
-
-function AMS4TrayIcon({ colors, className }: AMS4TrayIconProps) {
-  // Spool positions: x start, centered at 12.5, 21.5, 30.5, 39.5
-  // Each spool slot is 6 units wide (from 9.5-15.5, 18.5-24.5, etc.)
-  const spoolSlots = [
-    { x: 9.5, cx: 12.5 },
-    { x: 18.5, cx: 21.5 },
-    { x: 27.5, cx: 30.5 },
-    { x: 36.5, cx: 39.5 },
-  ];
-
-  return (
-    <svg className={className} width="56" height="34" viewBox="0 0 52 32" fill="none" xmlns="http://www.w3.org/2000/svg">
-      {/* Outer casing with window */}
-      <path
-        fillRule="evenodd"
-        clipRule="evenodd"
-        d="M4 0C1.79086 0 0 1.79086 0 4V28C0 30.2091 1.79086 32 4 32H48C50.2091 32 52 30.2091 52 28V4C52 1.79086 50.2091 0 48 0H4ZM44 8H8V24H44V8Z"
-        fill="#2F2E33"
-      />
-      {/* Spool color fills - rectangles that fill the visible window area */}
-      {spoolSlots.map((slot, i) => (
-        colors[i] ? (
-          <rect key={i} x={slot.x} y="8" width="6" height="16" fill={colors[i]!} />
-        ) : (
-          <g key={i}>
-            <rect x={slot.x} y="8" width="6" height="16" fill="#ffffff" />
-            <line x1={slot.x} y1="8" x2={slot.x + 6} y2="24" stroke="#555555" strokeWidth="1.5" />
-          </g>
-        )
-      ))}
-      {/* Bottom half overlay (spool holders - creates rounded bottom edges) */}
-      <path
-        fillRule="evenodd"
-        clipRule="evenodd"
-        d="M36.5 16H33.5V18.2617C33.5 19.9186 32.1569 21.2617 30.5 21.2617C28.8431 21.2617 27.5 19.9186 27.5 18.2617V16H24.5V18.2617C24.5 19.9186 23.1569 21.2617 21.5 21.2617C19.8431 21.2617 18.5 19.9186 18.5 18.2617V16H15.5V18.2617C15.5 19.9186 14.1569 21.2617 12.5 21.2617C10.8432 21.2617 9.5 19.9186 9.5 18.2617V16H4V28H48V16H42.5V18.2617C42.5 19.9186 41.1569 21.2617 39.5 21.2617C37.8431 21.2617 36.5 19.9186 36.5 18.2617V16Z"
-        fill="#767676"
-      />
-      {/* Top half overlay (spool tops - creates rounded top edges) */}
-      <path
-        fillRule="evenodd"
-        clipRule="evenodd"
-        d="M6 9.18382C6 6.32088 8.32088 4 11.1838 4H40.8162C43.6791 4 46 6.32088 46 9.18382V16H42.5V12.2617C42.5 10.6049 41.1569 9.26172 39.5 9.26172C37.8431 9.26172 36.5 10.6049 36.5 12.2617V16H33.5V12.2617C33.5 10.6049 32.1569 9.26172 30.5 9.26172C28.8431 9.26172 27.5 10.6049 27.5 12.2617V16H24.5V12.2617C24.5 10.6049 23.1569 9.26172 21.5 9.26172C19.8431 9.26172 18.5 10.6049 18.5 12.2617V16H15.5V12.2617C15.5 10.6049 14.1569 9.26172 12.5 9.26172C10.8432 9.26172 9.5 10.6049 9.5 12.2617V16H6V9.18382Z"
-        fill="#BFBFBF"
-      />
-    </svg>
-  );
-}
-
-// AMS 1-tray device icon (AMS-HT) with fillable colored slot (Bambu Studio style)
-interface AMS1TrayIconProps {
-  color: string | null; // Hex color or null for empty
-  className?: string;
-}
-
-function AMS1TrayIcon({ color, className }: AMS1TrayIconProps) {
-  return (
-    <svg className={className} width="56" height="56" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
-      {/* Filament color fill */}
-      {color ? (
-        <rect x="8.3" y="5.2" width="3.8" height="5.1" fill={color} rx="0.3"/>
-      ) : (
-        <g>
-          <rect x="8.3" y="5.2" width="3.8" height="5.1" fill="#ffffff" rx="0.3"/>
-          <line x1="8.3" y1="5.2" x2="12.1" y2="10.3" stroke="#555555" strokeWidth="0.8" />
-        </g>
-      )}
-      {/* Device outline - top housing */}
-      <path d="M5.88312 4.68555C5.88312 4.13326 6.33083 3.68555 6.88312 3.68555H13.5059C14.0582 3.68555 14.5059 4.13326 14.5059 4.68555V10.3887H5.88312V4.68555Z" stroke="#6B6B6B"/>
-      {/* Bottom base */}
-      <rect x="3.8725" y="10.3887" width="12.7037" height="7.55371" rx="1.2" stroke="#6B6B6B"/>
-      {/* Inner tray outline */}
-      <path d="M8.21991 5.65234C8.21991 5.3762 8.44377 5.15234 8.71991 5.15234H11.7288C12.005 5.15234 12.2288 5.3762 12.2288 5.65234V10.3887H8.21991V5.65234Z" stroke="#6B6B6B"/>
-    </svg>
-  );
-}
-
 // Water drop SVG - empty outline (Bambu Lab style from bambu-humidity)
 function WaterDropEmpty({ className }: { className?: string }) {
   return (
@@ -247,9 +307,10 @@ interface HumidityIndicatorProps {
   goodThreshold?: number;  // <= this is green
   fairThreshold?: number;  // <= this is orange, > is red
   onClick?: () => void;
+  compact?: boolean;  // Smaller version for grid layout
 }
 
-function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60, onClick }: HumidityIndicatorProps) {
+function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60, onClick, compact }: HumidityIndicatorProps) {
   const humidityValue = typeof humidity === 'string' ? parseInt(humidity, 10) : humidity;
   const good = typeof goodThreshold === 'number' ? goodThreshold : 40;
   const fair = typeof fairThreshold === 'number' ? fairThreshold : 60;
@@ -289,11 +350,11 @@ function HumidityIndicator({ humidity, goodThreshold = 40, fairThreshold = 60, o
     <button
       type="button"
       onClick={onClick}
-      className={`flex items-center justify-end gap-1 ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
+      className={`flex items-center gap-1 ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
       title={`Humidity: ${humidityValue}% - ${statusText}${onClick ? ' (click for history)' : ''}`}
     >
-      <DropComponent className="w-3 h-4" />
-      <span className="text-xs font-medium tabular-nums w-8 text-right" style={{ color: textColor }}>{humidityValue}%</span>
+      <DropComponent className={compact ? "w-2.5 h-3" : "w-3 h-4"} />
+      <span className={`font-medium tabular-nums ${compact ? 'text-[10px]' : 'text-xs'}`} style={{ color: textColor }}>{humidityValue}%</span>
     </button>
   );
 }
@@ -304,9 +365,10 @@ interface TemperatureIndicatorProps {
   goodThreshold?: number;  // <= this is blue
   fairThreshold?: number;  // <= this is orange, > is red
   onClick?: () => void;
+  compact?: boolean;  // Smaller version for grid layout
 }
 
-function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, onClick }: TemperatureIndicatorProps) {
+function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, onClick, compact }: TemperatureIndicatorProps) {
   // Ensure thresholds are numbers
   const good = typeof goodThreshold === 'number' ? goodThreshold : 28;
   const fair = typeof fairThreshold === 'number' ? fairThreshold : 35;
@@ -336,8 +398,8 @@ function TemperatureIndicator({ temp, goodThreshold = 28, fairThreshold = 35, on
       className={`flex items-center gap-1 ${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
       title={`Temperature: ${temp}°C - ${statusText}${onClick ? ' (click for history)' : ''}`}
     >
-      <ThermoComponent className="w-3 h-4" />
-      <span className="tabular-nums w-12 text-right" style={{ color: textColor }}>{temp}°C</span>
+      <ThermoComponent className={compact ? "w-2.5 h-3" : "w-3 h-4"} />
+      <span className={`tabular-nums text-right ${compact ? 'text-[10px] w-8' : 'w-12'}`} style={{ color: textColor }}>{temp}°C</span>
     </button>
   );
 }
@@ -356,6 +418,13 @@ function getAmsLabel(amsId: number | string, trayCount: number): string {
   return isHt ? `HT-${letter}` : `AMS-${letter}`;
 }
 
+// Get fill bar color based on spool fill level
+function getFillBarColor(fillLevel: number): string {
+  if (fillLevel > 50) return '#00ae42'; // Green - good
+  if (fillLevel >= 15) return '#f59e0b'; // Amber - warning (<= 50%)
+  return '#ef4444'; // Red - critical (< 15%)
+}
+
 function formatTime(seconds: number): string {
   const hours = Math.floor(seconds / 3600);
   const minutes = Math.floor((seconds % 3600) / 60);
@@ -615,6 +684,32 @@ function PrinterCard({
     refetchInterval: 30000, // Fallback polling, WebSocket handles real-time
   });
 
+  // Collect unique tray_info_idx values for cloud filament info lookup
+  const trayInfoIds = useMemo(() => {
+    const ids = new Set<string>();
+    if (status?.ams) {
+      for (const ams of status.ams) {
+        for (const tray of ams.tray || []) {
+          if (tray.tray_info_idx) {
+            ids.add(tray.tray_info_idx);
+          }
+        }
+      }
+    }
+    if (status?.vt_tray?.tray_info_idx) {
+      ids.add(status.vt_tray.tray_info_idx);
+    }
+    return Array.from(ids);
+  }, [status?.ams, status?.vt_tray]);
+
+  // Fetch cloud filament info for tooltips (name includes color, also has K value)
+  const { data: filamentInfo } = useQuery({
+    queryKey: ['filamentInfo', trayInfoIds],
+    queryFn: () => api.getFilamentInfo(trayInfoIds),
+    enabled: trayInfoIds.length > 0,
+    staleTime: 5 * 60 * 1000, // 5 minutes
+  });
+
   // Cache WiFi signal to prevent it disappearing on updates
   const [cachedWifiSignal, setCachedWifiSignal] = useState<number | null>(null);
   useEffect(() => {
@@ -653,6 +748,19 @@ function PrinterCard({
   }, [status?.ams]);
   const amsData = (status?.ams && status.ams.length > 0) ? status.ams : cachedAmsData.current;
 
+  // Cache tray_now to prevent flickering when 255 (unloaded) or undefined values come in
+  // Only update cache when we get a valid tray ID (0-253 or 254 for external)
+  const cachedTrayNow = useRef<number>(255);
+  const currentTrayNow = status?.tray_now;
+  // Update cache synchronously during render if we have a valid value
+  if (currentTrayNow !== undefined && currentTrayNow !== 255) {
+    cachedTrayNow.current = currentTrayNow;
+  }
+  // Use cached value if current is 255/undefined but we had a valid value before
+  const effectiveTrayNow = (currentTrayNow === undefined || currentTrayNow === 255)
+    ? cachedTrayNow.current
+    : currentTrayNow;
+
   // Fetch smart plug for this printer
   const { data: smartPlug } = useQuery({
     queryKey: ['smartPlugByPrinter', printer.id],
@@ -1135,132 +1243,328 @@ function PrinterCard({
               );
             })()}
 
-            {/* AMS Units with Device Icons, Humidity & Temperature */}
-            {amsData && amsData.length > 0 && viewMode === 'expanded' && (
-              <div className="mt-3 space-y-2">
-                {amsData.map((ams) => {
-                  // For dual nozzle printers, determine which nozzle this AMS is connected to
-                  // Use actual ams.id for map lookup (map uses real IDs: 0-3 for AMS, 128+ for AMS-HT)
-                  const mappedExtruderId = amsExtruderMap[String(ams.id)];
-                  // Fallback: normalize ID for conventional mapping (0=R, 1=L)
-                  const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id;
-                  const extruderId = mappedExtruderId !== undefined
-                    ? mappedExtruderId
-                    : normalizedId; // Fallback: AMS 0 → extruder 0 (R), AMS 1 → extruder 1 (L)
-                  // Use printer.nozzle_count as primary source (stable), fallback to nozzle_2 temp
-                  const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
-                  // extruder 0 = Right, extruder 1 = Left
-                  const isLeftNozzle = extruderId === 1;
-                  const isRightNozzle = extruderId === 0;
-
-                  // Get colors for the AMS icon (null for empty slots)
-                  const slotColors = ams.tray.map(tray =>
-                    tray.tray_color ? `#${tray.tray_color}` : (tray.tray_type ? '#333' : null)
-                  );
-                  const isHtAms = ams.tray.length === 1;
-
-                  return (
-                    <div key={ams.id} className="p-2 bg-bambu-dark rounded-lg">
-                      <div className="flex flex-wrap items-center gap-2 sm:gap-3">
-                        {/* Nozzle badge + AMS device icon */}
-                        <div className="flex items-center gap-1 flex-shrink-0">
-                          {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
-                            <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
-                          )}
-                          {isHtAms ? (
-                            <AMS1TrayIcon
-                              color={slotColors[0]}
-                              className="flex-shrink-0"
-                            />
-                          ) : (
-                            <AMS4TrayIcon
-                              colors={slotColors as (string | null)[]}
-                              className="flex-shrink-0"
-                            />
-                          )}
-                        </div>
-
-                        {/* Label and filament info */}
-                        <div className="flex-1 min-w-0">
-                          <span className="text-xs text-bambu-gray font-medium">
-                            {getAmsLabel(ams.id, ams.tray.length)}
-                          </span>
-                          {/* Filament types and fill levels */}
-                          <div className="mt-0.5 text-[10px] flex items-start">
-                            {ams.tray.map((tray, i) => (
-                              <div key={i} className="flex items-start">
-                                <div className="flex flex-col">
-                                  <span className="text-bambu-gray/70 truncate max-w-[60px] sm:max-w-none">
-                                    {tray.tray_type ? (tray.tray_sub_brands || tray.tray_type) : '—'}
-                                  </span>
-                                  <span className="text-bambu-gray/50 truncate">
-                                    {tray.tray_type && tray.remain >= 0 ? `${tray.remain}%` : '—'}
-                                  </span>
-                                </div>
-                                {i < ams.tray.length - 1 && (
-                                  <span className="text-bambu-gray/50 mx-1 flex flex-col">
-                                    <span>·</span>
-                                    <span>·</span>
-                                  </span>
+            {/* AMS Units - 2-Column Grid Layout */}
+            {amsData && amsData.length > 0 && viewMode === 'expanded' && (() => {
+              // Separate regular AMS (4-tray) from HT AMS (1-tray)
+              const regularAms = amsData.filter(ams => ams.tray.length > 1);
+              const htAms = amsData.filter(ams => ams.tray.length === 1);
+              const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
+
+              return (
+                <div className="mt-4 pt-3 border-t border-bambu-dark-tertiary/50">
+                  {/* Section Header */}
+                  <div className="flex items-center gap-2 mb-3">
+                    <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
+                      Filaments
+                    </span>
+                    <div className="flex-1 h-px bg-bambu-dark-tertiary/30" />
+                  </div>
+
+                  {/* AMS Content */}
+                  <div className="space-y-3">
+                    {/* Row 1-2: Regular AMS (4-tray) in 2-column grid */}
+                    {regularAms.length > 0 && (
+                      <div className="grid grid-cols-2 gap-3">
+                        {regularAms.map((ams) => {
+                        const mappedExtruderId = amsExtruderMap[String(ams.id)];
+                        const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id;
+                        const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
+                        const isLeftNozzle = extruderId === 1;
+                        const isRightNozzle = extruderId === 0;
+
+                        return (
+                          <div key={ams.id} className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
+                            {/* Header: Label + Stats (no icon) */}
+                            <div className="flex items-center justify-between mb-2">
+                              <div className="flex items-center gap-1.5">
+                                <span className="text-[10px] text-white font-medium">
+                                  {getAmsLabel(ams.id, ams.tray.length)}
+                                </span>
+                                {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
+                                  <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
                                 )}
                               </div>
-                            ))}
-                          </div>
-                        </div>
-                        {/* Humidity/temp - responsive positioning */}
-                        {(ams.humidity != null || ams.temp != null) && (
-                          <div className="flex items-center gap-2 text-xs flex-shrink-0 ml-auto">
-                            {ams.humidity != null && (
-                              <HumidityIndicator
-                                humidity={ams.humidity}
-                                goodThreshold={amsThresholds?.humidityGood}
-                                fairThreshold={amsThresholds?.humidityFair}
-                                onClick={() => setAmsHistoryModal({
-                                  amsId: ams.id,
-                                  amsLabel: getAmsLabel(ams.id, ams.tray.length),
-                                  mode: 'humidity',
-                                })}
-                              />
-                            )}
-                            {ams.temp != null && (
-                              <TemperatureIndicator
-                                temp={ams.temp}
-                                goodThreshold={amsThresholds?.tempGood}
-                                fairThreshold={amsThresholds?.tempFair}
-                                onClick={() => setAmsHistoryModal({
-                                  amsId: ams.id,
-                                  amsLabel: getAmsLabel(ams.id, ams.tray.length),
-                                  mode: 'temperature',
-                                })}
-                              />
-                            )}
+                              {(ams.humidity != null || ams.temp != null) && (
+                                <div className="flex items-center gap-1.5">
+                                  {ams.humidity != null && (
+                                    <HumidityIndicator
+                                      humidity={ams.humidity}
+                                      goodThreshold={amsThresholds?.humidityGood}
+                                      fairThreshold={amsThresholds?.humidityFair}
+                                      onClick={() => setAmsHistoryModal({
+                                        amsId: ams.id,
+                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                        mode: 'humidity',
+                                      })}
+                                      compact
+                                    />
+                                  )}
+                                  {ams.temp != null && (
+                                    <TemperatureIndicator
+                                      temp={ams.temp}
+                                      goodThreshold={amsThresholds?.tempGood}
+                                      fairThreshold={amsThresholds?.tempFair}
+                                      onClick={() => setAmsHistoryModal({
+                                        amsId: ams.id,
+                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                        mode: 'temperature',
+                                      })}
+                                      compact
+                                    />
+                                  )}
+                                </div>
+                              )}
+                            </div>
+                            {/* Slots grid: 4 columns - always render 4 slots */}
+                            <div className="grid grid-cols-4 gap-1.5">
+                              {[0, 1, 2, 3].map((slotIdx) => {
+                                // Find tray data for this slot (may be undefined if data incomplete)
+                                // Use array index if available, as tray.id may not always be set
+                                const tray = ams.tray[slotIdx] || ams.tray.find(t => t.id === slotIdx);
+                                const hasFillLevel = tray?.tray_type && tray.remain >= 0;
+                                const isEmpty = !tray?.tray_type;
+                                // Check if this is the currently loaded tray
+                                // Global tray ID = ams.id * 4 + slot index (for standard AMS)
+                                const globalTrayId = ams.id * 4 + slotIdx;
+                                const isActive = effectiveTrayNow === globalTrayId;
+                                // Get cloud preset info if available
+                                const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
+
+                                // Build filament data for hover card
+                                const filamentData = tray?.tray_type ? {
+                                  vendor: (tray.tray_uuid ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
+                                  profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
+                                  colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
+                                  colorHex: tray.tray_color || null,
+                                  kFactor: formatKValue(tray.k),
+                                  fillLevel: hasFillLevel ? tray.remain : null,
+                                } : null;
+
+                                const slotContent = (
+                                  <div
+                                    className={`bg-bambu-dark-tertiary rounded p-1 text-center cursor-default ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
+                                  >
+                                    <div
+                                      className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
+                                      style={{
+                                        backgroundColor: tray?.tray_color ? `#${tray.tray_color}` : (tray?.tray_type ? '#333' : 'transparent'),
+                                        borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
+                                        borderStyle: isEmpty ? 'dashed' : 'solid',
+                                      }}
+                                    />
+                                    <div className="text-[9px] text-white font-bold truncate">
+                                      {tray?.tray_type || '—'}
+                                    </div>
+                                    {/* Fill bar */}
+                                    <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
+                                      {hasFillLevel && tray ? (
+                                        <div
+                                          className="h-full rounded-full transition-all"
+                                          style={{
+                                            width: `${tray.remain}%`,
+                                            backgroundColor: getFillBarColor(tray.remain),
+                                          }}
+                                        />
+                                      ) : tray?.tray_type ? (
+                                        <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
+                                      ) : null}
+                                    </div>
+                                  </div>
+                                );
+
+                                return filamentData ? (
+                                  <FilamentHoverCard key={slotIdx} data={filamentData}>
+                                    {slotContent}
+                                  </FilamentHoverCard>
+                                ) : (
+                                  <EmptySlotHoverCard key={slotIdx}>
+                                    {slotContent}
+                                  </EmptySlotHoverCard>
+                                );
+                              })}
+                            </div>
                           </div>
-                        )}
-                      </div>
+                        );
+                      })}
                     </div>
-                  );
-                })}
-                {/* External spool indicator */}
-                {status.vt_tray && status.vt_tray.tray_type && (
-                  <div className="p-2 bg-bambu-dark rounded-lg">
-                    <div className="flex items-center gap-3">
-                      <div
-                        className="w-10 h-10 rounded-full border-2 border-white/20 flex-shrink-0"
-                        style={{
-                          backgroundColor: status.vt_tray.tray_color ? `#${status.vt_tray.tray_color}` : '#333',
-                        }}
-                      />
-                      <div>
-                        <span className="text-xs text-bambu-gray font-medium">External</span>
-                        <p className="text-[10px] text-bambu-gray/70">
-                          {status.vt_tray.tray_sub_brands || status.vt_tray.tray_type || 'Spool'}
-                        </p>
+                  )}
+
+                    {/* Row 3: HT AMS + External spools (same style as regular AMS, 4 across) */}
+                    {(htAms.length > 0 || (status.vt_tray && status.vt_tray.tray_type)) && (
+                      <div className="grid grid-cols-4 gap-3">
+                      {/* HT AMS units - name/badge top, slot left, stats right */}
+                      {htAms.map((ams) => {
+                        const mappedExtruderId = amsExtruderMap[String(ams.id)];
+                        const normalizedId = ams.id >= 128 ? ams.id - 128 : ams.id;
+                        const extruderId = mappedExtruderId !== undefined ? mappedExtruderId : normalizedId;
+                        const isLeftNozzle = extruderId === 1;
+                        const isRightNozzle = extruderId === 0;
+                        const tray = ams.tray[0];
+                        const hasFillLevel = tray?.tray_type && tray.remain >= 0;
+                        const isEmpty = !tray?.tray_type;
+                        // Check if this is the currently loaded tray
+                        // Global tray ID = ams.id * 4 + tray.id
+                        const globalTrayId = ams.id * 4 + (tray?.id ?? 0);
+                        const isActive = effectiveTrayNow === globalTrayId;
+                        // Get cloud preset info if available
+                        const cloudInfo = tray?.tray_info_idx ? filamentInfo?.[tray.tray_info_idx] : null;
+
+                        // Build filament data for hover card
+                        const filamentData = tray?.tray_type ? {
+                          vendor: (tray.tray_uuid ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
+                          profile: cloudInfo?.name || tray.tray_sub_brands || tray.tray_type,
+                          colorName: getBambuColorName(tray.tray_id_name) || hexToBasicColorName(tray.tray_color),
+                          colorHex: tray.tray_color || null,
+                          kFactor: formatKValue(tray.k),
+                          fillLevel: hasFillLevel ? tray.remain : null,
+                        } : null;
+
+                        const slotContent = (
+                          <div
+                            className={`bg-bambu-dark-tertiary rounded p-1 text-center cursor-default ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
+                          >
+                            <div
+                              className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
+                              style={{
+                                backgroundColor: tray?.tray_color ? `#${tray.tray_color}` : (tray?.tray_type ? '#333' : 'transparent'),
+                                borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
+                                borderStyle: isEmpty ? 'dashed' : 'solid',
+                              }}
+                            />
+                            <div className="text-[9px] text-white font-bold truncate">
+                              {tray?.tray_type || '—'}
+                            </div>
+                            {/* Fill bar */}
+                            <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
+                              {hasFillLevel ? (
+                                <div
+                                  className="h-full rounded-full transition-all"
+                                  style={{
+                                    width: `${tray.remain}%`,
+                                    backgroundColor: getFillBarColor(tray.remain),
+                                  }}
+                                />
+                              ) : tray?.tray_type ? (
+                                <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
+                              ) : null}
+                            </div>
+                          </div>
+                        );
+
+                        return (
+                          <div key={ams.id} className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
+                            {/* Row 1: Label + Nozzle */}
+                            <div className="flex items-center gap-1 mb-2">
+                              <span className="text-[10px] text-white font-medium">
+                                {getAmsLabel(ams.id, ams.tray.length)}
+                              </span>
+                              {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
+                                <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
+                              )}
+                            </div>
+                            {/* Row 2: Slot (left) + Stats (right stacked) */}
+                            <div className="flex gap-1.5">
+                              {/* Slot - takes remaining width */}
+                              {filamentData ? (
+                                <FilamentHoverCard data={filamentData} className="flex-1">
+                                  {slotContent}
+                                </FilamentHoverCard>
+                              ) : (
+                                <EmptySlotHoverCard className="flex-1">
+                                  {slotContent}
+                                </EmptySlotHoverCard>
+                              )}
+                              {/* Stats stacked vertically: Temp on top, Humidity below */}
+                              {(ams.humidity != null || ams.temp != null) && (
+                                <div className="flex flex-col justify-center gap-1 shrink-0">
+                                  {ams.temp != null && (
+                                    <TemperatureIndicator
+                                      temp={ams.temp}
+                                      goodThreshold={amsThresholds?.tempGood}
+                                      fairThreshold={amsThresholds?.tempFair}
+                                      onClick={() => setAmsHistoryModal({
+                                        amsId: ams.id,
+                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                        mode: 'temperature',
+                                      })}
+                                      compact
+                                    />
+                                  )}
+                                  {ams.humidity != null && (
+                                    <HumidityIndicator
+                                      humidity={ams.humidity}
+                                      goodThreshold={amsThresholds?.humidityGood}
+                                      fairThreshold={amsThresholds?.humidityFair}
+                                      onClick={() => setAmsHistoryModal({
+                                        amsId: ams.id,
+                                        amsLabel: getAmsLabel(ams.id, ams.tray.length),
+                                        mode: 'humidity',
+                                      })}
+                                      compact
+                                    />
+                                  )}
+                                </div>
+                              )}
+                            </div>
+                          </div>
+                        );
+                      })}
+                      {/* External spool - name top, slot below (no stats) */}
+                      {status.vt_tray && status.vt_tray.tray_type && (() => {
+                        const extTray = status.vt_tray;
+                        // Check if external spool is active (tray_now = 254)
+                        const isExtActive = effectiveTrayNow === 254;
+                        // Get cloud preset info if available
+                        const extCloudInfo = extTray.tray_info_idx ? filamentInfo?.[extTray.tray_info_idx] : null;
+
+                        // Build filament data for hover card
+                        const extFilamentData = {
+                          vendor: (extTray.tray_uuid ? 'Bambu Lab' : 'Generic') as 'Bambu Lab' | 'Generic',
+                          profile: extCloudInfo?.name || extTray.tray_sub_brands || extTray.tray_type || 'Unknown',
+                          colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
+                          colorHex: extTray.tray_color || null,
+                          kFactor: formatKValue(extTray.k),
+                          fillLevel: null, // External spool has unknown fill level
+                        };
+
+                        const extSlotContent = (
+                          <div className={`bg-bambu-dark-tertiary rounded p-1 text-center cursor-default ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
+                            <div
+                              className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
+                              style={{
+                                backgroundColor: extTray.tray_color ? `#${extTray.tray_color}` : '#333',
+                                borderColor: isExtActive ? 'var(--accent)' : 'rgba(255,255,255,0.1)',
+                              }}
+                            />
+                            <div className="text-[9px] text-white font-bold truncate">
+                              {extTray.tray_type || 'Spool'}
+                            </div>
+                            {/* Unknown fill level - subtle bar */}
+                            <div className="mt-1 h-1.5 bg-black/30 rounded-full overflow-hidden">
+                              <div className="h-full w-full rounded-full bg-white/50 dark:bg-gray-500/40" />
+                            </div>
+                          </div>
+                        );
+
+                        return (
+                          <div className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
+                            {/* Row 1: Label */}
+                            <div className="flex items-center gap-1 mb-2">
+                              <span className="text-[10px] text-white font-medium">External</span>
+                            </div>
+                            {/* Row 2: Slot (full width since no stats) */}
+                            <FilamentHoverCard data={extFilamentData}>
+                              {extSlotContent}
+                            </FilamentHoverCard>
+                          </div>
+                        );
+                      })()}
                       </div>
-                    </div>
+                    )}
                   </div>
-                )}
-              </div>
-            )}
+                </div>
+              );
+            })()}
           </>
         )}
 

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


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


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


+ 2 - 2
static/index.html

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

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