maziggy 3 months ago
parent
commit
1f0931e00c

+ 8 - 2
backend/app/api/routes/inventory.py

@@ -613,7 +613,10 @@ 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."""
-    query = select(SpoolAssignment).options(selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles))
+    query = select(SpoolAssignment).options(
+        selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
+        selectinload(SpoolAssignment.printer),
+    )
     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)
@@ -745,7 +748,10 @@ async def assign_spool(
     # Return assignment with spool data
     # Return assignment with spool data
     result = await db.execute(
     result = await db.execute(
         select(SpoolAssignment)
         select(SpoolAssignment)
-        .options(selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles))
+        .options(
+            selectinload(SpoolAssignment.spool).selectinload(Spool.k_profiles),
+            selectinload(SpoolAssignment.printer),
+        )
         .where(SpoolAssignment.id == assignment.id)
         .where(SpoolAssignment.id == assignment.id)
     )
     )
     resp = result.scalar_one()
     resp = result.scalar_one()

+ 317 - 0
backend/app/core/bambu_colors.py

@@ -0,0 +1,317 @@
+"""Bambu Lab filament color code to color name mapping.
+
+Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
+
+Maps tray_id_name codes (e.g. "A06-D0") to human-readable color names (e.g. "Titan Gray").
+"""
+
+# Full color code → name mapping by material prefix
+BAMBU_FILAMENT_COLORS: dict[str, str] = {
+    # PLA Basic (A00)
+    "A00-W1": "Jade White",
+    "A00-P0": "Beige",
+    "A00-D2": "Light Gray",
+    "A00-Y0": "Yellow",
+    "A00-Y2": "Sunflower Yellow",
+    "A00-A1": "Pumpkin Orange",
+    "A00-A0": "Orange",
+    "A00-Y4": "Gold",
+    "A00-G3": "Bright Green",
+    "A00-G1": "Bambu Green",
+    "A00-G2": "Mistletoe Green",
+    "A00-R3": "Hot Pink",
+    "A00-P6": "Magenta",
+    "A00-R0": "Red",
+    "A00-R2": "Maroon Red",
+    "A00-P5": "Purple",
+    "A00-P2": "Indigo Purple",
+    "A00-B5": "Turquoise",
+    "A00-B8": "Cyan",
+    "A00-B3": "Cobalt Blue",
+    "A00-N0": "Brown",
+    "A00-N1": "Cocoa Brown",
+    "A00-Y3": "Bronze",
+    "A00-D0": "Gray",
+    "A00-D1": "Silver",
+    "A00-B1": "Blue Grey",
+    "A00-D3": "Dark Gray",
+    "A00-K0": "Black",
+    # PLA Basic Gradient (A00-M*)
+    "A00-M3": "Pink Citrus",
+    "A00-M6": "Dusk Glare",
+    "A00-M0": "Arctic Whisper",
+    "A00-M1": "Solar Breeze",
+    "A00-M5": "Blueberry Bubblegum",
+    "A00-M4": "Mint Lime",
+    "A00-M2": "Ocean to Meadow",
+    "A00-M7": "Cotton Candy Cloud",
+    # PLA Lite (A18)
+    "A18-K0": "Black",
+    "A18-D0": "Gray",
+    "A18-W0": "White",
+    "A18-R0": "Red",
+    "A18-Y0": "Yellow",
+    "A18-B0": "Cyan",
+    "A18-B1": "Blue",
+    "A18-P0": "Matte Beige",
+    # PLA Matte (A01)
+    "A01-W2": "Ivory White",
+    "A01-W3": "Bone White",
+    "A01-Y2": "Lemon Yellow",
+    "A01-A2": "Mandarin Orange",
+    "A01-P3": "Sakura Pink",
+    "A01-P4": "Lilac Purple",
+    "A01-R3": "Plum",
+    "A01-R1": "Scarlet Red",
+    "A01-R4": "Dark Red",
+    "A01-G0": "Apple Green",
+    "A01-G1": "Grass Green",
+    "A01-G7": "Dark Green",
+    "A01-B4": "Ice Blue",
+    "A01-B0": "Sky Blue",
+    "A01-B3": "Marine Blue",
+    "A01-B6": "Dark Blue",
+    "A01-Y3": "Desert Tan",
+    "A01-N1": "Latte Brown",
+    "A01-N3": "Caramel",
+    "A01-R2": "Terracotta",
+    "A01-N2": "Dark Brown",
+    "A01-N0": "Dark Chocolate",
+    "A01-D3": "Ash Gray",
+    "A01-D0": "Nardo Gray",
+    "A01-K1": "Charcoal",
+    # PLA Glow (A12)
+    "A12-G0": "Green",
+    "A12-R0": "Pink",
+    "A12-A0": "Orange",
+    "A12-Y0": "Yellow",
+    "A12-B0": "Blue",
+    # PLA Marble (A07)
+    "A07-R5": "Red Granite",
+    "A07-D4": "White Marble",
+    # PLA Aero (A11)
+    "A11-W0": "White",
+    "A11-K0": "Black",
+    # PLA Sparkle (A08)
+    "A08-G3": "Alpine Green Sparkle",
+    "A08-D5": "Slate Gray Sparkle",
+    "A08-B7": "Royal Purple Sparkle",
+    "A08-R2": "Crimson Red Sparkle",
+    "A08-K2": "Onyx Black Sparkle",
+    "A08-Y1": "Classic Gold Sparkle",
+    # PLA Metal (A02)
+    "A02-B2": "Cobalt Blue Metallic",
+    "A02-G2": "Oxide Green Metallic",
+    "A02-Y1": "Iridium Gold Metallic",
+    "A02-D2": "Iron Gray Metallic",
+    # PLA Translucent (A17)
+    "A17-B1": "Blue",
+    "A17-A0": "Orange",
+    "A17-P0": "Purple",
+    # PLA Silk+ (A06)
+    "A06-Y1": "Gold",
+    "A06-D0": "Titan Gray",
+    "A06-D1": "Silver",
+    "A06-W0": "White",
+    "A06-R0": "Candy Red",
+    "A06-G0": "Candy Green",
+    "A06-G1": "Mint",
+    "A06-B1": "Blue",
+    "A06-B0": "Baby Blue",
+    "A06-P0": "Purple",
+    "A06-R1": "Rose Gold",
+    "A06-R2": "Pink",
+    "A06-Y0": "Champagne",
+    # PLA Silk Multi-Color (A05)
+    "A05-M8": "Dawn Radiance",
+    "A05-M4": "Aurora Purple",
+    "A05-M1": "South Beach",
+    "A05-T3": "Neon City",
+    "A05-T2": "Midnight Blaze",
+    "A05-T1": "Gilded Rose",
+    "A05-T4": "Blue Hawaii",
+    "A05-T5": "Velvet Eclipse",
+    # PLA Galaxy (A15)
+    "A15-B0": "Purple",
+    "A15-G0": "Green",
+    "A15-G1": "Nebulae",
+    "A15-R0": "Brown",
+    # PLA Wood (A16)
+    "A16-K0": "Black Walnut",
+    "A16-R0": "Rosewood",
+    "A16-N0": "Clay Brown",
+    "A16-G0": "Classic Birch",
+    "A16-W0": "White Oak",
+    "A16-Y0": "Ochre Yellow",
+    # PLA-CF (A50)
+    "A50-D6": "Lava Gray",
+    "A50-K0": "Black",
+    "A50-B6": "Royal Blue",
+    # PLA Tough+ (A10)
+    "A10-W0": "White",
+    "A10-D0": "Gray",
+    # PLA Tough (A09)
+    "A09-B5": "Lavender Blue",
+    "A09-B4": "Light Blue",
+    "A09-A0": "Orange",
+    "A09-D1": "Silver",
+    "A09-R3": "Vermilion Red",
+    "A09-Y0": "Yellow",
+    # PETG HF (G02)
+    "G02-K0": "Black",
+    "G02-W0": "White",
+    "G02-R0": "Red",
+    "G02-D0": "Gray",
+    "G02-D1": "Dark Gray",
+    "G02-Y1": "Cream",
+    "G02-Y0": "Yellow",
+    "G02-A0": "Orange",
+    "G02-N1": "Peanut Brown",
+    "G02-G1": "Lime Green",
+    "G02-G0": "Green",
+    "G02-G2": "Forest Green",
+    "G02-B1": "Lake Blue",
+    "G02-B0": "Blue",
+    # PETG Translucent (G01)
+    "G01-G1": "Translucent Teal",
+    "G01-B0": "Translucent Light Blue",
+    "G01-C0": "Clear",
+    "G01-D0": "Translucent Gray",
+    "G01-G0": "Translucent Olive",
+    "G01-N0": "Translucent Brown",
+    "G01-A0": "Translucent Orange",
+    "G01-P1": "Translucent Pink",
+    "G01-P0": "Translucent Purple",
+    # PETG-CF (G50)
+    "G50-P7": "Violet Purple",
+    "G50-K0": "Black",
+    # ABS (B00)
+    "B00-D1": "Silver",
+    "B00-K0": "Black",
+    "B00-W0": "White",
+    "B00-G6": "Bambu Green",
+    "B00-G7": "Olive",
+    "B00-Y1": "Tangerine Yellow",
+    "B00-A0": "Orange",
+    "B00-R0": "Red",
+    "B00-B4": "Azure",
+    "B00-B0": "Blue",
+    "B00-B6": "Navy Blue",
+    # ABS-GF (B50)
+    "B50-A0": "Orange",
+    "B50-K0": "Black",
+    # ASA (B01)
+    "B01-W0": "White",
+    "B01-K0": "Black",
+    "B01-D0": "Gray",
+    # ASA Aero (B02)
+    "B02-W0": "White",
+    # PC (C00)
+    "C00-C1": "Transparent",
+    "C00-C0": "Clear Black",
+    "C00-K0": "Black",
+    "C00-W0": "White",
+    # PC FR (C01)
+    "C01-K0": "Black",
+    # TPU for AMS (U02)
+    "U02-B0": "Blue",
+    "U02-D0": "Gray",
+    "U02-K0": "Black",
+    # PAHT-CF (N04)
+    "N04-K0": "Black",
+    # PA6-GF (N08)
+    "N08-K0": "Black",
+    # Support for PLA/PETG (S02, S05)
+    "S02-W0": "Nature",
+    "S02-W1": "White",
+    "S05-C0": "Black",
+    # Support for ABS (S06)
+    "S06-W0": "White",
+    # Support for PA/PET (S03)
+    "S03-G1": "Green",
+    # PVA (S04)
+    "S04-Y0": "Clear",
+}
+
+# Fallback: color code suffix → name (for unknown material prefixes)
+BAMBU_COLOR_CODE_FALLBACK: dict[str, str] = {
+    "W0": "White",
+    "W1": "Jade White",
+    "W2": "Ivory White",
+    "W3": "Bone White",
+    "Y0": "Yellow",
+    "Y1": "Gold",
+    "Y2": "Sunflower Yellow",
+    "Y3": "Bronze",
+    "Y4": "Gold",
+    "A0": "Orange",
+    "A1": "Pumpkin Orange",
+    "A2": "Mandarin Orange",
+    "R0": "Red",
+    "R1": "Scarlet Red",
+    "R2": "Maroon Red",
+    "R3": "Hot Pink",
+    "R4": "Dark Red",
+    "R5": "Red Granite",
+    "P0": "Beige",
+    "P1": "Pink",
+    "P2": "Indigo Purple",
+    "P3": "Sakura Pink",
+    "P4": "Lilac Purple",
+    "P5": "Purple",
+    "P6": "Magenta",
+    "P7": "Violet Purple",
+    "B0": "Blue",
+    "B1": "Blue Grey",
+    "B2": "Cobalt Blue",
+    "B3": "Cobalt Blue",
+    "B4": "Ice Blue",
+    "B5": "Turquoise",
+    "B6": "Navy Blue",
+    "B7": "Royal Purple",
+    "B8": "Cyan",
+    "G0": "Green",
+    "G1": "Grass Green",
+    "G2": "Mistletoe Green",
+    "G3": "Bright Green",
+    "G6": "Bambu Green",
+    "G7": "Dark Green",
+    "N0": "Brown",
+    "N1": "Peanut Brown",
+    "N2": "Dark Brown",
+    "N3": "Caramel",
+    "D0": "Gray",
+    "D1": "Silver",
+    "D2": "Light Gray",
+    "D3": "Dark Gray",
+    "D4": "White Marble",
+    "D5": "Slate Gray",
+    "D6": "Lava Gray",
+    "K0": "Black",
+    "K1": "Charcoal",
+    "K2": "Onyx Black",
+    "C0": "Clear Black",
+    "C1": "Transparent",
+}
+
+
+def resolve_bambu_color_name(tray_id_name: str) -> str | None:
+    """Resolve a Bambu Lab tray_id_name code to a human-readable color name.
+
+    Tries exact match first, then falls back to color code suffix lookup.
+    Returns None if the code cannot be resolved.
+    """
+    if not tray_id_name:
+        return None
+
+    # Exact match
+    name = BAMBU_FILAMENT_COLORS.get(tray_id_name)
+    if name:
+        return name
+
+    # Fallback: use color code suffix (e.g. "D0" from "A06-D0")
+    parts = tray_id_name.split("-")
+    if len(parts) >= 2:
+        return BAMBU_COLOR_CODE_FALLBACK.get(parts[1])
+
+    return None

+ 26 - 3
backend/app/main.py

@@ -596,11 +596,34 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         tray_info_idx = tray.get("tray_info_idx", "")
                         tray_info_idx = tray.get("tray_info_idx", "")
                         if not tray.get("tray_type"):
                         if not tray.get("tray_type"):
                             continue  # Empty slot
                             continue  # Empty slot
-                        # Skip if assignment already exists for this slot
+                        # Check if assignment already exists for this slot
                         existing = await db.execute(
                         existing = await db.execute(
-                            select(SA).where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == tray_id)
+                            select(SA)
+                            .options(selectinload(SA.spool))
+                            .where(SA.printer_id == printer_id, SA.ams_id == ams_id, SA.tray_id == tray_id)
                         )
                         )
-                        if existing.scalar_one_or_none():
+                        existing_assignment = existing.scalar_one_or_none()
+                        if existing_assignment:
+                            # Sync spool weight_used from AMS remain if valid
+                            remain_raw = tray.get("remain")
+                            if remain_raw is not None and existing_assignment.spool:
+                                try:
+                                    remain_val = int(remain_raw)
+                                except (TypeError, ValueError):
+                                    remain_val = -1
+                                if 0 <= remain_val <= 100:
+                                    lw = existing_assignment.spool.label_weight or 1000
+                                    new_used = round(lw * (100 - remain_val) / 100.0, 1)
+                                    if abs((existing_assignment.spool.weight_used or 0) - new_used) > 1:
+                                        logger.info(
+                                            "Weight sync: spool %d weight_used %s -> %s (remain=%d)",
+                                            existing_assignment.spool_id,
+                                            existing_assignment.spool.weight_used,
+                                            new_used,
+                                            remain_val,
+                                        )
+                                        existing_assignment.spool.weight_used = new_used
+                                        await db.commit()
                             continue
                             continue
 
 
                         if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):
                         if is_bambu_tag(tag_uid, tray_uuid, tray_info_idx):

+ 5 - 0
backend/app/models/spool_assignment.py

@@ -25,6 +25,11 @@ class SpoolAssignment(Base):
 
 
     __table_args__ = (UniqueConstraint("printer_id", "ams_id", "tray_id"),)
     __table_args__ = (UniqueConstraint("printer_id", "ams_id", "tray_id"),)
 
 
+    @property
+    def printer_name(self) -> str | None:
+        """Get printer name from loaded relationship."""
+        return self.printer.name if self.printer else None
+
 
 
 from backend.app.models.printer import Printer  # noqa: E402, F401
 from backend.app.models.printer import Printer  # noqa: E402, F401
 from backend.app.models.spool import Spool  # noqa: E402, F401
 from backend.app.models.spool import Spool  # noqa: E402, F401

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

@@ -96,6 +96,7 @@ class SpoolAssignmentResponse(BaseModel):
     id: int
     id: int
     spool_id: int
     spool_id: int
     printer_id: int
     printer_id: int
+    printer_name: str | None = None
     ams_id: int
     ams_id: int
     tray_id: int
     tray_id: int
     fingerprint_color: str | None = None
     fingerprint_color: str | None = None

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

@@ -1062,7 +1062,9 @@ class BambuMQTTClient:
             self._previous_ams_hash = ams_hash
             self._previous_ams_hash = ams_hash
             if self.on_ams_change:
             if self.on_ams_change:
                 logger.info("[%s] AMS data changed, triggering sync callback", self.serial_number)
                 logger.info("[%s] AMS data changed, triggering sync callback", self.serial_number)
-                self.on_ams_change(ams_list)
+                # Pass merged AMS data (not raw ams_list) — partial MQTT updates
+                # may lack fields like 'remain' that the merged state preserves
+                self.on_ams_change(merged_ams)
 
 
     def _update_state(self, data: dict):
     def _update_state(self, data: dict):
         """Update printer state from message data."""
         """Update printer state from message data."""

+ 75 - 11
backend/app/services/firmware_check.py

@@ -1,8 +1,9 @@
 """
 """
 Firmware Check Service
 Firmware Check Service
 
 
-Checks for firmware updates by fetching from Bambu Lab's official firmware download page.
-Also provides firmware download functionality for offline updates.
+Checks for firmware updates by fetching from Bambu Lab's official wiki and firmware
+download page. The wiki is used as the primary version source (always up-to-date),
+while the download page provides firmware file URLs for offline updates.
 """
 """
 
 
 import logging
 import logging
@@ -18,10 +19,13 @@ from backend.app.core.config import _data_dir
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
-# Bambu Lab firmware download page
+# Bambu Lab firmware download page (for download URLs)
 BAMBU_FIRMWARE_BASE = "https://bambulab.com"
 BAMBU_FIRMWARE_BASE = "https://bambulab.com"
 FIRMWARE_PAGE = "/en/support/firmware-download/all"
 FIRMWARE_PAGE = "/en/support/firmware-download/all"
 
 
+# Bambu Lab wiki (primary source for latest version detection)
+BAMBU_WIKI_BASE = "https://wiki.bambulab.com"
+
 # Cache TTL in seconds (1 hour)
 # Cache TTL in seconds (1 hour)
 CACHE_TTL = 3600
 CACHE_TTL = 3600
 
 
@@ -61,6 +65,20 @@ API_KEY_TO_DEV_MODEL = {
     "h2d-pro": "O1E",
     "h2d-pro": "O1E",
 }
 }
 
 
+# Wiki firmware release history pages (primary version source)
+API_KEY_TO_WIKI_PATH = {
+    "x1": "/en/x1/manual/X1-X1C-firmware-release-history",
+    "x1e": "/en/x1/manual/X1E-firmware-release-history",
+    "p1": "/en/p1/manual/p1p-firmware-release-history",
+    "a1": "/en/a1/manual/a1-firmware-release-history",
+    "a1-mini": "/en/a1-mini/manual/a1-mini-firmware-release-history",
+    "h2d": "/en/h2d/manual/h2d-firmware-release-history",
+    "h2c": "/en/h2c/manual/h2c-firmware-release-history",
+    "h2s": "/en/h2s/manual/h2s-firmware-release-history",
+    "p2s": "/en/p2s/manual/p2s-firmware-release-history",
+    "h2d-pro": "/en/h2d-pro/manual/firmware-release-history",
+}
+
 
 
 @dataclass
 @dataclass
 class FirmwareVersion:
 class FirmwareVersion:
@@ -109,11 +127,34 @@ class FirmwareCheckService:
 
 
         return self._build_id  # Return cached value if available
         return self._build_id  # Return cached value if available
 
 
-    async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
-        """Fetch firmware versions for a specific printer from Bambu Lab API."""
+    async def _fetch_version_from_wiki(self, api_key: str) -> str | None:
+        """Fetch the latest firmware version from Bambu Lab's wiki release history page."""
+        wiki_path = API_KEY_TO_WIKI_PATH.get(api_key)
+        if not wiki_path:
+            return None
+
+        try:
+            url = f"{BAMBU_WIKI_BASE}{wiki_path}"
+            response = await self._client.get(url, follow_redirects=True)
+
+            if response.status_code == 200:
+                # Extract version strings (format: XX.XX.XX.XX), first match is the latest
+                versions = re.findall(r"(\d{2}\.\d{2}\.\d{2}\.\d{2})", response.text)
+                if versions:
+                    logger.debug("Wiki firmware for %s: %s", api_key, versions[0])
+                    return versions[0]
+            else:
+                logger.debug("Wiki firmware page for %s returned %s", api_key, response.status_code)
+
+        except Exception as e:
+            logger.debug("Error fetching wiki firmware for %s: %s", api_key, e)
+
+        return None
+
+    async def _fetch_from_download_page(self, api_key: str) -> FirmwareVersion | None:
+        """Fetch firmware info from Bambu Lab's download page (has download URLs)."""
         build_id = await self._get_build_id()
         build_id = await self._get_build_id()
         if not build_id:
         if not build_id:
