Browse Source

Feature AMS Info Card (#570)

* AMS Labels addition to PrintersPage

* Added database reinitialization for schema migrations on database restore

* Bug fixes and AMS Label persistence updates

* Update database.py to resolve PR conflicts

* PR Comment Resolution

* Resolve conflicts in database.py for PR#570

* Updates to address PR#570 additional comments

* Optimize visibility handling for popup component

* Improve serial key handling in printers.py

Refactor serial key assignment to handle empty AMS serial gracefully.

* Implement error handling in serial number mapping

Add error handling for serial number mapping.

* Add migration to drop old ams_labels table

---------

Co-authored-by: MartinNYHC <mz@v8w.de>
Thomas Rambach 2 months ago
parent
commit
7ba67012f6

+ 52 - 1
backend/app/api/routes/inventory.py

@@ -13,6 +13,7 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
 from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.models.ams_label import AmsLabel
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.spool import Spool
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_assignment import SpoolAssignment
@@ -638,6 +639,8 @@ async def list_assignments(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
 ):
     """List spool assignments, optionally filtered by printer."""
     """List spool assignments, optionally filtered by printer."""
+    from backend.app.services.printer_manager import printer_manager
+
     query = select(SpoolAssignment).options(
     query = select(SpoolAssignment).options(
         selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
         selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
         selectinload(SpoolAssignment.printer),
         selectinload(SpoolAssignment.printer),
@@ -645,7 +648,55 @@ async def list_assignments(
     if printer_id is not None:
     if printer_id is not None:
         query = query.where(SpoolAssignment.printer_id == printer_id)
         query = query.where(SpoolAssignment.printer_id == printer_id)
     result = await db.execute(query)
     result = await db.execute(query)
-    return list(result.scalars().all())
+    assignments = list(result.scalars().all())
+
+    # Build (printer_id, ams_id) -> ams_serial map from live printer states.
+    # Fetch all statuses in one call rather than one get_status() call per printer.
+    serial_map: dict[tuple[int, int], str] = {}
+    seen_printer_ids: set[int] = {a.printer_id for a in assignments}
+    all_statuses = printer_manager.get_all_statuses()
+    for pid in seen_printer_ids:
+        state = all_statuses.get(pid)
+        if state and state.raw_data:
+            for ams_unit in state.raw_data.get("ams", []):
+                sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
+                if sn:
+                    try:
+                        serial_map[(pid, int(ams_unit.get("id", 0)))] = sn
+                    except (ValueError, TypeError):
+                        continue
+              
+    # Fetch all relevant AMS labels keyed by serial number
+    all_serials = set(serial_map.values())
+    # Also include synthetic fallback keys for assignments without a known serial
+    synthetic_keys: dict[str, tuple[int, int]] = {}
+    for a in assignments:
+        if (a.printer_id, a.ams_id) not in serial_map:
+            synthetic = f"p{a.printer_id}a{a.ams_id}"
+            synthetic_keys[synthetic] = (a.printer_id, a.ams_id)
+            all_serials.add(synthetic)
+
+    label_by_serial: dict[str, str] = {}
+    if all_serials:
+        lbl_result = await db.execute(
+            select(AmsLabel).where(AmsLabel.ams_serial_number.in_(all_serials))
+        )
+        for lbl in lbl_result.scalars().all():
+            label_by_serial[lbl.ams_serial_number] = lbl.label
+
+    # Build response objects, attaching ams_label where available
+    responses: list[SpoolAssignmentResponse] = []
+    for a in assignments:
+        resp = SpoolAssignmentResponse.model_validate(a)
+        sn = serial_map.get((a.printer_id, a.ams_id))
+        if sn and sn in label_by_serial:
+            resp.ams_label = label_by_serial[sn]
+        elif not sn:
+            synthetic = f"p{a.printer_id}a{a.ams_id}"
+            resp.ams_label = label_by_serial.get(synthetic)
+        responses.append(resp)
+
+    return responses
 
 
 
 
 @router.post("/assignments", response_model=SpoolAssignmentResponse)
 @router.post("/assignments", response_model=SpoolAssignmentResponse)

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

@@ -12,9 +12,11 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
+from backend.app.models.ams_label import AmsLabel
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.slot_preset import SlotPresetMapping
 from backend.app.models.slot_preset import SlotPresetMapping
 from backend.app.schemas.printer import (
 from backend.app.schemas.printer import (
+    AmsLabelBody,
     AMSTray,
     AMSTray,
     AMSUnit,
     AMSUnit,
     HMSErrorResponse,
     HMSErrorResponse,
@@ -433,6 +435,10 @@ async def get_printer_status(
                     temp=ams_data.get("temp"),
                     temp=ams_data.get("temp"),
                     is_ams_ht=is_ams_ht,
                     is_ams_ht=is_ams_ht,
                     tray=trays,
                     tray=trays,
+                    # Serial number: Bambu MQTT uses "sn" key on AMS unit objects
+                    serial_number=str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
+                    # Firmware version: populated by _handle_version_info from info.module ams/* entries
+                    sw_ver=str(ams_data.get("sw_ver") or ""),
                 )
                 )
             )
             )
 
 
@@ -1956,6 +1962,124 @@ async def reset_ams_slot(
     }
     }
 
 
 
 
+@router.get("/{printer_id}/ams-labels")
+async def get_ams_labels(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get all user-defined AMS labels for a printer, keyed by AMS unit ID.
+
+    Labels are stored by AMS serial number.  This endpoint resolves the current
+    serial-to-ams_id mapping from the live printer state so the response is still
+    keyed by ams_id for UI compatibility.
+    """
+    # Build serial -> ams_id map from live printer state
+    serial_to_ams_id: dict[str, int] = {}
+    state = printer_manager.get_status(printer_id)
+    if state and state.raw_data:
+        for ams_unit in state.raw_data.get("ams", []):
+            sn = str(ams_unit.get("sn") or ams_unit.get("serial_number") or "")
+            if sn:
+                serial_to_ams_id[sn] = int(ams_unit.get("id", 0))
+
+    # Collect all known serials for this printer (live + synthetic fallback keys)
+    serials_to_query = set(serial_to_ams_id.keys())
+
+    # Fetch labels for all known serials
+    labels: dict[int, str] = {}
+    if serials_to_query:
+        result = await db.execute(
+            select(AmsLabel).where(AmsLabel.ams_serial_number.in_(serials_to_query))
+        )
+        for lbl in result.scalars().all():
+            aid = serial_to_ams_id.get(lbl.ams_serial_number)
+            if aid is not None:
+                labels[aid] = lbl.label
+
+    # Also fetch labels stored under synthetic keys for this printer (backward compat)
+    # Collect all synthetic keys first, then query with a single IN clause.
+    if state and state.raw_data:
+        synthetic_key_to_aid: dict[str, int] = {
+            f"p{printer_id}a{int(ams_unit.get('id', 0))}": int(ams_unit.get("id", 0))
+            for ams_unit in state.raw_data.get("ams", [])
+            if int(ams_unit.get("id", 0)) not in labels
+        }
+        if synthetic_key_to_aid:
+            result = await db.execute(
+                select(AmsLabel).where(AmsLabel.ams_serial_number.in_(synthetic_key_to_aid.keys()))
+            )
+            for lbl in result.scalars().all():
+                aid = synthetic_key_to_aid.get(lbl.ams_serial_number)
+                if aid is not None:
+                    labels[aid] = lbl.label
+
+    return labels
+
+
+@router.put("/{printer_id}/ams-labels/{ams_id}")
+async def save_ams_label(
+    printer_id: int,
+    ams_id: int,
+    body: AmsLabelBody,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create or update the friendly name for a specific AMS unit.
+
+    When ``ams_serial`` is provided the label is stored under that serial number so
+    it survives the AMS being moved to a different printer.  When it is absent (e.g.
+    older firmware that does not report a serial) a synthetic key based on the
+    printer_id and ams_id is used as a fallback.
+    """
+    # Verify printer exists
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(404, "Printer not found")
+
+    # Determine the serial key to store under
+    stripped = body.ams_serial.strip() if body.ams_serial else ""
+    serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
+
+    result = await db.execute(
+        select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key)
+    )
+    existing = result.scalar_one_or_none()
+
+    if existing:
+        existing.label = body.label
+        existing.ams_id = ams_id
+    else:
+        db.add(AmsLabel(ams_serial_number=serial_key, ams_id=ams_id, label=body.label))
+
+    await db.commit()
+    return {"ams_id": ams_id, "label": body.label}
+
+
+@router.delete("/{printer_id}/ams-labels/{ams_id}")
+async def delete_ams_label(
+    printer_id: int,
+    ams_id: int,
+    ams_serial: str = Query(default="", max_length=50),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete the friendly name for a specific AMS unit, reverting to the auto label."""
+    stripped = ams_serial.strip() if ams_serial else ""
+    serial_key = stripped if stripped else f"p{printer_id}a{ams_id}"
+
+    result = await db.execute(
+        select(AmsLabel).where(AmsLabel.ams_serial_number == serial_key)
+    )
+    existing = result.scalar_one_or_none()
+
+    if existing:
+        await db.delete(existing)
+        await db.commit()
+
+    return {"success": True}
+
+
 @router.post("/{printer_id}/debug/simulate-print-complete")
 @router.post("/{printer_id}/debug/simulate-print-complete")
 async def debug_simulate_print_complete(
 async def debug_simulate_print_complete(
     printer_id: int,
     printer_id: int,

+ 6 - 3
backend/app/api/routes/settings.py

@@ -411,7 +411,7 @@ async def restore_backup(
 
 
     from fastapi import HTTPException
     from fastapi import HTTPException
 
 
-    from backend.app.core.database import close_all_connections
+    from backend.app.core.database import close_all_connections, init_db, reinitialize_database
     from backend.app.services.virtual_printer import virtual_printer_manager
     from backend.app.services.virtual_printer import virtual_printer_manager
 
 
     base_dir = app_settings.base_dir
     base_dir = app_settings.base_dir
@@ -498,8 +498,11 @@ async def restore_backup(
                         logger.warning("Could not restore %s directory: %s", name, e)
                         logger.warning("Could not restore %s directory: %s", name, e)
                         skipped_dirs.append(name)
                         skipped_dirs.append(name)
 
 
-            # 7. Note: Virtual printer and database will be reinitialized on restart
-            # Do NOT try to restart services here - the database session is closed
+            # 7. Reinitialize the database engine and apply schema migrations so that
+            # tables added after the backup was created (e.g. ams_labels) exist
+            # immediately, without requiring a manual restart.
+            await reinitialize_database()
+            await init_db()
 
 
             logger.info("Restore complete - restart required")
             logger.info("Restore complete - restart required")
             message = "Backup restored successfully. Please restart Bambuddy for changes to take effect."
             message = "Backup restored successfully. Please restart Bambuddy for changes to take effect."

+ 40 - 0
backend/app/core/database.py

@@ -74,6 +74,7 @@ async def init_db():
     from backend.app.models import (  # noqa: F401
     from backend.app.models import (  # noqa: F401
         active_print_spoolman,
         active_print_spoolman,
         ams_history,
         ams_history,
+        ams_label,
         api_key,
         api_key,
         archive,
         archive,
         bug_report,
         bug_report,
@@ -1361,6 +1362,45 @@ async def run_migrations(conn):
     except OperationalError:
     except OperationalError:
         pass  # Already applied
         pass  # Already applied
 
 
+    # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key
+    # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
+    try:
+        await conn.execute(text("DROP TABLE IF EXISTS ams_labels_new"))
+        result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='ams_labels'"))
+        row = result.fetchone()
+        if row and "printer_id" in (row[0] or ""):
+            # Old schema: rebuild the table with ams_serial_number as the unique key.
+            # Existing rows get a synthetic serial "p{printer_id}a{ams_id}" so data is preserved.
+            await conn.execute(
+                text("""
+                CREATE TABLE ams_labels_new (
+                    id INTEGER PRIMARY KEY,
+                    ams_serial_number VARCHAR(50) NOT NULL,
+                    ams_id INTEGER,
+                    label VARCHAR(100) NOT NULL,
+                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+                    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+                    CONSTRAINT uq_ams_label_serial UNIQUE (ams_serial_number)
+                )
+            """)
+            )
+            await conn.execute(
+                text("""
+                INSERT INTO ams_labels_new (id, ams_serial_number, ams_id, label, created_at, updated_at)
+                SELECT id,
+                       'p' || CAST(printer_id AS TEXT) || 'a' || CAST(ams_id AS TEXT),
+                       ams_id,
+                       label,
+                       created_at,
+                       updated_at
+                FROM ams_labels
+            """)
+            )
+            await conn.execute(text("DROP TABLE ams_labels"))
+            await conn.execute(text("ALTER TABLE ams_labels_new RENAME TO ams_labels"))
+    except OperationalError:
+        pass  # Already migrated or table does not exist yet
+
     # Cleanup: Remove obsolete settings keys that are no longer used
     # Cleanup: Remove obsolete settings keys that are no longer used
     obsolete_keys = ["slicer_binary_path"]
     obsolete_keys = ["slicer_binary_path"]
     for key in obsolete_keys:
     for key in obsolete_keys:

+ 2 - 0
backend/app/models/__init__.py

@@ -1,4 +1,5 @@
 from backend.app.models.ams_history import AMSSensorHistory
 from backend.app.models.ams_history import AMSSensorHistory
+from backend.app.models.ams_label import AmsLabel
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.color_catalog import ColorCatalogEntry
@@ -40,6 +41,7 @@ __all__ = [
     "Project",
     "Project",
     "APIKey",
     "APIKey",
     "AMSSensorHistory",
     "AMSSensorHistory",
+    "AmsLabel",
     "PendingUpload",
     "PendingUpload",
     "LibraryFolder",
     "LibraryFolder",
     "LibraryFile",
     "LibraryFile",

+ 30 - 0
backend/app/models/ams_label.py

@@ -0,0 +1,30 @@
+"""Model for storing user-defined friendly names for AMS units.
+
+Users can assign a custom label to each AMS (e.g. "Workshop AMS", "Silk Colours")
+that is displayed in place of or alongside the auto-generated label (AMS-A, HT-A, …).
+
+Labels are keyed by AMS serial number so they persist when the AMS is moved to a
+different printer.  A fallback (printer_id + ams_id) is retained for units whose
+serial number is not yet known.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, Integer, String, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class AmsLabel(Base):
+    """Maps an AMS unit serial number to a user-defined friendly name."""
+
+    __tablename__ = "ams_labels"
+    __table_args__ = (UniqueConstraint("ams_serial_number", name="uq_ams_label_serial"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    ams_serial_number: Mapped[str] = mapped_column(String(50))  # AMS unit serial number (sn from MQTT)
+    ams_id: Mapped[int | None] = mapped_column(Integer, nullable=True)  # AMS unit ID hint (0, 1, 2, 3, 128…)
+    label: Mapped[str] = mapped_column(String(100))  # User-defined friendly name
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

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

@@ -138,6 +138,8 @@ class AMSUnit(BaseModel):
     temp: float | None = None
     temp: float | None = None
     is_ams_ht: bool = False  # True for AMS-HT (single spool), False for regular AMS (4 spools)
     is_ams_ht: bool = False  # True for AMS-HT (single spool), False for regular AMS (4 spools)
     tray: list[AMSTray] = []
     tray: list[AMSTray] = []
+    serial_number: str = ""  # AMS unit serial number (sn from MQTT)
+    sw_ver: str = ""         # AMS firmware version (from get_version info.module)
 
 
 
 
 class NozzleInfoResponse(BaseModel):
 class NozzleInfoResponse(BaseModel):
@@ -160,6 +162,11 @@ class NozzleRackSlot(BaseModel):
     filament_type: str = ""  # Material type (e.g. "PLA", "PETG")
     filament_type: str = ""  # Material type (e.g. "PLA", "PETG")
 
 
 
 
+class AmsLabelBody(BaseModel):
+    label: str = Field(..., min_length=1, max_length=100)
+    ams_serial: str = Field(default="", max_length=50)
+
+
 class PrintOptionsResponse(BaseModel):
 class PrintOptionsResponse(BaseModel):
     """AI detection and print options from xcam data."""
     """AI detection and print options from xcam data."""
 
 

+ 1 - 0
backend/app/schemas/spool.py

@@ -117,6 +117,7 @@ class SpoolAssignmentResponse(BaseModel):
     created_at: datetime
     created_at: datetime
     spool: SpoolResponse | None = None
     spool: SpoolResponse | None = None
     configured: bool = False
     configured: bool = False
+    ams_label: str | None = None  # User-defined friendly name for the AMS unit
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True

+ 143 - 3
backend/app/services/bambu_mqtt.py

@@ -22,6 +22,13 @@ import paho.mqtt.client as mqtt
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# AMS module name prefixes used in get_version responses.
+# The numeric suffix after '/' is the AMS unit ID as reported in push_status.
+#   "ams/<id>"  – original AMS (X1C, X1E, P1S, …)
+#   "n3f/<id>"  – AMS 2 Pro (H2D Pro and similar)
+#   "n3s/<id>"  – AMS HT (H2D Pro and similar; IDs typically start at 128)
+_AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
+
 
 
 @dataclass
 @dataclass
 class MQTTLogEntry:
 class MQTTLogEntry:
@@ -297,6 +304,14 @@ class BambuMQTTClient:
         self._disconnection_event: threading.Event | None = None
         self._disconnection_event: threading.Event | None = None
         self._previous_ams_hash: str | None = None  # Track AMS changes
         self._previous_ams_hash: str | None = None  # Track AMS changes
 
 
+        # Cache AMS firmware/SN from get_version in case it arrives before AMS status
+        # Key: ams_id (int). Value: {'sw_ver': str, 'sn': str}
+        self._ams_version_cache: dict[int, dict[str, str]] = {}
+
+        # Track which (ams_id, field) warnings have already been emitted this connection
+        # so that missing-serial / missing-firmware warnings fire only once per connection.
+        self._ams_version_warned: set[tuple[int | str, str]] = set()
+
         # K-profile command tracking
         # K-profile command tracking
         self._sequence_id: int = 0
         self._sequence_id: int = 0
         self._pending_kprofile_response: asyncio.Event | None = None
         self._pending_kprofile_response: asyncio.Event | None = None
@@ -356,6 +371,8 @@ class BambuMQTTClient:
     def _on_connect(self, client, userdata, flags, rc, properties=None):
     def _on_connect(self, client, userdata, flags, rc, properties=None):
         if rc == 0:
         if rc == 0:
             self.state.connected = True
             self.state.connected = True
+            # Reset per-connection warning state so warnings fire once per (re)connection
+            self._ams_version_warned = set()
             client.subscribe(self.topic_subscribe)
             client.subscribe(self.topic_subscribe)
             # Subscribe to request topic for ams_mapping capture (if supported by broker)
             # Subscribe to request topic for ams_mapping capture (if supported by broker)
             if self._request_topic_supported:
             if self._request_topic_supported:
@@ -664,12 +681,24 @@ class BambuMQTTClient:
         """Handle version info response from get_version command.
         """Handle version info response from get_version command.
 
 
         Parses firmware version from the 'ota' module in the module list.
         Parses firmware version from the 'ota' module in the module list.
+        Also extracts AMS unit firmware versions from AMS modules and stores
+        them on the corresponding AMS unit in raw_data so the status route can
+        expose them to the frontend.
+
+        AMS module naming conventions (numeric suffix is the AMS unit ID):
+        - ``ams/<id>``  – original AMS
+        - ``n3f/<id>``  – AMS 2 Pro (H2D Pro and similar)
+        - ``n3s/<id>``  – AMS HT (H2D Pro and similar)
+
         Message format:
         Message format:
         {
         {
             "command": "get_version",
             "command": "get_version",
             "module": [
             "module": [
                 {"name": "ota", "sw_ver": "01.08.05.00"},
                 {"name": "ota", "sw_ver": "01.08.05.00"},
                 {"name": "rv1126", "sw_ver": "00.00.14.74"},
                 {"name": "rv1126", "sw_ver": "00.00.14.74"},
+                {"name": "ams/0", "sw_ver": "00.00.06.96", "sn": "ABC123"},
+                {"name": "n3f/0", "sw_ver": "03.00.21.29", "sn": "19C06A552504488"},
+                {"name": "n3s/128", "sw_ver": "03.00.21.29", "sn": "19F06A561801096"},
                 ...
                 ...
             ]
             ]
         }
         }
@@ -678,6 +707,7 @@ class BambuMQTTClient:
         if not isinstance(modules, list):
         if not isinstance(modules, list):
             return
             return
 
 
+        state_changed = False
         for module in modules:
         for module in modules:
             if not isinstance(module, dict):
             if not isinstance(module, dict):
                 continue
                 continue
@@ -688,11 +718,115 @@ class BambuMQTTClient:
                     self.state.firmware_version = version
                     self.state.firmware_version = version
                     if old_version != version:
                     if old_version != version:
                         logger.info("[%s] Firmware version: %s", self.serial_number, version)
                         logger.info("[%s] Firmware version: %s", self.serial_number, version)
-                    # Trigger state change callback
-                    if self.on_state_change:
-                        self.on_state_change(self.state)
+                    state_changed = True
                 break
                 break
 
 
+        # Extract AMS unit firmware versions from AMS modules.
+        # See module-level _AMS_MODULE_PREFIXES for supported naming conventions.
+        # Always cache regardless of whether AMS data has arrived yet — get_version
+        # often arrives before the first push_status, so caching must be unconditional.
+        ams_raw = self.state.raw_data.get("ams")
+        for module in modules:
+            if not isinstance(module, dict):
+                continue
+            name = module.get("name", "")
+            if not any(name.startswith(prefix) for prefix in _AMS_MODULE_PREFIXES):
+                continue
+            try:
+                ams_id = int(name.split("/", 1)[1])
+            except (ValueError, IndexError):
+                continue
+            sw_ver = module.get("sw_ver", "")
+            sn = module.get("sn", "")
+
+            # Always cache so _apply_ams_version_cache can apply it when AMS data arrives
+            if sw_ver or sn:
+                self._ams_version_cache[ams_id] = {"sw_ver": sw_ver, "sn": sn}
+                state_changed = True
+
+            # Also directly update any AMS unit already present in raw_data
+            if ams_raw and isinstance(ams_raw, list):
+                for ams_unit in ams_raw:
+                    if not isinstance(ams_unit, dict):
+                        continue
+                    try:
+                        unit_id = int(ams_unit.get("id")) if ams_unit.get("id") is not None else None
+                    except (ValueError, TypeError):
+                        unit_id = None
+                    if unit_id == ams_id:
+                        if sw_ver:
+                            ams_unit["sw_ver"] = sw_ver
+                            logger.debug("[%s] AMS %s firmware: %s", self.serial_number, ams_id, sw_ver)
+                        # Only set sn from version info if not already present in AMS data
+                        if sn and not ams_unit.get("sn"):
+                            ams_unit["sn"] = sn
+                        break
+
+        # Trigger state change callback AFTER both loops so AMS sn/sw_ver are
+        # included in the broadcast (not just the printer firmware version).
+        if state_changed and self.on_state_change:
+            self.on_state_change(self.state)
+
+        # Warn if any AMS unit is still missing serial number or firmware version
+        # after processing the version info response. Warn only once per connection
+        # to avoid repeated noise on older firmware that doesn't report these fields.
+        if ams_raw and isinstance(ams_raw, list):
+            for ams_unit in ams_raw:
+                if not isinstance(ams_unit, dict):
+                    continue
+                ams_id = ams_unit.get("id", "?")
+                if not ams_unit.get("sn") and not ams_unit.get("serial_number"):
+                    key = (ams_id, "sn")
+                    if key not in self._ams_version_warned:
+                        self._ams_version_warned.add(key)
+                        logger.warning(
+                            "[%s] AMS unit %s: serial number not available in version info",
+                            self.serial_number,
+                            ams_id,
+                        )
+                if not ams_unit.get("sw_ver"):
+                    key = (ams_id, "sw_ver")
+                    if key not in self._ams_version_warned:
+                        self._ams_version_warned.add(key)
+                        logger.warning(
+                            "[%s] AMS unit %s: firmware version not available in version info",
+                            self.serial_number,
+                            ams_id,
+                        )
+
+    def _apply_ams_version_cache(self, ams_list: list) -> None:
+        """Apply cached AMS firmware/SN (from get_version) onto an AMS list in-place.
+
+        get_version may arrive before pushall/AMS status, and AMS unit IDs may be
+        strings in MQTT payloads. This helper normalizes IDs and fills missing
+        sw_ver/sn fields without overwriting values already present.
+        """
+        if not ams_list or not isinstance(ams_list, list):
+            return
+        cache = self._ams_version_cache
+        if not cache:
+            return
+        for unit in ams_list:
+            if not isinstance(unit, dict):
+                continue
+            raw_id = unit.get("id")
+            try:
+                unit_id = int(raw_id) if raw_id is not None else None
+            except (ValueError, TypeError):
+                unit_id = None
+            if unit_id is None:
+                continue
+            cached = cache.get(unit_id)
+            if not cached:
+                continue
+            sw_ver = cached.get("sw_ver") or ""
+            sn = cached.get("sn") or ""
+            if sw_ver and not unit.get("sw_ver"):
+                unit["sw_ver"] = sw_ver
+            # Only set sn if not already present in AMS data
+            if sn and not unit.get("sn") and not unit.get("serial_number"):
+                unit["sn"] = sn
+
     def _parse_xcam_data(self, xcam_data):
     def _parse_xcam_data(self, xcam_data):
         """Parse xcam data for camera settings and AI detection options."""
         """Parse xcam data for camera settings and AI detection options."""
         if not isinstance(xcam_data, dict):
         if not isinstance(xcam_data, dict):
@@ -1239,6 +1373,10 @@ class BambuMQTTClient:
                             merged_trays.append(new_tray)
                             merged_trays.append(new_tray)
                     # Update ams_unit with merged trays
                     # Update ams_unit with merged trays
                     ams_unit = {**ams_unit, "tray": merged_trays}
                     ams_unit = {**ams_unit, "tray": merged_trays}
+                elif existing_unit:
+                    # Partial update without tray data: merge new fields into existing
+                    # unit to preserve tray, sn, sw_ver, and other accumulated data.
+                    ams_unit = {**existing_unit, **ams_unit}
                 existing_by_id[ams_id] = ams_unit
                 existing_by_id[ams_id] = ams_unit
 
 
         # Convert back to list, sorted by ID for consistent ordering
         # Convert back to list, sorted by ID for consistent ordering
@@ -1287,6 +1425,8 @@ class BambuMQTTClient:
 
 
         self.state.raw_data["ams"] = merged_ams
         self.state.raw_data["ams"] = merged_ams
 
 
+        # Apply cached AMS firmware/SN from get_version (handles ordering and id type mismatches)
+        self._apply_ams_version_cache(merged_ams)
         # Update timestamp for RFID refresh detection (frontend can detect "new data arrived")
         # Update timestamp for RFID refresh detection (frontend can detect "new data arrived")
         self.state.last_ams_update = time.time()
         self.state.last_ams_update = time.time()
         logger.debug("[%s] Merged AMS data: %s new units, %s total", self.serial_number, len(ams_list), len(merged_ams))
         logger.debug("[%s] Merged AMS data: %s new units, %s total", self.serial_number, len(ams_list), len(merged_ams))

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

@@ -589,6 +589,10 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                     "temp": ams_data.get("temp"),
                     "temp": ams_data.get("temp"),
                     "is_ams_ht": is_ams_ht,
                     "is_ams_ht": is_ams_ht,
                     "tray": trays,
                     "tray": trays,
+                    # Serial number: Bambu MQTT uses "sn" key on AMS unit objects
+                    "serial_number": str(ams_data.get("sn") or ams_data.get("serial_number") or ""),
+                    # Firmware version: populated by _handle_version_info from get_version
+                    "sw_ver": str(ams_data.get("sw_ver") or ""),
                 }
                 }
             )
             )
 
 

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

@@ -134,6 +134,8 @@ export interface AMSUnit {
   temp: number | null;
   temp: number | null;
   is_ams_ht: boolean;  // True for AMS-HT (single spool), False for regular AMS (4 spools)
   is_ams_ht: boolean;  // True for AMS-HT (single spool), False for regular AMS (4 spools)
   tray: AMSTray[];
   tray: AMSTray[];
+  serial_number: string;  // AMS unit serial number (from MQTT sn field)
+  sw_ver: string;         // AMS firmware version (from get_version info.module ams/* entry)
 }
 }
 
 
 export interface NozzleInfo {
 export interface NozzleInfo {
@@ -1878,6 +1880,7 @@ export interface SpoolAssignment {
   spool?: InventorySpool | null;
   spool?: InventorySpool | null;
   configured: boolean;
   configured: boolean;
   created_at: string;
   created_at: string;
+  ams_label?: string | null;  // User-defined friendly name for the AMS unit
 }
 }
 
 
 // Update types
 // Update types
@@ -3345,6 +3348,23 @@ export const api = {
     request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
     request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
       method: 'DELETE',
       method: 'DELETE',
     }),
     }),