-            logger.warning("No build ID available, cannot fetch firmware versions")
             return None
             return None
 
 
         try:
         try:
@@ -135,16 +176,39 @@ class FirmwareCheckService:
                         release_notes=latest.get("release_notes_en"),
                         release_notes=latest.get("release_notes_en"),
                         release_time=latest.get("release_time"),
                         release_time=latest.get("release_time"),
                     )
                     )
-            else:
-                # api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
-                logger.warning("Failed to fetch firmware for %s: %s", api_key, response.status_code)
 
 
         except Exception as e:
         except Exception as e:
-            # api_key is a printer model identifier (e.g. "x1", "p1"), not a secret
-            logger.error("Error fetching firmware for %s: %s", api_key, e)
+            logger.debug("Error fetching download page firmware for %s: %s", api_key, e)
 
 
         return None
         return None
 
 
+    async def _fetch_firmware_versions(self, api_key: str) -> FirmwareVersion | None:
+        """Fetch firmware version info, using wiki as primary source and download page as fallback."""
+        # Try wiki first (always has the latest version)
+        wiki_version = await self._fetch_version_from_wiki(api_key)
+
+        # Try download page (has download URLs, may lag behind wiki)
+        download_info = await self._fetch_from_download_page(api_key)
+
+        if wiki_version:
+            # Wiki has the latest version — use it, attach download URL if available
+            download_url = ""
+            release_notes = None
+            if download_info and download_info.version == wiki_version:
+                download_url = download_info.download_url
+                release_notes = download_info.release_notes
+            return FirmwareVersion(
+                version=wiki_version,
+                download_url=download_url,
+                release_notes=release_notes,
+            )
+
+        if download_info:
+            return download_info
+
+        logger.warning("Could not fetch firmware info for %s from wiki or download page", api_key)
+        return None
+
     async def get_latest_version(self, model: str) -> FirmwareVersion | None:
     async def get_latest_version(self, model: str) -> FirmwareVersion | None:
         """
         """
         Get the latest firmware version for a printer model.
         Get the latest firmware version for a printer model.

+ 42 - 5
backend/app/services/spool_tag_matcher.py

@@ -63,11 +63,23 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
     elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
     elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
         material = tray_sub_brands
         material = tray_sub_brands
 
 
-    # Try to find color name from color catalog
-    color_name = tray_id_name or None
-    rgba = tray_color if tray_color else None
+    # Resolve color name from tray_id_name code, hex catalog, or raw tray_id_name
+    from backend.app.core.bambu_colors import resolve_bambu_color_name
 
 
-    # Look up color catalog for a better color name if we only have hex
+    rgba = tray_color if tray_color else None
+    color_name = None
+
+    # 1. Try Bambu color code mapping (e.g. "A06-D0" → "Titan Gray")
+    if tray_id_name:
+        color_name = resolve_bambu_color_name(tray_id_name)
+        logger.info("Color resolve: tray_id_name=%r → resolved=%r", tray_id_name, color_name)
+        # If not a known code, use tray_id_name directly (it may be a readable name)
+        if not color_name and "-" not in tray_id_name:
+            color_name = tray_id_name
+    else:
+        logger.info("Color resolve: tray_id_name is empty, rgba=%r", rgba)
+
+    # 2. Try color catalog lookup by hex color
     if not color_name and rgba and len(rgba) >= 6:
     if not color_name and rgba and len(rgba) >= 6:
         hex_prefix = f"#{rgba[:6].upper()}"
         hex_prefix = f"#{rgba[:6].upper()}"
         cat_result = await db.execute(
         cat_result = await db.execute(
@@ -88,6 +100,30 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         core_weight = entry.weight
         core_weight = entry.weight
         break
         break
 
 
+    # Resolve slicer filament name from builtin table
+    slicer_filament_name = None
+    if tray_info_idx:
+        try:
+            from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
+
+            slicer_filament_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx)
+        except Exception:
+            pass
+        # Fallback: use tray_sub_brands as the display name
+        if not slicer_filament_name and tray_sub_brands:
+            slicer_filament_name = tray_sub_brands
+
+    # Calculate initial weight_used from AMS remain percentage
+    remain_raw = tray_data.get("remain")
+    try:
+        remain_pct = int(remain_raw) if remain_raw is not None else 100
+    except (TypeError, ValueError):
+        remain_pct = 100
+    # Clamp to valid range: negative means unknown, >100 is invalid
+    if remain_pct < 0 or remain_pct > 100:
+        remain_pct = 100  # Unknown → assume full
+    weight_used = round(label_weight * (100 - remain_pct) / 100.0, 1)
+
     spool = Spool(
     spool = Spool(
         material=material,
         material=material,
         subtype=subtype,
         subtype=subtype,
@@ -96,8 +132,9 @@ async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
         brand="Bambu Lab",
         brand="Bambu Lab",
         label_weight=label_weight,
         label_weight=label_weight,
         core_weight=core_weight,
         core_weight=core_weight,
-        weight_used=0,
+        weight_used=weight_used,
         slicer_filament=tray_info_idx or None,
         slicer_filament=tray_info_idx or None,
+        slicer_filament_name=slicer_filament_name,
         nozzle_temp_min=int(nozzle_min) if nozzle_min else None,
         nozzle_temp_min=int(nozzle_min) if nozzle_min else None,
         nozzle_temp_max=int(nozzle_max) if nozzle_max else None,
         nozzle_temp_max=int(nozzle_max) if nozzle_max else None,
         tag_uid=tag_uid if tag_uid and tag_uid != ZERO_TAG_UID else None,
         tag_uid=tag_uid if tag_uid and tag_uid != ZERO_TAG_UID else None,

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

@@ -1784,6 +1784,7 @@ export interface SpoolAssignment {
   id: number;
   id: number;
   spool_id: number;
   spool_id: number;
   printer_id: number;
   printer_id: number;
+  printer_name: string | null;
   ams_id: number;
   ams_id: number;
   tray_id: number;
   tray_id: number;
   fingerprint_color: string | null;
   fingerprint_color: string | null;

+ 4 - 1
frontend/src/components/FilamentHoverCard.tsx

@@ -10,7 +10,7 @@ interface FilamentData {
   kFactor: string;
   kFactor: string;
   fillLevel: number | null; // null = unknown
   fillLevel: number | null; // null = unknown
   trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
   trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
-  fillSource?: 'ams' | 'spoolman'; // Source of fill level data
+  fillSource?: 'ams' | 'spoolman' | 'inventory'; // Source of fill level data
 }
 }
 
 
 interface SpoolmanConfig {
 interface SpoolmanConfig {
@@ -242,6 +242,9 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     {data.fillSource === 'spoolman' && data.fillLevel !== null && (
                     {data.fillSource === 'spoolman' && data.fillLevel !== null && (
                       <span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
                       <span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
                     )}
                     )}
+                    {data.fillSource === 'inventory' && data.fillLevel !== null && (
+                      <span className="text-[9px] text-bambu-gray font-normal">{t('inventory.fillSourceLabel')}</span>
+                    )}
                   </span>
                   </span>
                 </div>
                 </div>
                 {/* Fill bar */}
                 {/* Fill bar */}

+ 2 - 2
frontend/src/hooks/useWebSocket.ts

@@ -228,9 +228,9 @@ export function useWebSocket() {
         break;
         break;
 
 
       case 'spool_auto_assigned':
       case 'spool_auto_assigned':
-        // RFID tag matched - refresh inventory data
+        // RFID tag matched - refresh inventory and assignment data
         debouncedInvalidate('inventory-spools');
         debouncedInvalidate('inventory-spools');
-        debouncedInvalidate('inventory-assignments');
+        debouncedInvalidate('spool-assignments');
         break;
         break;
 
 
       case 'spool_usage_logged':
       case 'spool_usage_logged':

+ 2 - 1
frontend/src/i18n/locales/de.ts

@@ -2447,7 +2447,7 @@ export default {
     coreWeight: 'Leergewicht der Spule',
     coreWeight: 'Leergewicht der Spule',
     searchSpoolWeight: 'Spulengewicht suchen...',
     searchSpoolWeight: 'Spulengewicht suchen...',
     weightUsed: 'Verbraucht',
     weightUsed: 'Verbraucht',
-    slicerFilament: 'Slicer-Filament-ID',
+    slicerFilament: 'Slicer-Filament',
     slicerFilamentName: 'Slicer-Preset-Name',
     slicerFilamentName: 'Slicer-Preset-Name',
     slicerPreset: 'Slicer-Preset',
     slicerPreset: 'Slicer-Preset',
     searchPresets: 'Filament-Presets suchen...',
     searchPresets: 'Filament-Presets suchen...',
@@ -2565,6 +2565,7 @@ export default {
     weightConsumed: 'Verbrauchtes Gewicht',
     weightConsumed: 'Verbrauchtes Gewicht',
     clearHistory: 'Löschen',
     clearHistory: 'Löschen',
     historyCleared: 'Verbrauchshistorie gelöscht',
     historyCleared: 'Verbrauchshistorie gelöscht',
+    fillSourceLabel: '(Inv)',
   },
   },
 
 
   // Timelapse
   // Timelapse

+ 2 - 1
frontend/src/i18n/locales/en.ts

@@ -2447,7 +2447,7 @@ export default {
     coreWeight: 'Empty Spool Weight',
     coreWeight: 'Empty Spool Weight',
     searchSpoolWeight: 'Search spool weight...',
     searchSpoolWeight: 'Search spool weight...',
     weightUsed: 'Used',
     weightUsed: 'Used',
-    slicerFilament: 'Slicer Filament ID',
+    slicerFilament: 'Slicer Filament',
     slicerFilamentName: 'Slicer Preset Name',
     slicerFilamentName: 'Slicer Preset Name',
     slicerPreset: 'Slicer Preset',
     slicerPreset: 'Slicer Preset',
     searchPresets: 'Search filament presets...',
     searchPresets: 'Search filament presets...',
@@ -2569,6 +2569,7 @@ export default {
     weightConsumed: 'Weight Consumed',
     weightConsumed: 'Weight Consumed',
     clearHistory: 'Clear',
     clearHistory: 'Clear',
     historyCleared: 'Usage history cleared',
     historyCleared: 'Usage history cleared',
+    fillSourceLabel: '(Inv)',
   },
   },
 
 
   // Timelapse
   // Timelapse

+ 2 - 1
frontend/src/i18n/locales/ja.ts

@@ -2378,7 +2378,7 @@ export default {
     coreWeight: '空スプール重量',
     coreWeight: '空スプール重量',
     searchSpoolWeight: 'スプール重量を検索...',
     searchSpoolWeight: 'スプール重量を検索...',
     weightUsed: '使用量',
     weightUsed: '使用量',
-    slicerFilament: 'スライサーフィラメントID',
+    slicerFilament: 'スライサーフィラメント',
     slicerFilamentName: 'スライサープリセット名',
     slicerFilamentName: 'スライサープリセット名',
     slicerPreset: 'スライサープリセット',
     slicerPreset: 'スライサープリセット',
     searchPresets: 'フィラメントプリセットを検索...',
     searchPresets: 'フィラメントプリセットを検索...',
@@ -2494,6 +2494,7 @@ export default {
     weightConsumed: '消費重量',
     weightConsumed: '消費重量',
     clearHistory: 'クリア',
     clearHistory: 'クリア',
     historyCleared: '使用履歴がクリアされました',
     historyCleared: '使用履歴がクリアされました',
+    fillSourceLabel: '(Inv)',
   },
   },
   timelapse: {
   timelapse: {
     download: 'ダウンロード',
     download: 'ダウンロード',

+ 10 - 5
frontend/src/pages/InventoryPage.tsx

@@ -12,6 +12,7 @@ import { Button } from '../components/Button';
 import { SpoolFormModal } from '../components/SpoolFormModal';
 import { SpoolFormModal } from '../components/SpoolFormModal';
 import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
 import { ColumnConfigModal, type ColumnConfig } from '../components/ColumnConfigModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { resolveSpoolColorName } from '../utils/colors';
 
 
 type ArchiveFilter = 'active' | 'archived';
 type ArchiveFilter = 'active' | 'archived';
 type UsageFilter = 'all' | 'used' | 'new';
 type UsageFilter = 'all' | 'used' | 'new';
@@ -149,12 +150,12 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
     <span className="text-sm text-bambu-gray">{spool.last_used ? formatDate(spool.last_used) : 'Never'}</span>
     <span className="text-sm text-bambu-gray">{spool.last_used ? formatDate(spool.last_used) : 'Never'}</span>
   ),
   ),
   rgba: ({ spool }) => (
   rgba: ({ spool }) => (
-    <div className="flex items-center gap-2">
+    <div className="flex items-center justify-center">
       <span
       <span
         className="w-5 h-5 rounded-full border border-white/20 flex-shrink-0"
         className="w-5 h-5 rounded-full border border-white/20 flex-shrink-0"
         style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}
         style={{ backgroundColor: spool.rgba ? `#${spool.rgba.substring(0, 6)}` : '#808080' }}
+        title={spool.rgba ? `#${spool.rgba.substring(0, 6)}` : undefined}
       />
       />
-      <span className="text-sm text-white">{spool.color_name || '-'}</span>
     </div>
     </div>
   ),
   ),
   material: ({ spool }) => (
   material: ({ spool }) => (
@@ -164,7 +165,7 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
     <span className="text-sm text-bambu-gray">{spool.subtype || '-'}</span>
     <span className="text-sm text-bambu-gray">{spool.subtype || '-'}</span>
   ),
   ),
   color_name: ({ spool }) => (
   color_name: ({ spool }) => (
-    <span className="text-sm text-bambu-gray">{spool.color_name || '-'}</span>
+    <span className="text-sm text-bambu-gray">{resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}</span>
   ),
   ),
   brand: ({ spool }) => (
   brand: ({ spool }) => (
     <span className="text-sm text-bambu-gray">{spool.brand || '-'}</span>
     <span className="text-sm text-bambu-gray">{spool.brand || '-'}</span>
@@ -177,9 +178,13 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
   location: ({ spool, assignmentMap }) => {
   location: ({ spool, assignmentMap }) => {
     const assignment = assignmentMap[spool.id];
     const assignment = assignmentMap[spool.id];
     if (!assignment) return <span className="text-sm text-bambu-gray">-</span>;
     if (!assignment) return <span className="text-sm text-bambu-gray">-</span>;
+    const printerLabel = assignment.printer_name || `Printer ${assignment.printer_id}`;
+    // Bambu slot notation: AMS 0=A, 1=B, 2=C, 3=D; tray 0-based → 1-based
+    const slotLetter = String.fromCharCode(65 + assignment.ams_id);
+    const slotNumber = assignment.tray_id + 1;
     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">
-        AMS {assignment.ams_id} T{assignment.tray_id}
+        {printerLabel} {slotLetter}{slotNumber}
       </span>
       </span>
     );
     );
   },
   },
@@ -701,7 +706,7 @@ export default function InventoryPage() {
                     {/* Color header */}
                     {/* Color header */}
                     <div className="h-14 flex items-center justify-center" style={{ backgroundColor: colorStyle }}>
                     <div className="h-14 flex items-center justify-center" style={{ backgroundColor: colorStyle }}>
                       <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
                       <span className="bg-white/90 text-gray-800 px-3 py-0.5 rounded-full text-sm font-medium">
-                        {spool.color_name || '-'}
+                        {resolveSpoolColorName(spool.color_name, spool.rgba) || '-'}
                       </span>
                       </span>
                     </div>
                     </div>
                     {/* Content */}
                     {/* Content */}

+ 41 - 9
frontend/src/pages/PrintersPage.tsx

@@ -2688,14 +2688,24 @@ function PrinterCard({
                                 // Get saved slot preset mapping (for user-configured slots)
                                 // Get saved slot preset mapping (for user-configured slots)
                                 const slotPreset = slotPresets?.[globalTrayId];
                                 const slotPreset = slotPresets?.[globalTrayId];
 
 
-                                // Spoolman fill level fallback (when AMS reports 0%)
+                                // Fill level fallback chain: AMS remain → Spoolman → Inventory spool
                                 const trayTag = tray?.tray_uuid?.toUpperCase();
                                 const trayTag = tray?.tray_uuid?.toUpperCase();
                                 const linkedSpool = trayTag ? linkedSpools?.[trayTag] : undefined;
                                 const linkedSpool = trayTag ? linkedSpools?.[trayTag] : undefined;
                                 const spoolmanFill = getSpoolmanFillLevel(linkedSpool);
                                 const spoolmanFill = getSpoolmanFillLevel(linkedSpool);
+                                const inventoryAssignment = onGetAssignment?.(printer.id, ams.id, slotIdx);
+                                const inventoryFill = (() => {
+                                  const sp = inventoryAssignment?.spool;
+                                  if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
+                                    return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
+                                  }
+                                  return null;
+                                })();
                                 const effectiveFill = hasFillLevel && tray.remain > 0
                                 const effectiveFill = hasFillLevel && tray.remain > 0
                                   ? tray.remain
                                   ? tray.remain
-                                  : (spoolmanFill ?? (hasFillLevel ? tray.remain : null));
-                                const fillSource = (hasFillLevel && tray.remain === 0 && spoolmanFill !== null) ? 'spoolman' as const : 'ams' as const;
+                                  : (spoolmanFill ?? inventoryFill ?? (hasFillLevel ? tray.remain : null));
+                                const fillSource = (hasFillLevel && tray.remain === 0 && (spoolmanFill !== null || inventoryFill !== null))
+                                  ? (spoolmanFill !== null ? 'spoolman' as const : 'inventory' as const)
+                                  : 'ams' as const;
 
 
                                 // Build filament data for hover card
                                 // Build filament data for hover card
                                 const filamentData = tray?.tray_type ? {
                                 const filamentData = tray?.tray_type ? {
@@ -2898,14 +2908,25 @@ function PrinterCard({
                         // Get saved slot preset mapping (for user-configured slots)
                         // Get saved slot preset mapping (for user-configured slots)
                         const slotPreset = slotPresets?.[globalTrayId];
                         const slotPreset = slotPresets?.[globalTrayId];
 
 
-                        // Spoolman fill level fallback (when AMS reports 0%)
+                        // Fill level fallback chain: AMS remain → Spoolman → Inventory spool
                         const htTrayTag = tray?.tray_uuid?.toUpperCase();
                         const htTrayTag = tray?.tray_uuid?.toUpperCase();
                         const htLinkedSpool = htTrayTag ? linkedSpools?.[htTrayTag] : undefined;
                         const htLinkedSpool = htTrayTag ? linkedSpools?.[htTrayTag] : undefined;
                         const htSpoolmanFill = getSpoolmanFillLevel(htLinkedSpool);
                         const htSpoolmanFill = getSpoolmanFillLevel(htLinkedSpool);
+                        const htTraySlotId = tray?.id ?? 0;
+                        const htInventoryAssignment = onGetAssignment?.(printer.id, ams.id, htTraySlotId);
+                        const htInventoryFill = (() => {
+                          const sp = htInventoryAssignment?.spool;
+                          if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
+                            return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
+                          }
+                          return null;
+                        })();
                         const htEffectiveFill = hasFillLevel && tray.remain > 0
                         const htEffectiveFill = hasFillLevel && tray.remain > 0
                           ? tray.remain
                           ? tray.remain
-                          : (htSpoolmanFill ?? (hasFillLevel ? tray.remain : null));
-                        const htFillSource = (hasFillLevel && tray.remain === 0 && htSpoolmanFill !== null) ? 'spoolman' as const : 'ams' as const;
+                          : (htSpoolmanFill ?? htInventoryFill ?? (hasFillLevel ? tray.remain : null));
+                        const htFillSource = (hasFillLevel && tray.remain === 0 && (htSpoolmanFill !== null || htInventoryFill !== null))
+                          ? (htSpoolmanFill !== null ? 'spoolman' as const : 'inventory' as const)
+                          : 'ams' as const;
 
 
                         // Build filament data for hover card
                         // Build filament data for hover card
                         const filamentData = tray?.tray_type ? {
                         const filamentData = tray?.tray_type ? {
@@ -3135,10 +3156,19 @@ function PrinterCard({
                         // Get saved slot preset mapping (external spool uses amsId=255, trayId=0)
                         // Get saved slot preset mapping (external spool uses amsId=255, trayId=0)
                         const extSlotPreset = slotPresets?.[255 * 4 + 0];
                         const extSlotPreset = slotPresets?.[255 * 4 + 0];
 
 
-                        // Spoolman fill level for external spool
+                        // Fill level fallback chain: Spoolman → Inventory spool
                         const extTrayTag = extTray.tray_uuid?.toUpperCase();
                         const extTrayTag = extTray.tray_uuid?.toUpperCase();
                         const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
                         const extLinkedSpool = extTrayTag ? linkedSpools?.[extTrayTag] : undefined;
                         const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
                         const extSpoolmanFill = getSpoolmanFillLevel(extLinkedSpool);
+                        const extInventoryAssignment = onGetAssignment?.(printer.id, 255, 0);
+                        const extInventoryFill = (() => {
+                          const sp = extInventoryAssignment?.spool;
+                          if (sp && sp.label_weight > 0 && sp.weight_used > 0) {
+                            return Math.round(Math.max(0, sp.label_weight - sp.weight_used) / sp.label_weight * 100);
+                          }
+                          return null;
+                        })();
+                        const extEffectiveFill = extSpoolmanFill ?? extInventoryFill ?? null;
 
 
                         // Build filament data for hover card
                         // Build filament data for hover card
                         const extFilamentData = {
                         const extFilamentData = {
@@ -3147,10 +3177,12 @@ function PrinterCard({
                           colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
                           colorName: getBambuColorName(extTray.tray_id_name) || hexToBasicColorName(extTray.tray_color),
                           colorHex: extTray.tray_color || null,
                           colorHex: extTray.tray_color || null,
                           kFactor: formatKValue(extTray.k),
                           kFactor: formatKValue(extTray.k),
-                          fillLevel: extSpoolmanFill, // Use Spoolman data if available
+                          fillLevel: extEffectiveFill,
                           trayUuid: extTray.tray_uuid || null,
                           trayUuid: extTray.tray_uuid || null,
                           tagUid: extTray.tag_uid || null,
                           tagUid: extTray.tag_uid || null,
-                          fillSource: extSpoolmanFill !== null ? 'spoolman' as const : undefined,
+                          fillSource: extSpoolmanFill !== null ? 'spoolman' as const
+                            : extInventoryFill !== null ? 'inventory' as const
+                            : undefined,
                         };
                         };
 
 
                         const extSlotContent = (
                         const extSlotContent = (

+ 21 - 0
frontend/src/utils/colors.ts

@@ -104,3 +104,24 @@ export function getColorName(hexColor: string): string {
   }
   }
   return hexToColorName(hexColor);
   return hexToColorName(hexColor);
 }
 }
+
+/**
+ * Resolve a spool's display color name.
+ * Tries: stored color_name (if it's a readable name) → hex color database → HSL fallback.
+ * Detects Bambu internal codes (e.g. "A06-D0") and resolves them to names ("Titan Gray").
+ */
+export function resolveSpoolColorName(colorName: string | null, rgba: string | null): string | null {
+  // If color_name looks like a readable name (no pattern like "X00-Y0"), use it directly
+  if (colorName && !/^[A-Z]\d+-[A-Z]\d+$/.test(colorName)) {
+    return colorName;
+  }
+  // Try hex color lookup from rgba
+  if (rgba && rgba.length >= 6) {
+    const hex = rgba.substring(0, 6).toLowerCase();
+    if (BAMBU_HEX_COLORS[hex]) {
+      return BAMBU_HEX_COLORS[hex];
+    }
+  }
+  // Return null (displayed as "-") — better than showing a code
+  return null;
+}

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


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-B4os6TlG.js"></script>
+    <script type="module" crossorigin src="/assets/index-C-jcYP-o.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C8xaQF5N.css">
     <link rel="stylesheet" crossorigin href="/assets/index-C8xaQF5N.css">
   </head>
   </head>
   <body>
   <body>

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