+
+  // AMS Labels (user-defined friendly names)
+  getAmsLabels: (printerId: number) =>
+    request<Record<number, string>>(`/printers/${printerId}/ams-labels`),
+  saveAmsLabel: (printerId: number, amsId: number, label: string, amsSerial = '') =>
+    request<{ ams_id: number; label: string }>(
+      `/printers/${printerId}/ams-labels/${amsId}`,
+      {
+        method: 'PUT',
+        body: JSON.stringify({ label, ams_serial: amsSerial }),
+      }
+    ),
+  deleteAmsLabel: (printerId: number, amsId: number, amsSerial = '') =>
+    request<{ success: boolean }>(`/printers/${printerId}/ams-labels/${amsId}?ams_serial=${encodeURIComponent(amsSerial)}`, {
+      method: 'DELETE',
+    }),
+
   configureAmsSlot: (
   configureAmsSlot: (
     printerId: number,
     printerId: number,
     amsId: number,
     amsId: number,

+ 47 - 0
frontend/src/components/FilamentSlotCircle.tsx

@@ -0,0 +1,47 @@
+/**
+ * FilamentSlotCircle renders a small color circle with the 1-based slot
+ * number centered inside, matching the style used on AMS cards in PrintersPage.
+ *
+ * Props:
+ *   trayColor  - 6-char hex color string WITHOUT leading '#' (e.g. "FF0000").
+ *                Pass undefined / empty string when the slot is empty.
+ *   trayType   - Filament material string (e.g. "PLA").  Used to decide the
+ *                fallback background when there is no color but a type is known.
+ *   isEmpty    - Whether the slot contains no filament.
+ *   slotNumber - 1-based slot number to display inside the circle.
+ */
+
+interface FilamentSlotCircleProps {
+  trayColor?: string | null;
+  trayType?: string | null;
+  isEmpty: boolean;
+  slotNumber: number;
+}
+
+function isLightFilamentColor(hex: string): boolean {
+  if (!hex || hex.length < 6) return false;
+  const r = parseInt(hex.slice(0, 2), 16);
+  const g = parseInt(hex.slice(2, 4), 16);
+  const b = parseInt(hex.slice(4, 6), 16);
+  return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
+}
+
+export function FilamentSlotCircle({ trayColor, trayType, isEmpty, slotNumber }: FilamentSlotCircleProps) {
+  return (
+    <div
+      className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2 flex items-center justify-center"
+      style={{
+        backgroundColor: trayColor ? `#${trayColor}` : (trayType ? '#333' : 'transparent'),
+        borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
+        borderStyle: isEmpty ? 'dashed' : 'solid',
+      }}
+    >
+      <span
+        className="text-[6px] font-bold leading-none select-none"
+        style={{ color: trayColor && isLightFilamentColor(trayColor) ? '#000' : '#fff' }}
+      >
+        {slotNumber}
+      </span>
+    </div>
+  );
+}

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

@@ -445,6 +445,16 @@ export default {
     clickToViewHmsErrors: 'Klicken, um HMS-Fehler anzuzeigen',
     clickToViewHmsErrors: 'Klicken, um HMS-Fehler anzuzeigen',
     estimatedCompletion: 'Geschätzte Fertigstellungszeit',
     estimatedCompletion: 'Geschätzte Fertigstellungszeit',
     slotOptions: 'Slot-Optionen',
     slotOptions: 'Slot-Optionen',
+    // AMS hover popup
+    amsPopup: {
+      friendlyName: 'AMS-Name',
+      friendlyNamePlaceholder: 'z. B. AMS-Anzeigename',
+      serialNumber: 'Seriennummer',
+      firmwareVersion: 'Firmware',
+      save: 'Speichern',
+      clear: 'Löschen',
+      noEditPermission: 'Sie haben keine Berechtigung, AMS-Einheiten umzubenennen',
+    },
     // Firmware modal
     // Firmware modal
     firmwareModal: {
     firmwareModal: {
       title: 'Firmware-Update',
       title: 'Firmware-Update',

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

@@ -445,6 +445,16 @@ export default {
     clickToViewHmsErrors: 'Click to view HMS errors',
     clickToViewHmsErrors: 'Click to view HMS errors',
     estimatedCompletion: 'Estimated completion time',
     estimatedCompletion: 'Estimated completion time',
     slotOptions: 'Slot options',
     slotOptions: 'Slot options',
+    // AMS hover popup
+    amsPopup: {
+      friendlyName: 'AMS Name',
+      friendlyNamePlaceholder: 'e.g. AMS Friendly Name',
+      serialNumber: 'Serial Number',
+      firmwareVersion: 'Firmware',
+      save: 'Save',
+      clear: 'Clear',
+      noEditPermission: 'You do not have permission to rename AMS units',
+    },
     // Firmware modal
     // Firmware modal
     firmwareModal: {
     firmwareModal: {
       title: 'Firmware Update',
       title: 'Firmware Update',

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

@@ -445,6 +445,16 @@ export default {
     clickToViewHmsErrors: 'Cliquez pour voir les erreurs HMS',
     clickToViewHmsErrors: 'Cliquez pour voir les erreurs HMS',
     estimatedCompletion: 'Fin estimée',
     estimatedCompletion: 'Fin estimée',
     slotOptions: 'Options du slot',
     slotOptions: 'Options du slot',
+    // AMS hover popup
+    amsPopup: {
+      friendlyName: 'Nom AMS',
+      friendlyNamePlaceholder: 'ex. Nom convivial AMS',
+      serialNumber: 'Numéro de série',
+      firmwareVersion: 'Firmware',
+      save: 'Enregistrer',
+      clear: 'Effacer',
+      noEditPermission: "Vous n'avez pas la permission de renommer les unités AMS",
+    },
     // Firmware modal
     // Firmware modal
     firmwareModal: {
     firmwareModal: {
       title: 'Mise à jour Firmware',
       title: 'Mise à jour Firmware',

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

@@ -436,6 +436,16 @@ export default {
     clickToViewHmsErrors: 'Clicca per vedere errori HMS',
     clickToViewHmsErrors: 'Clicca per vedere errori HMS',
     estimatedCompletion: 'Tempo completamento stimato',
     estimatedCompletion: 'Tempo completamento stimato',
     slotOptions: 'Opzioni slot',
     slotOptions: 'Opzioni slot',
+    // AMS hover popup
+    amsPopup: {
+      friendlyName: 'Nome AMS',
+      friendlyNamePlaceholder: 'es. Nome AMS amichevole',
+      serialNumber: 'Numero di serie',
+      firmwareVersion: 'Firmware',
+      save: 'Salva',
+      clear: 'Cancella',
+      noEditPermission: 'Non hai il permesso di rinominare le unità AMS',
+    },
     // Firmware modal
     // Firmware modal
     firmwareModal: {
     firmwareModal: {
       title: 'Aggiornamento Firmware',
       title: 'Aggiornamento Firmware',

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

@@ -408,6 +408,16 @@ export default {
     },
     },
     estimatedCompletion: '完了予定時刻',
     estimatedCompletion: '完了予定時刻',
     slotOptions: 'スロットオプション',
     slotOptions: 'スロットオプション',
+    // AMS hover popup
+    amsPopup: {
+      friendlyName: 'AMS名',
+      friendlyNamePlaceholder: '例: AMS フレンドリー名',
+      serialNumber: 'シリアル番号',
+      firmwareVersion: 'ファームウェア',
+      save: '保存',
+      clear: 'クリア',
+      noEditPermission: 'AMS ユニットの名前を変更する権限がありません',
+    },
     firmwareModal: {
     firmwareModal: {
       title: 'ファームウェアアップデート',
       title: 'ファームウェアアップデート',
       titleUpToDate: 'ファームウェア情報',
       titleUpToDate: 'ファームウェア情報',

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

@@ -445,6 +445,16 @@ export default {
     clickToViewHmsErrors: 'Clique para ver erros do HMS',
     clickToViewHmsErrors: 'Clique para ver erros do HMS',
     estimatedCompletion: 'Tempo estimado de conclusão',
     estimatedCompletion: 'Tempo estimado de conclusão',
     slotOptions: 'Opções de slot',
     slotOptions: 'Opções de slot',
+    // AMS hover popup
+    amsPopup: {
+      friendlyName: 'Nome do AMS',
+      friendlyNamePlaceholder: 'ex.: Nome amigável do AMS',
+      serialNumber: 'Número de série',
+      firmwareVersion: 'Firmware',
+      save: 'Salvar',
+      clear: 'Limpar',
+      noEditPermission: 'Você não tem permissão para renomear unidades AMS',
+    },
     // Firmware modal
     // Firmware modal
     firmwareModal: {
     firmwareModal: {
       title: 'Atualização de Firmware',
       title: 'Atualização de Firmware',

+ 3 - 2
frontend/src/pages/InventoryPage.tsx

@@ -210,7 +210,7 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
     const slotLabel = formatSlotLabel(assignment.ams_id, assignment.tray_id, isHt, isExternal);
     const slotLabel = formatSlotLabel(assignment.ams_id, assignment.tray_id, isHt, isExternal);
     return (
     return (
       <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400">
       <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-500/20 text-purple-400">
-        {printerLabel} {slotLabel}
+        {printerLabel} {slotLabel}{assignment.ams_label ? ` (${assignment.ams_label})` : ''}
       </span>
       </span>
     );
     );
   },
   },
@@ -359,7 +359,8 @@ const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Re
     if (!a) return '';
     if (!a) return '';
     const isExt = a.ams_id === 254 || a.ams_id === 255;
     const isExt = a.ams_id === 254 || a.ams_id === 255;
     const isHt = !isExt && a.ams_id >= 128;
     const isHt = !isExt && a.ams_id >= 128;
-    return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}`;
+    const label = a.ams_label ? ` (${a.ams_label})` : '';
+    return `${a.printer_name || ''} ${formatSlotLabel(a.ams_id, a.tray_id, isHt, isExt)}${label}`;
   },
   },
   label_weight: (s) => s.label_weight,
   label_weight: (s) => s.label_weight,
   net: (s) => Math.max(0, s.label_weight - s.weight_used),
   net: (s) => Math.max(0, s.label_weight - s.weight_used),

+ 247 - 27
frontend/src/pages/PrintersPage.tsx

@@ -72,6 +72,7 @@ import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { getGlobalTrayId } from '../utils/amsHelpers';
 import { getGlobalTrayId } from '../utils/amsHelpers';
 import { getPrinterImage, getWifiStrength } from '../utils/printer';
 import { getPrinterImage, getWifiStrength } from '../utils/printer';
+import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { hexToColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 import { hexToColorName, parseFilamentColor, isLightColor } from '../utils/colors';
 
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Complete Bambu Lab filament color mapping by tray_id_name
@@ -1289,6 +1290,201 @@ function mapModelCode(ssdpModel: string | null): string {
   return modelMap[ssdpModel] || ssdpModel;
   return modelMap[ssdpModel] || ssdpModel;
 }
 }
 
 
+// ─── AMS Name Hover Card ──────────────────────────────────────────────────────
+// Wraps the AMS label (e.g. "AMS-A") and shows a popup with:
+//  • User-defined friendly name (editable, protected by printers:update)
+//  • AMS serial number
+//  • AMS firmware version
+export function AmsNameHoverCard({
+  ams,
+  printerId,
+  label,
+  amsLabels,
+  canEdit,
+  onSaved,
+  children,
+}: {
+  ams: import('../api/client').AMSUnit;
+  printerId: number;
+  label: string;           // auto-generated label, e.g. "AMS-A"
+  amsLabels?: Record<number, string>;
+  canEdit: boolean;
+  onSaved: () => void;
+  children: React.ReactNode;
+}) {
+  const { t } = useTranslation();
+  const [isVisible, setIsVisible] = useState(false);
+  const [position, setPosition] = useState<'top' | 'bottom'>('top');
+  const [editValue, setEditValue] = useState('');
+  const [isSaving, setIsSaving] = useState(false);
+  const [saveError, setSaveError] = useState<string | null>(null);
+  const [isInputFocused, setIsInputFocused] = useState(false);
+  const triggerRef = useRef<HTMLDivElement>(null);
+  const cardRef = useRef<HTMLDivElement>(null);
+  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  useEffect(() => {
+    if (isVisible) {
+      setEditValue(amsLabels?.[ams.id] ?? '');
+      setSaveError(null);
+      requestAnimationFrame(() => {
+        if (triggerRef.current && cardRef.current) {
+          const rect = triggerRef.current.getBoundingClientRect();
+          const spaceAbove = rect.top - 56;
+          const spaceBelow = window.innerHeight - rect.bottom;
+          setPosition(spaceAbove < cardRef.current.offsetHeight + 12 && spaceBelow > spaceAbove ? 'bottom' : 'top');
+        }
+      });
+    }
+  }, [isVisible, amsLabels, ams.id]);
+  
+  const handleMouseEnter = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    timeoutRef.current = setTimeout(() => setIsVisible(true), 80);
+  };
+  const handleMouseLeave = () => {
+    if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    if (!isInputFocused) {
+      timeoutRef.current = setTimeout(() => setIsVisible(false), 200);
+    }
+  };
+  useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []);
+
+  const handleSave = async () => {
+    if (!canEdit) return;
+    setIsSaving(true);
+    setSaveError(null);
+    try {
+      const trimmed = editValue.trim();
+      if (trimmed) {
+        await api.saveAmsLabel(printerId, ams.id, trimmed, ams.serial_number);
+      } else {
+        await api.deleteAmsLabel(printerId, ams.id, ams.serial_number);
+      }
+      onSaved();
+      setIsVisible(false);
+    } catch (err) {
+      setSaveError(err instanceof Error ? err.message : String(err));
+    } finally {
+      setIsSaving(false);
+    }
+  };
+
+  const handleClear = async () => {
+    if (!canEdit) return;
+    setIsSaving(true);
+    setSaveError(null);
+    try {
+      await api.deleteAmsLabel(printerId, ams.id, ams.serial_number);
+      onSaved();
+      setIsVisible(false);
+    } catch (err) {
+      setSaveError(err instanceof Error ? err.message : String(err));
+    } finally {
+      setIsSaving(false);
+    }
+  };
+
+  return (
+    <div
+      ref={triggerRef}
+      className="relative inline-block"
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
+    >
+      {children}
+
+      {isVisible && (
+        <div
+          ref={cardRef}
+          className={`
+            absolute left-0 z-50
+            ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'}
+            animate-in fade-in-0 zoom-in-95 duration-150
+          `}
+          style={{ maxWidth: 'calc(100vw - 24px)' }}
+          onMouseEnter={handleMouseEnter}
+          onMouseLeave={handleMouseLeave}
+        >
+          <div className="w-52 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl overflow-hidden backdrop-blur-sm p-2.5 space-y-2">
+            {/* AMS auto-label */}
+            <div className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">{label}</div>
+
+            {/* Serial number */}
+            <div className="flex items-center justify-between gap-2">
+              <span className="text-[10px] tracking-wide text-bambu-gray font-medium shrink-0">
+                {t('printers.amsPopup.serialNumber')}
+              </span>
+              <span className="text-[10px] text-white font-mono truncate">{ams.serial_number || '—'}</span>
+            </div>
+
+            {/* Firmware version */}
+            <div className="flex items-center justify-between gap-2">
+              <span className="text-[10px] tracking-wide text-bambu-gray font-medium shrink-0">
+                {t('printers.amsPopup.firmwareVersion')}
+              </span>
+              <span className="text-[10px] text-white font-mono truncate">{ams.sw_ver || '—'}</span>
+            </div>
+
+            {/* Divider */}
+            <div className="h-px bg-bambu-dark-tertiary/50" />
+
+            {/* Friendly name editor */}
+            <div className="space-y-1">
+              <span className="text-[10px] text-bambu-gray font-medium block">
+                {t('printers.amsPopup.friendlyName')}
+              </span>
+              <input
+                type="text"
+                value={editValue}
+                onChange={(e) => canEdit && setEditValue(e.target.value)}
+                onKeyDown={(e) => e.key === 'Enter' && handleSave()}
+                onFocus={() => setIsInputFocused(true)}
+                onBlur={() => {
+                  setIsInputFocused(false);
+                  if (timeoutRef.current) clearTimeout(timeoutRef.current);
+                    timeoutRef.current = setTimeout(() => setIsVisible(false), 200);
+                }}
+                placeholder={canEdit ? t('printers.amsPopup.friendlyNamePlaceholder') : (amsLabels?.[ams.id] || '—')}
+                disabled={!canEdit}
+                title={!canEdit ? t('printers.amsPopup.noEditPermission') : undefined}
+                className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1 text-xs text-white placeholder-bambu-gray/60 focus:outline-none focus:border-bambu-green disabled:opacity-50 disabled:cursor-not-allowed"
+                maxLength={100}
+              />
+              {canEdit && (
+                <div className="space-y-1">
+                  {saveError && (
+                    <p className="text-[10px] text-red-400 break-words">{saveError}</p>
+                  )}
+                  <div className="flex gap-1 justify-end">
+                    <button
+                      onClick={handleSave}
+                      disabled={isSaving}
+                      className="px-2 py-0.5 text-[10px] bg-bambu-green text-white rounded hover:bg-bambu-green/80 disabled:opacity-50"
+                    >
+                      {t('printers.amsPopup.save')}
+                    </button>
+                    {amsLabels?.[ams.id] && (
+                      <button
+                        onClick={handleClear}
+                        disabled={isSaving}
+                        className="px-2 py-0.5 text-[10px] bg-bambu-dark-tertiary text-bambu-gray rounded hover:bg-bambu-dark-tertiary/70 disabled:opacity-50"
+                      >
+                        {t('printers.amsPopup.clear')}
+                      </button>
+                    )}
+                  </div>
+                </div>
+              )}
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+
+
 function PrinterCard({
 function PrinterCard({
   printer,
   printer,
   hideIfDisconnected,
   hideIfDisconnected,
@@ -1498,6 +1694,13 @@ function PrinterCard({
     staleTime: 2 * 60 * 1000, // 2 minutes
     staleTime: 2 * 60 * 1000, // 2 minutes
   });
   });
 
 
+  // Fetch user-defined AMS friendly names from the database
+  const { data: amsLabels, refetch: refetchAmsLabels } = useQuery({
+    queryKey: ['amsLabels', printer.id],
+    queryFn: () => api.getAmsLabels(printer.id),
+    staleTime: 5 * 60 * 1000, // 5 minutes
+  });
+
   // Cache WiFi signal to prevent it disappearing on updates
   // Cache WiFi signal to prevent it disappearing on updates
   const [cachedWifiSignal, setCachedWifiSignal] = useState<number | null>(null);
   const [cachedWifiSignal, setCachedWifiSignal] = useState<number | null>(null);
   useEffect(() => {
   useEffect(() => {
@@ -2789,9 +2992,19 @@ function PrinterCard({
                             {/* Header: Label + Stats (no icon) */}
                             {/* Header: Label + Stats (no icon) */}
                             <div className="flex items-center justify-between mb-2">
                             <div className="flex items-center justify-between mb-2">
                               <div className="flex items-center gap-1.5">
                               <div className="flex items-center gap-1.5">
-                                <span className="text-[10px] text-white font-medium">
-                                  {getAmsLabel(ams.id, ams.tray.length)}
-                                </span>
+                                {/* AMS name — hover to see serial, firmware, and edit friendly name */}
+                                <AmsNameHoverCard
+                                  ams={ams}
+                                  printerId={printer.id}
+                                  label={getAmsLabel(ams.id, ams.tray.length)}
+                                  amsLabels={amsLabels}
+                                  canEdit={hasPermission('printers:update')}
+                                  onSaved={refetchAmsLabels}
+                                >
+                                  <span className="text-[10px] text-white font-medium cursor-default select-none">
+                                    {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}
+                                  </span>
+                                </AmsNameHoverCard>
                                 {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
                                 {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
                                   <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
                                   <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
                                 )}
                                 )}
@@ -2884,13 +3097,12 @@ function PrinterCard({
                                   <div
                                   <div
                                     className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
                                     className={`bg-bambu-dark-tertiary rounded p-1 text-center ${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',
-                                      }}
+                                    {/* Filament color circle with 1-based slot number centered inside */}
+                                    <FilamentSlotCircle
+                                      trayColor={tray?.tray_color}
+                                      trayType={tray?.tray_type}
+                                      isEmpty={isEmpty}
+                                      slotNumber={slotIdx + 1}
                                     />
                                     />
                                     <div className="text-[9px] text-white font-bold truncate">
                                     <div className="text-[9px] text-white font-bold truncate">
                                       {tray?.tray_type || '—'}
                                       {tray?.tray_type || '—'}
@@ -3107,13 +3319,12 @@ function PrinterCard({
                           <div
                           <div
                             className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}
                             className={`bg-bambu-dark-tertiary rounded p-1 text-center ${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',
-                              }}
+                            {/* Filament color circle with 1-based slot number centered inside */}
+                            <FilamentSlotCircle
+                              trayColor={tray?.tray_color}
+                              trayType={tray?.tray_type}
+                              isEmpty={isEmpty}
+                              slotNumber={1}
                             />
                             />
                             <div className="text-[9px] text-white font-bold truncate">
                             <div className="text-[9px] text-white font-bold truncate">
                               {tray?.tray_type || '—'}
                               {tray?.tray_type || '—'}
@@ -3137,9 +3348,19 @@ function PrinterCard({
                           <div key={ams.id} className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
                           <div key={ams.id} className="p-2.5 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary/30">
                             {/* Row 1: Label + Nozzle */}
                             {/* Row 1: Label + Nozzle */}
                             <div className="flex items-center gap-1 mb-2">
                             <div className="flex items-center gap-1 mb-2">
-                              <span className="text-[10px] text-white font-medium">
-                                {getAmsLabel(ams.id, ams.tray.length)}
-                              </span>
+                              {/* AMS name — hover to see serial, firmware, and edit friendly name */}
+                              <AmsNameHoverCard
+                                ams={ams}
+                                printerId={printer.id}
+                                label={getAmsLabel(ams.id, ams.tray.length)}
+                                amsLabels={amsLabels}
+                                canEdit={hasPermission('printers:update')}
+                                onSaved={refetchAmsLabels}
+                              >
+                                <span className="text-[10px] text-white font-medium cursor-default select-none">
+                                  {amsLabels?.[ams.id] || getAmsLabel(ams.id, ams.tray.length)}
+                                </span>
+                              </AmsNameHoverCard>
                               {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
                               {isDualNozzle && (isLeftNozzle || isRightNozzle) && (
                                 <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
                                 <NozzleBadge side={isLeftNozzle ? 'L' : 'R'} />
                               )}
                               )}
@@ -3361,13 +3582,12 @@ function PrinterCard({
                               const isEmpty = !extTray.tray_type;
                               const isEmpty = !extTray.tray_type;
                               const extSlotContent = (
                               const extSlotContent = (
                                 <div className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
                                 <div className={`bg-bambu-dark-tertiary rounded p-1 text-center ${isEmpty ? 'opacity-50' : ''} ${isExtActive ? 'ring-2 ring-bambu-green ring-offset-1 ring-offset-bambu-dark' : ''}`}>
-                                  <div
-                                    className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2"
-                                    style={{
-                                      backgroundColor: extTray.tray_color ? `#${extTray.tray_color}` : (extTray.tray_type ? '#333' : 'transparent'),
-                                      borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
-                                      borderStyle: isEmpty ? 'dashed' : 'solid',
-                                    }}
+                                  {/* Filament color circle with 1-based slot number centered inside */}
+                                  <FilamentSlotCircle
+                                    trayColor={extTray.tray_color}
+                                    trayType={extTray.tray_type}
+                                    isEmpty={isEmpty}
+                                    slotNumber={slotTrayId + 1}
                                   />
                                   />
                                   <div className={`text-[9px] font-bold truncate ${isEmpty ? 'text-white/40' : 'text-white'}`}>
                                   <div className={`text-[9px] font-bold truncate ${isEmpty ? 'text-white/40' : 'text-white'}`}>
                                     {extTray.tray_type || '—'}
                                     {extTray.tray_type || '—'}