Browse Source

Add spool inventory: AMS slot assignment, usage tracking, and remaining weight editing

Built-in spool inventory with AMS slot assignment for non-BL spools,
automatic filament usage tracking via 3MF estimates, and remaining
weight editing in the spool form.

Inventory features:
- AMS slot assignment: assign/unassign manual spools to AMS tray slots
- Filter out Bambu Lab spools (RFID-managed) from assignment modal
- Filter out already-assigned spools (one spool per slot)
- Auto-unlink manual assignments when a BL spool is inserted
- Hide assign/unassign UI on BL-occupied slots
- Case-insensitive fingerprint matching for auto-unlink logic

Usage tracking:
- 3MF-based filament consumption tracking for non-BL spools
- Extract per-filament used_g from archived 3MF slice_info
- Map 3MF slot_id to AMS (ams_id, tray_id) positions
- Scale estimates by print progress for failed/aborted prints
- Skip BL spools (tracked via AMS remain% delta, Path 1)
- Prevent double-counting with handled_trays set

Other:
- Add remaining weight field to spool edit form
- Fix hardcoded "Empty" string in AssignSpoolModal (now i18n)
- Add backend unit tests for usage_tracker (11 tests)
- Add frontend tests for AssignSpoolModal filters (5 tests)
- Update CHANGELOG, README, wiki docs, and website
maziggy 3 months ago
parent
commit
e6e62f2a68

+ 5 - 0
CHANGELOG.md

@@ -4,6 +4,11 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ## [0.2.0b] - Not released
 ## [0.2.0b] - Not released
 
 
+### New Features
+- **Spool Inventory — AMS Slot Assignment** — Assign inventory spools to AMS slots for filament tracking. Hover over any non-Bambu-Lab AMS slot to assign or unassign spools. The assign modal filters out Bambu Lab spools (tracked via RFID) and spools already assigned to other slots. Bambu Lab spool slots automatically hide assign/unassign UI since they are managed by the AMS. When a Bambu Lab spool is inserted into a slot with a manual assignment, the assignment is automatically unlinked.
+- **Spool Inventory — Remaining Weight Editing** — Edit the remaining filament weight when adding or editing a spool. The new "Remaining Weight" field in the Additional section shows current weight (label weight minus consumed) with a max reference. Edits are stored as `weight_used` internally.
+- **Spool Inventory — 3MF-Based Usage Tracking for Non-BL Spools** — Non-Bambu-Lab spools (no RFID) cannot use AMS remain% for usage tracking. Now falls back to per-filament weight estimates from the archived 3MF file (`used_g` per filament slot). For completed prints, uses the full slicer estimate. For failed or aborted prints, scales by print progress percentage. Bambu Lab spools continue using AMS remain% delta tracking as before.
+
 ### Fixed
 ### Fixed
 - **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.
 - **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.
 - **H2C Nozzle Rack Text Unreadable on Light Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white "0.4" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds.
 - **H2C Nozzle Rack Text Unreadable on Light Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white "0.4" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds.

+ 5 - 0
README.md

@@ -145,6 +145,11 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Build plate detection alerts
 - Build plate detection alerts
 - Queue events (waiting, skipped, failed)
 - Queue events (waiting, skipped, failed)
 
 
+### 🧵 Spool Inventory
+- Built-in spool inventory with AMS slot assignment, usage tracking, and remaining weight management
+- Automatic filament consumption tracking: AMS RFID for Bambu Lab spools, 3MF estimates for third-party spools
+- Spool catalog, color catalog, PA profile matching, and low-stock alerts
+
 ### 🔧 Integrations
 ### 🔧 Integrations
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync with per-filament usage tracking and fill level display
 - MQTT publishing for Home Assistant, Node-RED, etc.
 - MQTT publishing for Home Assistant, Node-RED, etc.

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

@@ -645,8 +645,12 @@ async def assign_spool(
     fingerprint_type = None
     fingerprint_type = None
     state = printer_manager.get_status(data.printer_id)
     state = printer_manager.get_status(data.printer_id)
     if state and state.raw_data:
     if state and state.raw_data:
+        ams_data = state.raw_data.get("ams", {})
+        ams_list = (
+            ams_data.get("ams", []) if isinstance(ams_data, dict) else ams_data if isinstance(ams_data, list) else []
+        )
         tray = _find_tray_in_ams_data(
         tray = _find_tray_in_ams_data(
-            state.raw_data.get("ams", {}).get("ams", []),
+            ams_list,
             data.ams_id,
             data.ams_id,
             data.tray_id,
             data.tray_id,
         )
         )

+ 175 - 61
backend/app/core/catalog_defaults.py

@@ -97,90 +97,204 @@ DEFAULT_SPOOL_CATALOG: list[tuple[str, int]] = [
 
 
 # (manufacturer, color_name, hex_color, material)
 # (manufacturer, color_name, hex_color, material)
 DEFAULT_COLOR_CATALOG: list[tuple[str, str, str, str]] = [
 DEFAULT_COLOR_CATALOG: list[tuple[str, str, str, str]] = [
-    # Bambu Lab PLA Basic
+    # Bambu Lab PLA Basic (from official hex code PDF)
     ("Bambu Lab", "Jade White", "#FFFFFF", "PLA Basic"),
     ("Bambu Lab", "Jade White", "#FFFFFF", "PLA Basic"),
     ("Bambu Lab", "Black", "#000000", "PLA Basic"),
     ("Bambu Lab", "Black", "#000000", "PLA Basic"),
     ("Bambu Lab", "Silver", "#A6A9AA", "PLA Basic"),
     ("Bambu Lab", "Silver", "#A6A9AA", "PLA Basic"),
-    ("Bambu Lab", "Light Gray", "#C0C0C0", "PLA Basic"),
+    ("Bambu Lab", "Light Gray", "#D1D3D5", "PLA Basic"),
     ("Bambu Lab", "Gray", "#8E9089", "PLA Basic"),
     ("Bambu Lab", "Gray", "#8E9089", "PLA Basic"),
-    ("Bambu Lab", "Dark Gray", "#616364", "PLA Basic"),
+    ("Bambu Lab", "Dark Gray", "#545454", "PLA Basic"),
     ("Bambu Lab", "Red", "#C12E1F", "PLA Basic"),
     ("Bambu Lab", "Red", "#C12E1F", "PLA Basic"),
+    ("Bambu Lab", "Maroon Red", "#9D2235", "PLA Basic"),
     ("Bambu Lab", "Magenta", "#EC008C", "PLA Basic"),
     ("Bambu Lab", "Magenta", "#EC008C", "PLA Basic"),
-    ("Bambu Lab", "Hot Pink", "#FF69B4", "PLA Basic"),
+    ("Bambu Lab", "Hot Pink", "#F5547C", "PLA Basic"),
     ("Bambu Lab", "Pink", "#F55A74", "PLA Basic"),
     ("Bambu Lab", "Pink", "#F55A74", "PLA Basic"),
     ("Bambu Lab", "Beige", "#F7E6DE", "PLA Basic"),
     ("Bambu Lab", "Beige", "#F7E6DE", "PLA Basic"),
-    ("Bambu Lab", "Yellow", "#FFFF00", "PLA Basic"),
+    ("Bambu Lab", "Yellow", "#F4EE2A", "PLA Basic"),
     ("Bambu Lab", "Sunflower Yellow", "#FEC600", "PLA Basic"),
     ("Bambu Lab", "Sunflower Yellow", "#FEC600", "PLA Basic"),
     ("Bambu Lab", "Gold", "#E4BD68", "PLA Basic"),
     ("Bambu Lab", "Gold", "#E4BD68", "PLA Basic"),
-    ("Bambu Lab", "Orange", "#FF8C00", "PLA Basic"),
+    ("Bambu Lab", "Orange", "#FF6A13", "PLA Basic"),
     ("Bambu Lab", "Pumpkin Orange", "#FF9016", "PLA Basic"),
     ("Bambu Lab", "Pumpkin Orange", "#FF9016", "PLA Basic"),
-    ("Bambu Lab", "Bright Green", "#66FF00", "PLA Basic"),
+    ("Bambu Lab", "Bright Green", "#BECF00", "PLA Basic"),
     ("Bambu Lab", "Bambu Green", "#00AE42", "PLA Basic"),
     ("Bambu Lab", "Bambu Green", "#00AE42", "PLA Basic"),
     ("Bambu Lab", "Mistletoe Green", "#3F8E43", "PLA Basic"),
     ("Bambu Lab", "Mistletoe Green", "#3F8E43", "PLA Basic"),
     ("Bambu Lab", "Turquoise", "#00B1B7", "PLA Basic"),
     ("Bambu Lab", "Turquoise", "#00B1B7", "PLA Basic"),
     ("Bambu Lab", "Cyan", "#0086D6", "PLA Basic"),
     ("Bambu Lab", "Cyan", "#0086D6", "PLA Basic"),
     ("Bambu Lab", "Blue", "#0A2989", "PLA Basic"),
     ("Bambu Lab", "Blue", "#0A2989", "PLA Basic"),
-    ("Bambu Lab", "Blue Grey", "#647988", "PLA Basic"),
-    ("Bambu Lab", "Cobalt Blue", "#0047AB", "PLA Basic"),
+    ("Bambu Lab", "Blue Grey", "#5B6579", "PLA Basic"),
+    ("Bambu Lab", "Cobalt Blue", "#0056B8", "PLA Basic"),
     ("Bambu Lab", "Purple", "#5E43B7", "PLA Basic"),
     ("Bambu Lab", "Purple", "#5E43B7", "PLA Basic"),
     ("Bambu Lab", "Indigo Purple", "#482960", "PLA Basic"),
     ("Bambu Lab", "Indigo Purple", "#482960", "PLA Basic"),
     ("Bambu Lab", "Brown", "#9D432C", "PLA Basic"),
     ("Bambu Lab", "Brown", "#9D432C", "PLA Basic"),
-    ("Bambu Lab", "Cocoa Brown", "#5C4033", "PLA Basic"),
+    ("Bambu Lab", "Cocoa Brown", "#6F5034", "PLA Basic"),
     ("Bambu Lab", "Bronze", "#847D48", "PLA Basic"),
     ("Bambu Lab", "Bronze", "#847D48", "PLA Basic"),
-    # Bambu Lab PLA Matte
-    ("Bambu Lab", "Ivory White", "#EBEBE3", "PLA Matte"),
-    ("Bambu Lab", "Bone White", "#F5F5DC", "PLA Matte"),
-    ("Bambu Lab", "Lemon Yellow", "#FFF44F", "PLA Matte"),
-    ("Bambu Lab", "Mandarin Orange", "#FF7518", "PLA Matte"),
-    ("Bambu Lab", "Scarlet Red", "#FF2400", "PLA Matte"),
-    ("Bambu Lab", "Lilac Purple", "#C8A2C8", "PLA Matte"),
-    ("Bambu Lab", "Grape Purple", "#6F2DA8", "PLA Matte"),
-    ("Bambu Lab", "Grass Green", "#6BB173", "PLA Matte"),
-    ("Bambu Lab", "Dark Green", "#656A4D", "PLA Matte"),
-    ("Bambu Lab", "Sakura Pink", "#EAB8CA", "PLA Matte"),
-    ("Bambu Lab", "Charcoal", "#36454F", "PLA Matte"),
-    # Bambu Lab PLA Silk
-    ("Bambu Lab", "Blue", "#4F9CCC", "PLA Silk"),
-    ("Bambu Lab", "Gold", "#CFB53B", "PLA Silk"),
-    ("Bambu Lab", "Silver", "#C0C0C0", "PLA Silk"),
-    ("Bambu Lab", "Copper", "#B87333", "PLA Silk"),
-    ("Bambu Lab", "Green", "#50C878", "PLA Silk"),
-    ("Bambu Lab", "Red", "#DC143C", "PLA Silk"),
-    # Bambu Lab PLA Sparkle
-    ("Bambu Lab", "Alpine Green Sparkle", "#4F6359", "PLA Sparkle"),
-    ("Bambu Lab", "Galaxy Black Sparkle", "#1C1C1C", "PLA Sparkle"),
-    ("Bambu Lab", "Space Gray Sparkle", "#4A4A4A", "PLA Sparkle"),
-    # Bambu Lab PETG Basic
-    ("Bambu Lab", "Black", "#000000", "PETG Basic"),
-    ("Bambu Lab", "White", "#FFFFFF", "PETG Basic"),
-    ("Bambu Lab", "Gray", "#808080", "PETG Basic"),
-    ("Bambu Lab", "Translucent", "#F0F0F0", "PETG Basic"),
-    # Bambu Lab PETG-HF
-    ("Bambu Lab", "White", "#F0F1F0", "PETG-HF"),
-    ("Bambu Lab", "Black", "#000000", "PETG-HF"),
-    ("Bambu Lab", "Gray", "#A3A6A6", "PETG-HF"),
-    ("Bambu Lab", "Red", "#C33F45", "PETG-HF"),
-    ("Bambu Lab", "Orange", "#FF7146", "PETG-HF"),
-    ("Bambu Lab", "Blue", "#1E90FF", "PETG-HF"),
-    ("Bambu Lab", "Translucent Orange", "#EF8E5B", "PETG-HF"),
-    # Bambu Lab ABS
-    ("Bambu Lab", "Black", "#000000", "ABS"),
+    # Bambu Lab PLA Matte (from official hex code PDF)
+    ("Bambu Lab", "Ivory White", "#FFFFFF", "PLA Matte"),
+    ("Bambu Lab", "Bone White", "#CBC6B8", "PLA Matte"),
+    ("Bambu Lab", "Desert Tan", "#E8DBB7", "PLA Matte"),
+    ("Bambu Lab", "Latte Brown", "#D3B7A7", "PLA Matte"),
+    ("Bambu Lab", "Caramel", "#AE835B", "PLA Matte"),
+    ("Bambu Lab", "Terracotta", "#B15533", "PLA Matte"),
+    ("Bambu Lab", "Dark Brown", "#7D6556", "PLA Matte"),
+    ("Bambu Lab", "Dark Chocolate", "#4D3324", "PLA Matte"),
+    ("Bambu Lab", "Lilac Purple", "#AE96D4", "PLA Matte"),
+    ("Bambu Lab", "Sakura Pink", "#E8AFCF", "PLA Matte"),
+    ("Bambu Lab", "Mandarin Orange", "#F99963", "PLA Matte"),
+    ("Bambu Lab", "Lemon Yellow", "#F7D959", "PLA Matte"),
+    ("Bambu Lab", "Plum", "#950051", "PLA Matte"),
+    ("Bambu Lab", "Scarlet Red", "#DE4343", "PLA Matte"),
+    ("Bambu Lab", "Dark Red", "#BB3D43", "PLA Matte"),
+    ("Bambu Lab", "Dark Green", "#68724D", "PLA Matte"),
+    ("Bambu Lab", "Grass Green", "#61C680", "PLA Matte"),
+    ("Bambu Lab", "Apple Green", "#C2E189", "PLA Matte"),
+    ("Bambu Lab", "Ice Blue", "#A3D8E1", "PLA Matte"),
+    ("Bambu Lab", "Sky Blue", "#56B7E6", "PLA Matte"),
+    ("Bambu Lab", "Marine Blue", "#0078BF", "PLA Matte"),
+    ("Bambu Lab", "Dark Blue", "#042F56", "PLA Matte"),
+    ("Bambu Lab", "Ash Gray", "#9B9EA0", "PLA Matte"),
+    ("Bambu Lab", "Nardo Gray", "#757575", "PLA Matte"),
+    ("Bambu Lab", "Charcoal", "#000000", "PLA Matte"),
+    # Bambu Lab PLA Silk+ (from store page)
+    ("Bambu Lab", "Gold", "#F4A925", "PLA Silk"),
+    ("Bambu Lab", "Silver", "#C8C8C8", "PLA Silk"),
+    ("Bambu Lab", "Titan Gray", "#5F6367", "PLA Silk"),
+    ("Bambu Lab", "Blue", "#008BDA", "PLA Silk"),
+    ("Bambu Lab", "Purple", "#8671CB", "PLA Silk"),
+    ("Bambu Lab", "Candy Red", "#D02727", "PLA Silk"),
+    ("Bambu Lab", "Candy Green", "#018814", "PLA Silk"),
+    ("Bambu Lab", "Rose Gold", "#BA9594", "PLA Silk"),
+    ("Bambu Lab", "Baby Blue", "#A8C6EE", "PLA Silk"),
+    ("Bambu Lab", "Pink", "#F7ADA6", "PLA Silk"),
+    ("Bambu Lab", "Mint", "#96DCB9", "PLA Silk"),
+    ("Bambu Lab", "Champagne", "#F3CFB2", "PLA Silk"),
+    ("Bambu Lab", "White", "#FFFFFF", "PLA Silk"),
+    # Bambu Lab PLA Sparkle (from store page)
+    ("Bambu Lab", "Classic Gold Sparkle", "#CEA629", "PLA Sparkle"),
+    ("Bambu Lab", "Slate Gray Sparkle", "#8E9089", "PLA Sparkle"),
+    ("Bambu Lab", "Crimson Red Sparkle", "#792B36", "PLA Sparkle"),
+    ("Bambu Lab", "Royal Purple Sparkle", "#483D8B", "PLA Sparkle"),
+    ("Bambu Lab", "Alpine Green Sparkle", "#3F5443", "PLA Sparkle"),
+    ("Bambu Lab", "Onyx Black Sparkle", "#2D2B28", "PLA Sparkle"),
+    # Bambu Lab PLA Translucent (from official hex code PDF)
+    ("Bambu Lab", "Teal", "#009FA1", "PLA Translucent"),
+    ("Bambu Lab", "Light Jade", "#96D8AF", "PLA Translucent"),
+    ("Bambu Lab", "Blue", "#0047BB", "PLA Translucent"),
+    ("Bambu Lab", "Mellow Yellow", "#F5DBAB", "PLA Translucent"),
+    ("Bambu Lab", "Purple", "#8344B0", "PLA Translucent"),
+    ("Bambu Lab", "Cherry Pink", "#F5B6CD", "PLA Translucent"),
+    ("Bambu Lab", "Orange", "#F74E02", "PLA Translucent"),
+    ("Bambu Lab", "Ice Blue", "#B8CDE9", "PLA Translucent"),
+    ("Bambu Lab", "Red", "#B50011", "PLA Translucent"),
+    ("Bambu Lab", "Lavender", "#B8ACD6", "PLA Translucent"),
+    # Bambu Lab PLA Glow (from store page)
+    ("Bambu Lab", "Glow Green", "#A1FFAC", "PLA Glow"),
+    ("Bambu Lab", "Glow Yellow", "#F8FF80", "PLA Glow"),
+    ("Bambu Lab", "Glow Pink", "#F17B8F", "PLA Glow"),
+    ("Bambu Lab", "Glow Blue", "#7AC0E9", "PLA Glow"),
+    ("Bambu Lab", "Glow Orange", "#FF9D5B", "PLA Glow"),
+    # Bambu Lab PLA Galaxy (from store page)
+    ("Bambu Lab", "Brown", "#684A43", "PLA Galaxy"),
+    ("Bambu Lab", "Green", "#3B665E", "PLA Galaxy"),
+    ("Bambu Lab", "Nebulae", "#424379", "PLA Galaxy"),
+    ("Bambu Lab", "Purple", "#594177", "PLA Galaxy"),
+    # Bambu Lab PLA Metal (from store page)
+    ("Bambu Lab", "Iridium Gold Metallic", "#B39B84", "PLA Metal"),
+    ("Bambu Lab", "Copper Brown Metallic", "#AA6443", "PLA Metal"),
+    ("Bambu Lab", "Oxide Green Metallic", "#1D7C6A", "PLA Metal"),
+    ("Bambu Lab", "Cobalt Blue Metallic", "#39699E", "PLA Metal"),
+    ("Bambu Lab", "Iron Gray Metallic", "#43403D", "PLA Metal"),
+    # Bambu Lab PLA Marble (from store page)
+    ("Bambu Lab", "White Marble", "#F7F3F0", "PLA Marble"),
+    ("Bambu Lab", "Red Granite", "#AD4E38", "PLA Marble"),
+    # Bambu Lab PLA Wood (from store page)
+    ("Bambu Lab", "Black Walnut", "#4F3F24", "PLA Wood"),
+    ("Bambu Lab", "Rosewood", "#4C241C", "PLA Wood"),
+    ("Bambu Lab", "Clay Brown", "#995F11", "PLA Wood"),
+    ("Bambu Lab", "Classic Birch", "#918669", "PLA Wood"),
+    ("Bambu Lab", "White Oak", "#D6CCA3", "PLA Wood"),
+    ("Bambu Lab", "Ochre Yellow", "#C98935", "PLA Wood"),
+    # Bambu Lab PLA Tough+ (from official hex code PDF)
+    ("Bambu Lab", "White", "#FFFFFF", "PLA Tough"),
+    ("Bambu Lab", "Gray", "#AFB1AE", "PLA Tough"),
+    ("Bambu Lab", "Black", "#000000", "PLA Tough"),
+    ("Bambu Lab", "Silver", "#959698", "PLA Tough"),
+    ("Bambu Lab", "Yellow", "#F4D53F", "PLA Tough"),
+    ("Bambu Lab", "Cyan", "#009BD8", "PLA Tough"),
+    ("Bambu Lab", "Orange", "#DC3A27", "PLA Tough"),
+    # Bambu Lab PLA-CF (from official hex code PDF)
+    ("Bambu Lab", "Burgundy Red", "#951E23", "PLA-CF"),
+    ("Bambu Lab", "Iris Purple", "#69398E", "PLA-CF"),
+    ("Bambu Lab", "Matcha Green", "#5C9748", "PLA-CF"),
+    ("Bambu Lab", "Jeans Blue", "#6E88BC", "PLA-CF"),
+    ("Bambu Lab", "Royal Blue", "#2842AD", "PLA-CF"),
+    ("Bambu Lab", "Lava Gray", "#4D5054", "PLA-CF"),
+    ("Bambu Lab", "Black", "#000000", "PLA-CF"),
+    # Bambu Lab ABS (from official hex code PDF)
     ("Bambu Lab", "White", "#FFFFFF", "ABS"),
     ("Bambu Lab", "White", "#FFFFFF", "ABS"),
-    ("Bambu Lab", "Gray", "#808080", "ABS"),
-    ("Bambu Lab", "Red", "#FF0000", "ABS"),
-    # Bambu Lab ASA
+    ("Bambu Lab", "Desert Tan", "#E8DBB7", "ABS"),
+    ("Bambu Lab", "Olive", "#789D4A", "ABS"),
+    ("Bambu Lab", "Azure", "#489FDF", "ABS"),
+    ("Bambu Lab", "Navy Blue", "#0C2340", "ABS"),
+    ("Bambu Lab", "Blue", "#0A2CA5", "ABS"),
+    ("Bambu Lab", "Tangerine Yellow", "#FFC72C", "ABS"),
+    ("Bambu Lab", "Orange", "#FF6A13", "ABS"),
+    ("Bambu Lab", "Red", "#D32941", "ABS"),
+    ("Bambu Lab", "Purple", "#AF1685", "ABS"),
+    ("Bambu Lab", "Silver", "#87909A", "ABS"),
+    ("Bambu Lab", "Black", "#000000", "ABS"),
+    # Bambu Lab ASA (from store page)
+    ("Bambu Lab", "White", "#FFFAF2", "ASA"),
+    ("Bambu Lab", "Gray", "#8A949E", "ASA"),
+    ("Bambu Lab", "Red", "#E02928", "ASA"),
+    ("Bambu Lab", "Green", "#00A6A0", "ASA"),
+    ("Bambu Lab", "Blue", "#2140B4", "ASA"),
     ("Bambu Lab", "Black", "#000000", "ASA"),
     ("Bambu Lab", "Black", "#000000", "ASA"),
-    ("Bambu Lab", "White", "#FFFFFF", "ASA"),
-    ("Bambu Lab", "Gray", "#808080", "ASA"),
-    # Bambu Lab TPU
-    ("Bambu Lab", "White", "#F0EFE3", "TPU 95A"),
-    ("Bambu Lab", "Black", "#000000", "TPU 95A"),
-    ("Bambu Lab", "Gray", "#8C9091", "TPU 95A"),
-    ("Bambu Lab", "Red", "#FF0000", "TPU 95A"),
-    # Bambu Lab PLA-CF / PAHT-CF / PETG-CF
-    ("Bambu Lab", "Black", "#1A1A1A", "PLA-CF"),
+    # Bambu Lab PETG HF (from store page)
+    ("Bambu Lab", "Yellow", "#FFD00B", "PETG HF"),
+    ("Bambu Lab", "Orange", "#F75403", "PETG HF"),
+    ("Bambu Lab", "Green", "#00AE42", "PETG HF"),
+    ("Bambu Lab", "Red", "#EB3A3A", "PETG HF"),
+    ("Bambu Lab", "Blue", "#002E96", "PETG HF"),
+    ("Bambu Lab", "Black", "#000000", "PETG HF"),
+    ("Bambu Lab", "White", "#FFFFFF", "PETG HF"),
+    ("Bambu Lab", "Cream", "#F9DFB9", "PETG HF"),
+    ("Bambu Lab", "Lime Green", "#6EE53C", "PETG HF"),
+    ("Bambu Lab", "Forest Green", "#39541A", "PETG HF"),
+    ("Bambu Lab", "Lake Blue", "#1F79E5", "PETG HF"),
+    ("Bambu Lab", "Peanut Brown", "#875718", "PETG HF"),
+    ("Bambu Lab", "Gray", "#ADB1B2", "PETG HF"),
+    ("Bambu Lab", "Dark Gray", "#515151", "PETG HF"),
+    # Bambu Lab PETG Translucent (from store page)
+    ("Bambu Lab", "Translucent Gray", "#8E8E8E", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Light Blue", "#61B0FF", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Olive", "#748C45", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Brown", "#C9A381", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Teal", "#77EDD7", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Orange", "#FF911A", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Purple", "#D6ABFF", "PETG Translucent"),
+    ("Bambu Lab", "Translucent Pink", "#F9C1BD", "PETG Translucent"),
+    # Bambu Lab PETG-CF (from official hex code PDF)
+    ("Bambu Lab", "Brick Red", "#9F332A", "PETG-CF"),
+    ("Bambu Lab", "Violet Purple", "#583061", "PETG-CF"),
+    ("Bambu Lab", "Indigo Blue", "#324585", "PETG-CF"),
+    ("Bambu Lab", "Malachite Green", "#16B08E", "PETG-CF"),
+    ("Bambu Lab", "Black", "#000000", "PETG-CF"),
+    ("Bambu Lab", "Titan Gray", "#565656", "PETG-CF"),
+    # Bambu Lab TPU 95A HF (from store page)
+    ("Bambu Lab", "White", "#FFFFFF", "TPU 95A"),
+    ("Bambu Lab", "Yellow", "#F3E600", "TPU 95A"),
+    ("Bambu Lab", "Blue", "#0072CE", "TPU 95A"),
+    ("Bambu Lab", "Red", "#C8102E", "TPU 95A"),
+    ("Bambu Lab", "Gray", "#898D8D", "TPU 95A"),
+    ("Bambu Lab", "Black", "#101820", "TPU 95A"),
+    # Bambu Lab TPU 90A (from official hex code PDF)
+    ("Bambu Lab", "Black", "#000000", "TPU 90A"),
+    ("Bambu Lab", "White", "#FFFFFF", "TPU 90A"),
+    ("Bambu Lab", "Grape Jelly", "#D6ABFF", "TPU 90A"),
+    ("Bambu Lab", "Crystal Blue", "#7EB4E1", "TPU 90A"),
+    ("Bambu Lab", "Cocoa Brown", "#5C4738", "TPU 90A"),
+    # Bambu Lab PAHT-CF
     ("Bambu Lab", "Black", "#1A1A1A", "PAHT-CF"),
     ("Bambu Lab", "Black", "#1A1A1A", "PAHT-CF"),
-    ("Bambu Lab", "Black", "#1A1A1A", "PETG-CF"),
     # Bambu Lab Support Materials
     # Bambu Lab Support Materials
     ("Bambu Lab", "Natural", "#F5F5DC", "PLA Support"),
     ("Bambu Lab", "Natural", "#F5F5DC", "PLA Support"),
     ("Bambu Lab", "Natural", "#F5F5DC", "PVA Support"),
     ("Bambu Lab", "Natural", "#F5F5DC", "PVA Support"),

+ 51 - 14
backend/app/main.py

@@ -541,30 +541,65 @@ async def on_ams_change(printer_id: int, ams_data: list):
             for assignment in result.scalars().all():
             for assignment in result.scalars().all():
                 current_tray = _find_tray_in_ams_data(ams_data, assignment.ams_id, assignment.tray_id)
                 current_tray = _find_tray_in_ams_data(ams_data, assignment.ams_id, assignment.tray_id)
                 if not current_tray:
                 if not current_tray:
+                    logger.info(
+                        "Auto-unlink: spool %d AMS%d-T%d — tray not found in AMS data (slot empty?)",
+                        assignment.spool_id,
+                        assignment.ams_id,
+                        assignment.tray_id,
+                    )
                     stale.append(assignment)  # Slot empty
                     stale.append(assignment)  # Slot empty
+                elif _is_bambu_uuid(current_tray.get("tray_uuid", "")):
+                    # A Bambu Lab spool was inserted — always unlink manual assignments
+                    logger.info(
+                        "Auto-unlink: spool %d AMS%d-T%d — Bambu Lab spool detected (uuid=%s)",
+                        assignment.spool_id,
+                        assignment.ams_id,
+                        assignment.tray_id,
+                        current_tray.get("tray_uuid", ""),
+                    )
+                    stale.append(assignment)
                 else:
                 else:
                     cur_color = current_tray.get("tray_color", "")
                     cur_color = current_tray.get("tray_color", "")
                     cur_type = current_tray.get("tray_type", "")
                     cur_type = current_tray.get("tray_type", "")
                     fp_color = assignment.fingerprint_color or ""
                     fp_color = assignment.fingerprint_color or ""
                     fp_type = assignment.fingerprint_type or ""
                     fp_type = assignment.fingerprint_type or ""
-                    if cur_color != fp_color or cur_type != fp_type:
-                        stale.append(assignment)  # Spool changed
-                    elif _is_bambu_uuid(current_tray.get("tray_uuid", "")):
-                        # Only unlink if the assigned spool doesn't match this tag
-                        tray_uuid = current_tray.get("tray_uuid", "")
-                        tag_uid = current_tray.get("tag_uid", "")
+                    if cur_color.upper() != fp_color.upper() or cur_type.upper() != fp_type.upper():
+                        # Fingerprint mismatch — but check if tray now matches the
+                        # assigned spool (e.g. auto-configure changed the tray).
                         spool = assignment.spool
                         spool = assignment.spool
-                        if spool and (
-                            (spool.tray_uuid and spool.tray_uuid == tray_uuid)
-                            or (spool.tag_uid and spool.tag_uid == tag_uid)
-                        ):
-                            continue  # Same spool — keep assignment
-                        stale.append(assignment)  # Different Bambu spool inserted
+                        if spool:
+                            spool_color = (spool.rgba or "FFFFFFFF").upper()
+                            spool_type = (spool.material or "").upper()
+                            if cur_color.upper() == spool_color and cur_type.upper() == spool_type:
+                                # Tray was reconfigured to match the spool — update fingerprint
+                                logger.info(
+                                    "Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch but tray matches spool, updating fp",
+                                    assignment.spool_id,
+                                    assignment.ams_id,
+                                    assignment.tray_id,
+                                )
+                                assignment.fingerprint_color = cur_color
+                                assignment.fingerprint_type = cur_type
+                                continue
+                        logger.info(
+                            "Auto-unlink: spool %d AMS%d-T%d — fingerprint mismatch (cur=%s/%s fp=%s/%s spool=%s/%s)",
+                            assignment.spool_id,
+                            assignment.ams_id,
+                            assignment.tray_id,
+                            cur_color,
+                            cur_type,
+                            fp_color,
+                            fp_type,
+                            spool.rgba if spool else "?",
+                            spool.material if spool else "?",
+                        )
+                        stale.append(assignment)  # Spool changed
             for a in stale:
             for a in stale:
                 await db.delete(a)
                 await db.delete(a)
             if stale:
             if stale:
-                await db.commit()
                 logger.info("Auto-unlinked %d stale spool assignments for printer %d", len(stale), printer_id)
                 logger.info("Auto-unlinked %d stale spool assignments for printer %d", len(stale), printer_id)
+            # Commit any changes (stale deletions and/or fingerprint updates)
+            await db.commit()
     except Exception as e:
     except Exception as e:
         logger.warning("Spool assignment cleanup failed: %s", e)
         logger.warning("Spool assignment cleanup failed: %s", e)
 
 
@@ -2051,7 +2086,9 @@ async def on_print_complete(printer_id: int, data: dict):
             from backend.app.services.usage_tracker import on_print_complete as usage_on_print_complete
             from backend.app.services.usage_tracker import on_print_complete as usage_on_print_complete
 
 
             async with async_session() as db:
             async with async_session() as db:
-                usage_results = await usage_on_print_complete(printer_id, data, printer_manager, db)
+                usage_results = await usage_on_print_complete(
+                    printer_id, data, printer_manager, db, archive_id=archive_id
+                )
                 if usage_results:
                 if usage_results:
                     await ws_manager.broadcast(
                     await ws_manager.broadcast(
                         {
                         {

+ 2 - 2
backend/app/services/printer_manager.py

@@ -519,7 +519,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
 
 
                 trays.append(
                 trays.append(
                     {
                     {
-                        "id": tray.get("id", 0),
+                        "id": int(tray.get("id", 0)),
                         "tray_color": tray.get("tray_color"),
                         "tray_color": tray.get("tray_color"),
                         "tray_type": tray.get("tray_type"),
                         "tray_type": tray.get("tray_type"),
                         "tray_sub_brands": tray.get("tray_sub_brands"),
                         "tray_sub_brands": tray.get("tray_sub_brands"),
@@ -556,7 +556,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
 
 
             ams_units.append(
             ams_units.append(
                 {
                 {
-                    "id": ams_data.get("id", 0),
+                    "id": int(ams_data.get("id", 0)),
                     "humidity": humidity_value,
                     "humidity": humidity_value,
                     "temp": ams_data.get("temp"),
                     "temp": ams_data.get("temp"),
                     "is_ams_ht": is_ams_ht,
                     "is_ams_ht": is_ams_ht,

+ 242 - 93
backend/app/services/usage_tracker.py

@@ -2,6 +2,9 @@
 
 
 Captures AMS tray remain% at print start, then computes consumption
 Captures AMS tray remain% at print start, then computes consumption
 deltas at print complete to update spool weight_used and last_used.
 deltas at print complete to update spool weight_used and last_used.
+
+For non-BL spools (no RFID, AMS reports remain=-1), falls back to
+per-filament usage estimates from the archived 3MF file.
 """
 """
 
 
 import logging
 import logging
@@ -37,7 +40,8 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
         logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
         logger.debug("[UsageTracker] No state for printer %d, skipping", printer_id)
         return
         return
 
 
-    ams_data = state.raw_data.get("ams", {}).get("ams", [])
+    ams_raw = state.raw_data.get("ams", [])
+    ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
     if not ams_data:
     if not ams_data:
         logger.debug("[UsageTracker] No AMS data for printer %d, skipping", printer_id)
         logger.debug("[UsageTracker] No AMS data for printer %d, skipping", printer_id)
         return
         return
@@ -51,12 +55,10 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
             if isinstance(remain, int) and 0 <= remain <= 100:
             if isinstance(remain, int) and 0 <= remain <= 100:
                 tray_remain_start[(ams_id, tray_id)] = remain
                 tray_remain_start[(ams_id, tray_id)] = remain
 
 
-    if not tray_remain_start:
-        logger.debug("[UsageTracker] No valid remain%% data for printer %d", printer_id)
-        return
-
     print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
     print_name = data.get("subtask_name", "") or data.get("filename", "unknown")
 
 
+    # Always create session (even without valid remain data) so print_name
+    # is available at completion for 3MF-based tracking
     session = PrintSession(
     session = PrintSession(
         printer_id=printer_id,
         printer_id=printer_id,
         print_name=print_name,
         print_name=print_name,
@@ -64,12 +66,16 @@ async def on_print_start(printer_id: int, data: dict, printer_manager) -> None:
         tray_remain_start=tray_remain_start,
         tray_remain_start=tray_remain_start,
     )
     )
     _active_sessions[printer_id] = session
     _active_sessions[printer_id] = session
-    logger.info(
-        "[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
-        printer_id,
-        len(tray_remain_start),
-        {f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
-    )
+
+    if tray_remain_start:
+        logger.info(
+            "[UsageTracker] Captured start remain%% for printer %d (%d trays): %s",
+            printer_id,
+            len(tray_remain_start),
+            {f"{k[0]}-{k[1]}": v for k, v in tray_remain_start.items()},
+        )
+    else:
+        logger.debug("[UsageTracker] No valid remain%% for printer %d, 3MF fallback available", printer_id)
 
 
 
 
 async def on_print_complete(
 async def on_print_complete(
@@ -77,103 +83,246 @@ async def on_print_complete(
     data: dict,
     data: dict,
     printer_manager,
     printer_manager,
     db: AsyncSession,
     db: AsyncSession,
+    archive_id: int | None = None,
 ) -> list[dict]:
 ) -> list[dict]:
     """Compute consumption deltas and update spool weight_used/last_used.
     """Compute consumption deltas and update spool weight_used/last_used.
 
 
+    Uses two tracking strategies:
+    1. AMS remain% delta — for BL spools with valid RFID remain data
+    2. 3MF per-filament estimates — for non-BL spools without remain data
+
     Returns a list of dicts describing what was logged (for WebSocket broadcast).
     Returns a list of dicts describing what was logged (for WebSocket broadcast).
     """
     """
     session = _active_sessions.pop(printer_id, None)
     session = _active_sessions.pop(printer_id, None)
-    if not session:
-        logger.debug("[UsageTracker] No active session for printer %d, skipping", printer_id)
-        return []
-
-    # Read current remain%
-    state = printer_manager.get_status(printer_id)
-    if not state or not state.raw_data:
-        logger.warning("[UsageTracker] No state at print complete for printer %d", printer_id)
-        return []
-
-    ams_data = state.raw_data.get("ams", {}).get("ams", [])
     status = data.get("status", "completed")
     status = data.get("status", "completed")
     results = []
     results = []
+    handled_trays: set[tuple[int, int]] = set()
+
+    # --- Path 1: AMS remain% delta (for spools with valid RFID remain data) ---
+    if session and session.tray_remain_start:
+        state = printer_manager.get_status(printer_id)
+        if state and state.raw_data:
+            ams_raw = state.raw_data.get("ams", [])
+            ams_data = (
+                ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
+            )
 
 
-    for ams_unit in ams_data:
-        ams_id = int(ams_unit.get("id", 0))
-        for tray in ams_unit.get("tray", []):
-            tray_id = int(tray.get("id", 0))
-            key = (ams_id, tray_id)
+            for ams_unit in ams_data:
+                ams_id = int(ams_unit.get("id", 0))
+                for tray in ams_unit.get("tray", []):
+                    tray_id = int(tray.get("id", 0))
+                    key = (ams_id, tray_id)
+
+                    if key not in session.tray_remain_start:
+                        continue
+
+                    current_remain = tray.get("remain", -1)
+                    if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
+                        continue
+
+                    start_remain = session.tray_remain_start[key]
+                    delta_pct = start_remain - current_remain
+
+                    if delta_pct <= 0:
+                        continue  # No consumption or tray was refilled
+
+                    # Look up SpoolAssignment for this slot
+                    result = await db.execute(
+                        select(SpoolAssignment).where(
+                            SpoolAssignment.printer_id == printer_id,
+                            SpoolAssignment.ams_id == ams_id,
+                            SpoolAssignment.tray_id == tray_id,
+                        )
+                    )
+                    assignment = result.scalar_one_or_none()
+                    if not assignment:
+                        continue
+
+                    # Load spool
+                    spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
+                    spool = spool_result.scalar_one_or_none()
+                    if not spool:
+                        continue
+
+                    # Compute weight consumed
+                    weight_grams = (delta_pct / 100.0) * spool.label_weight
+
+                    # Update spool
+                    spool.weight_used = (spool.weight_used or 0) + weight_grams
+                    spool.last_used = datetime.now(timezone.utc)
+
+                    # Insert usage history record
+                    history = SpoolUsageHistory(
+                        spool_id=spool.id,
+                        printer_id=printer_id,
+                        print_name=session.print_name,
+                        weight_used=round(weight_grams, 1),
+                        percent_used=delta_pct,
+                        status=status,
+                    )
+                    db.add(history)
+
+                    handled_trays.add(key)
+                    results.append(
+                        {
+                            "spool_id": spool.id,
+                            "weight_used": round(weight_grams, 1),
+                            "percent_used": delta_pct,
+                            "ams_id": ams_id,
+                            "tray_id": tray_id,
+                        }
+                    )
+
+                    logger.info(
+                        "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (%s)",
+                        spool.id,
+                        weight_grams,
+                        delta_pct,
+                        printer_id,
+                        ams_id,
+                        tray_id,
+                        status,
+                    )
+
+    # --- Path 2: 3MF per-filament estimates (for non-BL spools without remain data) ---
+    if archive_id:
+        print_name = (
+            (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
+        )
+        threemf_results = await _track_from_3mf(
+            printer_id, archive_id, status, print_name, handled_trays, printer_manager, db
+        )
+        results.extend(threemf_results)
+
+    if results:
+        await db.commit()
 
 
-            if key not in session.tray_remain_start:
-                continue
+    return results
 
 
-            current_remain = tray.get("remain", -1)
-            if not isinstance(current_remain, int) or current_remain < 0 or current_remain > 100:
-                continue
 
 
-            start_remain = session.tray_remain_start[key]
-            delta_pct = start_remain - current_remain
+async def _track_from_3mf(
+    printer_id: int,
+    archive_id: int,
+    status: str,
+    print_name: str,
+    handled_trays: set[tuple[int, int]],
+    printer_manager,
+    db: AsyncSession,
+) -> list[dict]:
+    """Track usage from 3MF per-filament data for non-BL spools.
 
 
-            if delta_pct <= 0:
-                continue  # No consumption or tray was refilled
+    Falls back to slicer-estimated filament weight when AMS remain% is
+    unavailable (non-RFID spools). For partial prints (failed/aborted),
+    scales the estimate by print progress.
+    """
+    from backend.app.core.config import settings as app_settings
+    from backend.app.models.archive import PrintArchive
+    from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
 
 
-            # Look up SpoolAssignment for this slot
-            result = await db.execute(
-                select(SpoolAssignment).where(
-                    SpoolAssignment.printer_id == printer_id,
-                    SpoolAssignment.ams_id == ams_id,
-                    SpoolAssignment.tray_id == tray_id,
-                )
-            )
-            assignment = result.scalar_one_or_none()
-            if not assignment:
-                continue
-
-            # Load spool
-            spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
-            spool = spool_result.scalar_one_or_none()
-            if not spool:
-                continue
-
-            # Compute weight consumed
-            weight_grams = (delta_pct / 100.0) * spool.label_weight
-
-            # Update spool
-            spool.weight_used = (spool.weight_used or 0) + weight_grams
-            spool.last_used = datetime.now(timezone.utc)
-
-            # Insert usage history record
-            history = SpoolUsageHistory(
-                spool_id=spool.id,
-                printer_id=printer_id,
-                print_name=session.print_name,
-                weight_used=round(weight_grams, 1),
-                percent_used=delta_pct,
-                status=status,
-            )
-            db.add(history)
-
-            results.append(
-                {
-                    "spool_id": spool.id,
-                    "weight_used": round(weight_grams, 1),
-                    "percent_used": delta_pct,
-                    "ams_id": ams_id,
-                    "tray_id": tray_id,
-                }
-            )
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive or not archive.file_path:
+        return []
 
 
-            logger.info(
-                "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (%s)",
-                spool.id,
-                weight_grams,
-                delta_pct,
-                printer_id,
-                ams_id,
-                tray_id,
-                status,
-            )
+    file_path = app_settings.base_dir / archive.file_path
+    if not file_path.exists():
+        return []
 
 
-    if results:
-        await db.commit()
+    filament_usage = extract_filament_usage_from_3mf(file_path)
+    if not filament_usage:
+        return []
+
+    # Scale factor for partial prints (failed/aborted)
+    if status == "completed":
+        scale = 1.0
+    else:
+        state = printer_manager.get_status(printer_id)
+        progress = state.progress if state else 0
+        scale = max(0.0, min(progress / 100.0, 1.0))
+
+    results = []
+
+    for usage in filament_usage:
+        slot_id = usage.get("slot_id", 0)
+        used_g = usage.get("used_g", 0)
+        if used_g <= 0:
+            continue
+
+        # Map 3MF slot_id (1-based) to (ams_id, tray_id)
+        global_tray_id = slot_id - 1
+        if global_tray_id >= 128:
+            ams_id = global_tray_id
+            tray_id = 0
+        else:
+            ams_id = global_tray_id // 4
+            tray_id = global_tray_id % 4
+
+        key = (ams_id, tray_id)
+        if key in handled_trays:
+            continue  # Already tracked via AMS remain% delta
+
+        # Find spool assignment for this tray
+        assign_result = await db.execute(
+            select(SpoolAssignment).where(
+                SpoolAssignment.printer_id == printer_id,
+                SpoolAssignment.ams_id == ams_id,
+                SpoolAssignment.tray_id == tray_id,
+            )
+        )
+        assignment = assign_result.scalar_one_or_none()
+        if not assignment:
+            continue
+
+        # Load spool
+        spool_result = await db.execute(select(Spool).where(Spool.id == assignment.spool_id))
+        spool = spool_result.scalar_one_or_none()
+        if not spool:
+            continue
+
+        # Only use 3MF tracking for non-BL spools (BL spools use AMS remain%)
+        if spool.tag_uid or spool.tray_uuid:
+            continue
+
+        weight_grams = used_g * scale
+        if weight_grams <= 0:
+            continue
+
+        # Update spool
+        spool.weight_used = (spool.weight_used or 0) + weight_grams
+        spool.last_used = datetime.now(timezone.utc)
+
+        percent = round(weight_grams / (spool.label_weight or 1000) * 100)
+
+        # Insert usage history record
+        history = SpoolUsageHistory(
+            spool_id=spool.id,
+            printer_id=printer_id,
+            print_name=print_name,
+            weight_used=round(weight_grams, 1),
+            percent_used=percent,
+            status=status,
+        )
+        db.add(history)
+
+        results.append(
+            {
+                "spool_id": spool.id,
+                "weight_used": round(weight_grams, 1),
+                "percent_used": percent,
+                "ams_id": ams_id,
+                "tray_id": tray_id,
+            }
+        )
+
+        logger.info(
+            "[UsageTracker] Spool %d consumed %.1fg (3MF estimate%s) on printer %d AMS%d-T%d (%s)",
+            spool.id,
+            weight_grams,
+            f" scaled to {scale:.0%}" if scale < 1 else "",
+            printer_id,
+            ams_id,
+            tray_id,
+            status,
+        )
 
 
     return results
     return results

+ 388 - 0
backend/tests/unit/services/test_usage_tracker.py

@@ -0,0 +1,388 @@
+"""Unit tests for the filament usage tracker.
+
+Tests both AMS remain% delta tracking (Path 1) and 3MF per-filament
+fallback tracking (Path 2) for non-BL spools.
+"""
+
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.usage_tracker import (
+    PrintSession,
+    _active_sessions,
+    _track_from_3mf,
+    on_print_complete,
+    on_print_start,
+)
+
+
+def _make_spool(*, id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
+    """Create a mock Spool object."""
+    spool = MagicMock()
+    spool.id = id
+    spool.label_weight = label_weight
+    spool.weight_used = weight_used
+    spool.tag_uid = tag_uid
+    spool.tray_uuid = tray_uuid
+    spool.last_used = None
+    return spool
+
+
+def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0):
+    """Create a mock SpoolAssignment object."""
+    assignment = MagicMock()
+    assignment.spool_id = spool_id
+    assignment.printer_id = printer_id
+    assignment.ams_id = ams_id
+    assignment.tray_id = tray_id
+    return assignment
+
+
+def _make_printer_state(ams_data, progress=0):
+    """Create a mock printer state with AMS data."""
+    state = MagicMock()
+    state.raw_data = {"ams": ams_data}
+    state.progress = progress
+    return state
+
+
+def _make_printer_manager(state=None):
+    """Create a mock printer manager."""
+    pm = MagicMock()
+    pm.get_status.return_value = state
+    return pm
+
+
+class TestOnPrintStart:
+    """Tests for on_print_start — capturing AMS remain%."""
+
+    @pytest.fixture(autouse=True)
+    def _clear_sessions(self):
+        _active_sessions.clear()
+        yield
+        _active_sessions.clear()
+
+    @pytest.mark.asyncio
+    async def test_creates_session_with_valid_remain(self):
+        """Session created with remain% data for trays reporting 0-100."""
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data))
+
+        await on_print_start(1, {"subtask_name": "test_print"}, pm)
+
+        assert 1 in _active_sessions
+        session = _active_sessions[1]
+        assert session.print_name == "test_print"
+        assert session.tray_remain_start == {(0, 0): 80}
+
+    @pytest.mark.asyncio
+    async def test_creates_session_even_without_valid_remain(self):
+        """Session still created when remain=-1 (for 3MF fallback path)."""
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data))
+
+        await on_print_start(1, {"subtask_name": "test_print"}, pm)
+
+        assert 1 in _active_sessions
+        session = _active_sessions[1]
+        assert session.tray_remain_start == {}  # Empty, no valid remain
+
+    @pytest.mark.asyncio
+    async def test_skips_without_ams_data(self):
+        """No session created when no AMS data available."""
+        state = MagicMock()
+        state.raw_data = {"ams": []}
+        pm = _make_printer_manager(state)
+
+        await on_print_start(1, {"subtask_name": "test"}, pm)
+
+        assert 1 not in _active_sessions
+
+
+class TestOnPrintCompleteAMSDelta:
+    """Tests for Path 1: AMS remain% delta tracking."""
+
+    @pytest.fixture(autouse=True)
+    def _clear_sessions(self):
+        _active_sessions.clear()
+        yield
+        _active_sessions.clear()
+
+    @pytest.mark.asyncio
+    async def test_computes_delta_and_updates_spool(self):
+        """Spool weight_used updated by remain% delta * label_weight."""
+        # Set up session with start remain = 80%
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="test",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+        )
+
+        # Current remain = 70% → 10% consumed → 100g on 1000g spool
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data))
+
+        spool = _make_spool(label_weight=1000, weight_used=50)
+        assignment = _make_assignment()
+
+        db = AsyncMock()
+        # First execute → assignment, second → spool
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        results = await on_print_complete(1, {"status": "completed"}, pm, db)
+
+        assert len(results) == 1
+        assert results[0]["weight_used"] == 100.0
+        assert results[0]["percent_used"] == 10
+        # weight_used should be old (50) + delta (100)
+        assert spool.weight_used == 150.0
+        db.commit.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_skips_negative_delta(self):
+        """No tracking when remain increased (spool refilled)."""
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="test",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 50},
+        )
+
+        # Remain went UP: 50 → 80 (refilled)
+        ams_data = [{"id": 0, "tray": [{"id": 0, "remain": 80}]}]
+        pm = _make_printer_manager(_make_printer_state(ams_data))
+        db = AsyncMock()
+
+        results = await on_print_complete(1, {"status": "completed"}, pm, db)
+
+        assert results == []
+        db.commit.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_no_session_falls_through_to_3mf(self):
+        """When no session exists, AMS delta path skipped (3MF may still run)."""
+        pm = _make_printer_manager()
+        db = AsyncMock()
+
+        results = await on_print_complete(1, {"status": "completed"}, pm, db)
+
+        assert results == []
+
+
+class TestTrackFrom3MF:
+    """Tests for Path 2: 3MF per-filament fallback tracking."""
+
+    @pytest.mark.asyncio
+    async def test_updates_non_bl_spool_from_3mf(self):
+        """Non-BL spool gets weight_used from 3MF used_g for completed print."""
+        spool = _make_spool(id=5, label_weight=1000, weight_used=100)
+        assignment = _make_assignment(spool_id=5)
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        db = AsyncMock()
+        # First execute → archive, second → assignment, third → spool
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        pm = _make_printer_manager()
+        filament_usage = [{"slot_id": 1, "used_g": 25.5, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="test_print",
+                handled_trays=set(),
+                printer_manager=pm,
+                db=db,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 5
+        assert results[0]["weight_used"] == 25.5
+        # weight_used = old (100) + 3MF (25.5)
+        assert spool.weight_used == 125.5
+
+    @pytest.mark.asyncio
+    async def test_scales_by_progress_for_failed_print(self):
+        """Failed print scales 3MF estimate by progress percentage."""
+        spool = _make_spool(id=1, label_weight=1000, weight_used=0)
+        assignment = _make_assignment()
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        # Print failed at 50% progress → 50g consumed from 100g estimate
+        pm = _make_printer_manager(_make_printer_state([], progress=50))
+        filament_usage = [{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": ""}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="failed",
+                print_name="test",
+                handled_trays=set(),
+                printer_manager=pm,
+                db=db,
+            )
+
+        assert len(results) == 1
+        assert results[0]["weight_used"] == 50.0
+        assert spool.weight_used == 50.0
+
+    @pytest.mark.asyncio
+    async def test_skips_bl_spools(self):
+        """BL spools (with tag_uid) are NOT tracked via 3MF — they use AMS remain%."""
+        spool = _make_spool(tag_uid="ABCD1234", tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4")
+        assignment = _make_assignment()
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        pm = _make_printer_manager()
+        filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="test",
+                handled_trays=set(),
+                printer_manager=pm,
+                db=db,
+            )
+
+        assert results == []
+
+    @pytest.mark.asyncio
+    async def test_skips_already_handled_trays(self):
+        """Trays handled by AMS remain% delta are not double-tracked via 3MF."""
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+            ]
+        )
+
+        pm = _make_printer_manager()
+        filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="test",
+                handled_trays={(0, 0)},  # slot_id=1 → ams_id=0, tray_id=0
+                printer_manager=pm,
+                db=db,
+            )
+
+        assert results == []
+
+    @pytest.mark.asyncio
+    async def test_slot_to_tray_mapping(self):
+        """3MF slot_id maps correctly to (ams_id, tray_id)."""
+        # slot 5 → global_tray_id 4 → ams_id=1, tray_id=0
+        spool = _make_spool(id=9)
+        assignment = _make_assignment(spool_id=9, ams_id=1, tray_id=0)
+        archive = MagicMock()
+        archive.file_path = "archives/test.3mf"
+
+        db = AsyncMock()
+        db.execute = AsyncMock(
+            side_effect=[
+                MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
+            ]
+        )
+
+        pm = _make_printer_manager()
+        filament_usage = [{"slot_id": 5, "used_g": 30.0, "type": "PETG", "color": ""}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch("backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", return_value=filament_usage),
+        ):
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="test",
+                handled_trays=set(),
+                printer_manager=pm,
+                db=db,
+            )
+
+        assert len(results) == 1
+        assert results[0]["ams_id"] == 1
+        assert results[0]["tray_id"] == 0

+ 134 - 0
frontend/src/__tests__/components/AssignSpoolModal.test.tsx

@@ -0,0 +1,134 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { AssignSpoolModal } from '../../components/AssignSpoolModal';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getSpools: vi.fn(),
+    getAssignments: vi.fn(),
+    assignSpool: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+  },
+}));
+
+const defaultProps = {
+  isOpen: true,
+  onClose: vi.fn(),
+  printerId: 1,
+  amsId: 0,
+  trayId: 0,
+  trayInfo: { type: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' },
+};
+
+const manualSpool = {
+  id: 1,
+  material: 'PLA',
+  subtype: 'Basic',
+  brand: 'Polymaker',
+  color_name: 'Red',
+  rgba: 'FF0000FF',
+  label_weight: 1000,
+  weight_used: 0,
+  tag_uid: null,
+  tray_uuid: null,
+};
+
+const blSpool = {
+  id: 2,
+  material: 'PLA',
+  subtype: 'Basic',
+  brand: 'Bambu',
+  color_name: 'Jade White',
+  rgba: 'FFFFFFFE',
+  label_weight: 1000,
+  weight_used: 50,
+  tag_uid: '05CC1E0F00000100',
+  tray_uuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
+};
+
+const anotherManualSpool = {
+  id: 3,
+  material: 'PETG',
+  subtype: 'HF',
+  brand: 'Overture',
+  color_name: 'Black',
+  rgba: '000000FF',
+  label_weight: 1000,
+  weight_used: 200,
+  tag_uid: null,
+  tray_uuid: null,
+};
+
+describe('AssignSpoolModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool, blSpool, anotherManualSpool]);
+    (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+  });
+
+  it('renders nothing when closed', () => {
+    render(<AssignSpoolModal {...defaultProps} isOpen={false} />);
+    expect(screen.queryByText('Assign Spool')).not.toBeInTheDocument();
+  });
+
+  it('filters out Bambu Lab spools (with tag_uid/tray_uuid)', async () => {
+    render(<AssignSpoolModal {...defaultProps} />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+    });
+
+    // Manual spools should be visible
+    expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+    expect(screen.getByText(/Overture/)).toBeInTheDocument();
+
+    // BL spool should NOT be visible
+    expect(screen.queryByText(/Jade White/)).not.toBeInTheDocument();
+  });
+
+  it('filters out spools already assigned to other slots', async () => {
+    (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
+      { id: 1, spool_id: 3, printer_id: 1, ams_id: 0, tray_id: 1 }, // spool 3 assigned to different slot
+    ]);
+
+    render(<AssignSpoolModal {...defaultProps} />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+    });
+
+    // Spool 1 (not assigned) should be visible
+    expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+
+    // Spool 3 (assigned to another slot) should NOT be visible
+    expect(screen.queryByText(/Overture/)).not.toBeInTheDocument();
+  });
+
+  it('keeps spool visible if assigned to the current slot', async () => {
+    (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
+      { id: 1, spool_id: 1, printer_id: 1, ams_id: 0, tray_id: 0 }, // spool 1 assigned to THIS slot
+    ]);
+
+    render(<AssignSpoolModal {...defaultProps} />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+    });
+
+    // Spool 1 (assigned to current slot) should still be visible for re-assignment
+    expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+  });
+
+  it('shows noManualSpools message when all spools are BL or assigned', async () => {
+    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([blSpool]);
+
+    render(<AssignSpoolModal {...defaultProps} />);
+
+    await waitFor(() => {
+      expect(screen.getByText(/No manually added spools/i)).toBeInTheDocument();
+    });
+  });
+});

+ 77 - 141
frontend/src/__tests__/components/LinkSpoolModal.test.tsx

@@ -1,11 +1,11 @@
 /**
 /**
  * Tests for the LinkSpoolModal component.
  * Tests for the LinkSpoolModal component.
  *
  *
- * Tests the Spoolman link spool modal including:
- * - Displaying unlinked spools
- * - Selecting a spool to link
- * - Link success with toast notification
- * - Link error with toast notification
+ * Tests the inventory link-to-spool modal including:
+ * - Rendering modal with tag/tray info
+ * - Displaying untagged spools
+ * - Linking a spool via click
+ * - Search filtering
  */
  */
 
 
 import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { describe, it, expect, vi, beforeEach } from 'vitest';
@@ -16,10 +16,10 @@ import { LinkSpoolModal } from '../../components/LinkSpoolModal';
 // Mock the API client
 // Mock the API client
 vi.mock('../../api/client', () => ({
 vi.mock('../../api/client', () => ({
   api: {
   api: {
-    getUnlinkedSpools: vi.fn(),
-    linkSpool: vi.fn(),
+    getSpools: vi.fn(),
+    linkTagToSpool: vi.fn(),
     getSettings: vi.fn().mockResolvedValue({}),
     getSettings: vi.fn().mockResolvedValue({}),
-    getAuthStatus: vi.fn().mockResolvedValue({ enabled: false, configured: false }),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
   },
   },
 }));
 }));
 
 
@@ -40,37 +40,56 @@ describe('LinkSpoolModal', () => {
   const defaultProps = {
   const defaultProps = {
     isOpen: true,
     isOpen: true,
     onClose: vi.fn(),
     onClose: vi.fn(),
+    tagUid: 'ABCD1234',
     trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
     trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
-    trayInfo: {
-      type: 'PLA Basic',
-      color: 'FF0000',
-      location: 'AMS A1',
-    },
+    printerId: 1,
+    amsId: 0,
+    trayId: 0,
   };
   };
 
 
-  const mockUnlinkedSpools = [
+  const mockSpools = [
     {
     {
       id: 1,
       id: 1,
-      filament_name: 'PLA Red',
-      filament_material: 'PLA',
-      filament_color_hex: 'FF0000',
-      remaining_weight: 800,
-      location: 'Shelf A',
+      material: 'PLA',
+      brand: 'Generic',
+      subtype: '',
+      color_name: 'Red',
+      rgba: 'FF0000FF',
+      label_weight: 1000,
+      weight_used: 200,
+      tag_uid: null,
+      tray_uuid: null,
     },
     },
     {
     {
       id: 2,
       id: 2,
-      filament_name: 'PETG Blue',
-      filament_material: 'PETG',
-      filament_color_hex: '0000FF',
-      remaining_weight: 500,
-      location: null,
+      material: 'PETG',
+      brand: 'Bambu',
+      subtype: 'Basic',
+      color_name: 'Blue',
+      rgba: '0000FFFF',
+      label_weight: 1000,
+      weight_used: 500,
+      tag_uid: null,
+      tray_uuid: null,
+    },
+    {
+      id: 3,
+      material: 'ABS',
+      brand: 'Brand',
+      subtype: '',
+      color_name: 'White',
+      rgba: 'FFFFFFFF',
+      label_weight: 1000,
+      weight_used: 0,
+      tag_uid: 'EXISTING_TAG',
+      tray_uuid: 'EXISTING_UUID',
     },
     },
   ];
   ];
 
 
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.clearAllMocks();
-    vi.mocked(api.getUnlinkedSpools).mockResolvedValue(mockUnlinkedSpools);
-    vi.mocked(api.linkSpool).mockResolvedValue({ success: true, message: 'Linked' });
+    vi.mocked(api.getSpools).mockResolvedValue(mockSpools);
+    vi.mocked(api.linkTagToSpool).mockResolvedValue({});
   });
   });
 
 
   describe('rendering', () => {
   describe('rendering', () => {
@@ -78,33 +97,21 @@ describe('LinkSpoolModal', () => {
       render(<LinkSpoolModal {...defaultProps} />);
       render(<LinkSpoolModal {...defaultProps} />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        // Look for the title in h2 element
-        expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
-      });
-    });
-
-    it('displays tray info', async () => {
-      render(<LinkSpoolModal {...defaultProps} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('PLA Basic')).toBeInTheDocument();
-        expect(screen.getByText('(AMS A1)')).toBeInTheDocument();
+        expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
       });
       });
     });
     });
 
 
-    it('displays tray UUID', async () => {
+    it('displays printer and tray info', async () => {
       render(<LinkSpoolModal {...defaultProps} />);
       render(<LinkSpoolModal {...defaultProps} />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText(defaultProps.trayUuid)).toBeInTheDocument();
+        expect(screen.getByText(/AMS 0 T0/)).toBeInTheDocument();
+        expect(screen.getByText(/Printer #1/)).toBeInTheDocument();
       });
       });
     });
     });
 
 
     it('shows loading state while fetching spools', async () => {
     it('shows loading state while fetching spools', async () => {
-      // Delay the response
-      vi.mocked(api.getUnlinkedSpools).mockImplementation(
-        () => new Promise(() => {})
-      );
+      vi.mocked(api.getSpools).mockImplementation(() => new Promise(() => {}));
 
 
       render(<LinkSpoolModal {...defaultProps} />);
       render(<LinkSpoolModal {...defaultProps} />);
 
 
@@ -113,132 +120,74 @@ describe('LinkSpoolModal', () => {
       });
       });
     });
     });
 
 
-    it('displays unlinked spools list', async () => {
+    it('displays untagged spools only', async () => {
       render(<LinkSpoolModal {...defaultProps} />);
       render(<LinkSpoolModal {...defaultProps} />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
-        expect(screen.getByText('PETG Blue')).toBeInTheDocument();
+        // Spools 1 and 2 have no tag_uid/tray_uuid — should be shown
+        expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
+        expect(screen.getByText(/Bambu PETG/)).toBeInTheDocument();
       });
       });
-    });
-
-    it('shows message when no unlinked spools', async () => {
-      vi.mocked(api.getUnlinkedSpools).mockResolvedValue([]);
 
 
-      render(<LinkSpoolModal {...defaultProps} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('No unlinked spools available')).toBeInTheDocument();
-      });
+      // Spool 3 has tag_uid — should be filtered out
+      expect(screen.queryByText(/Brand ABS/)).not.toBeInTheDocument();
     });
     });
 
 
     it('does not render when isOpen is false', () => {
     it('does not render when isOpen is false', () => {
       render(<LinkSpoolModal {...defaultProps} isOpen={false} />);
       render(<LinkSpoolModal {...defaultProps} isOpen={false} />);
-      expect(screen.queryByRole('heading', { name: /link to spoolman/i })).not.toBeInTheDocument();
-    });
-  });
-
-  describe('spool selection', () => {
-    it('allows selecting a spool', async () => {
-      render(<LinkSpoolModal {...defaultProps} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
-      });
-
-      // Click to select spool
-      fireEvent.click(screen.getByText('PLA Red'));
-
-      // Should show check mark (via visual styling)
-      const selectedButton = screen.getByText('PLA Red').closest('button');
-      expect(selectedButton).toHaveClass('border-bambu-green');
-    });
-
-    it('link button is disabled until spool is selected', async () => {
-      render(<LinkSpoolModal {...defaultProps} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
-      });
-
-      const linkButton = screen.getByRole('button', { name: /link to spoolman/i });
-      expect(linkButton).toBeDisabled();
-
-      // Select a spool
-      fireEvent.click(screen.getByText('PLA Red'));
-
-      expect(linkButton).not.toBeDisabled();
+      expect(screen.queryByRole('heading', { name: /link to spool/i })).not.toBeInTheDocument();
     });
     });
   });
   });
 
 
   describe('linking', () => {
   describe('linking', () => {
-    it('calls linkSpool API on submit', async () => {
-      render(<LinkSpoolModal {...defaultProps} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
-      });
-
-      // Select a spool
-      fireEvent.click(screen.getByText('PLA Red'));
-
-      // Click link button
-      fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
-
-      await waitFor(() => {
-        expect(api.linkSpool).toHaveBeenCalledWith(1, defaultProps.trayUuid);
-      });
-    });
-
-    it('shows success toast on successful link', async () => {
+    it('calls linkTagToSpool on spool click', async () => {
       render(<LinkSpoolModal {...defaultProps} />);
       render(<LinkSpoolModal {...defaultProps} />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+        expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
       });
       });
 
 
-      fireEvent.click(screen.getByText('PLA Red'));
-      fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
+      fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(mockShowToast).toHaveBeenCalledWith(
-          'Spool linked to Spoolman successfully',
-          'success'
-        );
+        expect(api.linkTagToSpool).toHaveBeenCalledWith(1, {
+          tag_uid: 'ABCD1234',
+          tray_uuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
+          tag_type: 'bambulab',
+          data_origin: 'nfc_link',
+        });
       });
       });
     });
     });
 
 
-    it('calls onClose after successful link', async () => {
+    it('shows success toast and calls onClose', async () => {
       render(<LinkSpoolModal {...defaultProps} />);
       render(<LinkSpoolModal {...defaultProps} />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+        expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
       });
       });
 
 
-      fireEvent.click(screen.getByText('PLA Red'));
-      fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
+      fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
 
 
       await waitFor(() => {
       await waitFor(() => {
+        expect(mockShowToast).toHaveBeenCalled();
         expect(defaultProps.onClose).toHaveBeenCalled();
         expect(defaultProps.onClose).toHaveBeenCalled();
       });
       });
     });
     });
 
 
-    it('shows error toast on link failure', async () => {
-      const errorMessage = 'Failed to update spool';
-      vi.mocked(api.linkSpool).mockRejectedValue(new Error(errorMessage));
+    it('shows error toast on failure', async () => {
+      vi.mocked(api.linkTagToSpool).mockRejectedValue(new Error('Link failed'));
 
 
       render(<LinkSpoolModal {...defaultProps} />);
       render(<LinkSpoolModal {...defaultProps} />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+        expect(screen.getByText(/Generic PLA/)).toBeInTheDocument();
       });
       });
 
 
-      fireEvent.click(screen.getByText('PLA Red'));
-      fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
+      fireEvent.click(screen.getByText(/Generic PLA/).closest('button')!);
 
 
       await waitFor(() => {
       await waitFor(() => {
         expect(mockShowToast).toHaveBeenCalledWith(
         expect(mockShowToast).toHaveBeenCalledWith(
-          `Failed to link spool: ${errorMessage}`,
+          expect.stringContaining('Link failed'),
           'error'
           'error'
         );
         );
       });
       });
@@ -246,25 +195,13 @@ describe('LinkSpoolModal', () => {
   });
   });
 
 
   describe('modal actions', () => {
   describe('modal actions', () => {
-    it('calls onClose when cancel button is clicked', async () => {
-      render(<LinkSpoolModal {...defaultProps} />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Cancel')).toBeInTheDocument();
-      });
-
-      fireEvent.click(screen.getByText('Cancel'));
-      expect(defaultProps.onClose).toHaveBeenCalled();
-    });
-
     it('calls onClose when backdrop is clicked', async () => {
     it('calls onClose when backdrop is clicked', async () => {
       render(<LinkSpoolModal {...defaultProps} />);
       render(<LinkSpoolModal {...defaultProps} />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
+        expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
       });
       });
 
 
-      // Click the backdrop (the element with bg-black/60)
       const backdrop = document.querySelector('.bg-black\\/60');
       const backdrop = document.querySelector('.bg-black\\/60');
       if (backdrop) {
       if (backdrop) {
         fireEvent.click(backdrop);
         fireEvent.click(backdrop);
@@ -276,10 +213,9 @@ describe('LinkSpoolModal', () => {
       render(<LinkSpoolModal {...defaultProps} />);
       render(<LinkSpoolModal {...defaultProps} />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
+        expect(screen.getByRole('heading', { name: /link to spool/i })).toBeInTheDocument();
       });
       });
 
 
-      // Find and click the X button in the header
       const closeButtons = screen.getAllByRole('button');
       const closeButtons = screen.getAllByRole('button');
       const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));
       const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));
       if (xButton) {
       if (xButton) {

+ 58 - 120
frontend/src/__tests__/components/SpoolmanSettings.test.tsx

@@ -1,15 +1,16 @@
 /**
 /**
  * Tests for the SpoolmanSettings component.
  * Tests for the SpoolmanSettings component.
  *
  *
- * Tests the Spoolman integration UI including:
- * - Enable/disable toggle
- * - URL configuration
- * - Connection status
- * - Sync functionality
+ * Tests the filament tracking mode selector and Spoolman integration UI:
+ * - Mode selector (Built-in Inventory vs Spoolman)
+ * - Built-in Inventory info panel
+ * - Spoolman URL, sync mode, connection status
+ * - Weight sync and partial usage toggles
  */
  */
 
 
 import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { describe, it, expect, vi, beforeEach } from 'vitest';
 import { screen, waitFor } from '@testing-library/react';
 import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { render } from '../utils';
 import { SpoolmanSettings } from '../../components/SpoolmanSettings';
 import { SpoolmanSettings } from '../../components/SpoolmanSettings';
 
 
@@ -26,6 +27,7 @@ vi.mock('../../api/client', () => ({
     syncAllPrintersAms: vi.fn(),
     syncAllPrintersAms: vi.fn(),
     syncPrinterAms: vi.fn(),
     syncPrinterAms: vi.fn(),
     getPrinters: vi.fn(),
     getPrinters: vi.fn(),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
   },
   },
 }));
 }));
 
 
@@ -36,7 +38,7 @@ describe('SpoolmanSettings', () => {
   beforeEach(() => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.clearAllMocks();
 
 
-    // Default API mocks
+    // Default API mocks — Spoolman disabled (Built-in Inventory mode)
     vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
     vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
       spoolman_enabled: 'false',
       spoolman_enabled: 'false',
       spoolman_url: '',
       spoolman_url: '',
@@ -70,90 +72,61 @@ describe('SpoolmanSettings', () => {
 
 
   describe('rendering', () => {
   describe('rendering', () => {
     it('renders loading state initially', () => {
     it('renders loading state initially', () => {
-      // Delay the API response to catch loading state
       vi.mocked(api.getSpoolmanSettings).mockImplementation(() => new Promise(() => {}));
       vi.mocked(api.getSpoolmanSettings).mockImplementation(() => new Promise(() => {}));
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
-      // Should show loading spinner
       expect(document.querySelector('.animate-spin')).toBeInTheDocument();
       expect(document.querySelector('.animate-spin')).toBeInTheDocument();
     });
     });
 
 
-    it('renders component title', async () => {
+    it('renders filament tracking title', async () => {
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
+        expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
       });
       });
     });
     });
 
 
-    it('renders enable toggle', async () => {
+    it('renders mode selector cards', async () => {
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Enable Spoolman')).toBeInTheDocument();
-      });
-    });
-
-    it('renders URL input', async () => {
-      render(<SpoolmanSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Spoolman URL')).toBeInTheDocument();
-        expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
-      });
-    });
-
-    it('renders sync mode selector', async () => {
-      render(<SpoolmanSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('Sync Mode')).toBeInTheDocument();
-      });
-    });
-
-    it('renders info banner about sync', async () => {
-      render(<SpoolmanSettings />);
-
-      await waitFor(() => {
-        expect(screen.getByText('How Sync Works')).toBeInTheDocument();
-        expect(screen.getByText(/Only official Bambu Lab spools/)).toBeInTheDocument();
+        expect(screen.getByText('Built-in Inventory')).toBeInTheDocument();
+        expect(screen.getByText('Spoolman')).toBeInTheDocument();
       });
       });
     });
     });
   });
   });
 
 
-  describe('disabled state', () => {
-    it('URL input is disabled when Spoolman is disabled', async () => {
+  describe('built-in inventory mode (default)', () => {
+    it('shows built-in inventory as selected by default', async () => {
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        const urlInput = screen.getByPlaceholderText('http://192.168.1.100:7912');
-        expect(urlInput).toBeDisabled();
+        // Built-in Inventory card should have the active border
+        const builtInBtn = screen.getByText('Built-in Inventory').closest('button');
+        expect(builtInBtn).toHaveClass('border-bambu-green');
       });
       });
     });
     });
 
 
-    it('sync mode selector is disabled when Spoolman is disabled', async () => {
+    it('shows built-in info panel when selected', async () => {
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        // Find the select by its display value
-        const selectElement = screen.getByDisplayValue('Automatic');
-        expect(selectElement).toBeDisabled();
+        expect(screen.getByText(/Automatically detects Bambu Lab RFID spools/)).toBeInTheDocument();
       });
       });
     });
     });
 
 
-    it('does not show connection status when disabled', async () => {
+    it('does not show Spoolman URL input', async () => {
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
+        expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
       });
       });
 
 
-      // Status section should not be visible when disabled
-      expect(screen.queryByText('Status:')).not.toBeInTheDocument();
+      expect(screen.queryByPlaceholderText('http://192.168.1.100:7912')).not.toBeInTheDocument();
     });
     });
   });
   });
 
 
-  describe('enabled state', () => {
+  describe('spoolman mode', () => {
     beforeEach(() => {
     beforeEach(() => {
       vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
       vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
         spoolman_enabled: 'true',
         spoolman_enabled: 'true',
@@ -171,67 +144,62 @@ describe('SpoolmanSettings', () => {
       });
       });
     });
     });
 
 
-    it('URL input is enabled when Spoolman is enabled', async () => {
+    it('shows Spoolman card as selected', async () => {
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        const urlInput = screen.getByPlaceholderText('http://192.168.1.100:7912');
-        expect(urlInput).not.toBeDisabled();
+        const spoolmanBtn = screen.getByText('Spoolman').closest('button');
+        expect(spoolmanBtn).toHaveClass('border-bambu-green');
       });
       });
     });
     });
 
 
-    it('shows connection status section when enabled', async () => {
+    it('shows URL input when Spoolman is selected', async () => {
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Status:')).toBeInTheDocument();
+        expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
       });
       });
     });
     });
 
 
-    it('shows Disconnected when not connected', async () => {
-      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
-        enabled: true,
-        connected: false,
-        url: 'http://localhost:7912',
-      });
-
+    it('shows sync mode selector', async () => {
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Disconnected')).toBeInTheDocument();
+        expect(screen.getByText('Sync Mode')).toBeInTheDocument();
       });
       });
     });
     });
 
 
-    it('shows Connect button when disconnected', async () => {
-      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
-        enabled: true,
-        connected: false,
-        url: 'http://localhost:7912',
+    it('shows how sync works info', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('How Sync Works')).toBeInTheDocument();
       });
       });
+    });
 
 
+    it('shows connection status section', async () => {
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Connect')).toBeInTheDocument();
+        expect(screen.getByText('Status:')).toBeInTheDocument();
       });
       });
     });
     });
 
 
-    it('shows Connected and Disconnect button when connected', async () => {
+    it('shows Disconnected when not connected', async () => {
       vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
       vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
         enabled: true,
         enabled: true,
-        connected: true,
+        connected: false,
         url: 'http://localhost:7912',
         url: 'http://localhost:7912',
       });
       });
 
 
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Connected')).toBeInTheDocument();
-        expect(screen.getByText('Disconnect')).toBeInTheDocument();
+        expect(screen.getByText('Disconnected')).toBeInTheDocument();
       });
       });
     });
     });
 
 
-    it('shows sync section when connected', async () => {
+    it('shows Connected and Disconnect button when connected', async () => {
       vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
       vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
         enabled: true,
         enabled: true,
         connected: true,
         connected: true,
@@ -241,12 +209,12 @@ describe('SpoolmanSettings', () => {
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Sync AMS Data')).toBeInTheDocument();
-        expect(screen.getByText('Sync')).toBeInTheDocument();
+        expect(screen.getByText('Connected')).toBeInTheDocument();
+        expect(screen.getByText('Disconnect')).toBeInTheDocument();
       });
       });
     });
     });
 
 
-    it('shows All Printers option in sync dropdown', async () => {
+    it('shows sync section when connected', async () => {
       vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
       vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
         enabled: true,
         enabled: true,
         connected: true,
         connected: true,
@@ -256,13 +224,13 @@ describe('SpoolmanSettings', () => {
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByRole('option', { name: 'All Printers' })).toBeInTheDocument();
+        expect(screen.getByText('Sync AMS Data')).toBeInTheDocument();
       });
       });
     });
     });
   });
   });
 
 
   describe('weight sync toggle', () => {
   describe('weight sync toggle', () => {
-    it('shows weight sync toggle when sync mode is auto and enabled', async () => {
+    it('shows weight sync toggle when Spoolman enabled and sync mode is auto', async () => {
       vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
       vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
         spoolman_enabled: 'true',
         spoolman_enabled: 'true',
         spoolman_url: 'http://localhost:7912',
         spoolman_url: 'http://localhost:7912',
@@ -290,20 +258,11 @@ describe('SpoolmanSettings', () => {
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
+        expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
       });
       });
 
 
       expect(screen.queryByText('Disable AMS Estimated Weight Sync')).not.toBeInTheDocument();
       expect(screen.queryByText('Disable AMS Estimated Weight Sync')).not.toBeInTheDocument();
     });
     });
-
-    it('shows weight sync toggle in disabled state when sync mode is auto', async () => {
-      render(<SpoolmanSettings />);
-
-      await waitFor(() => {
-        // Toggle label is visible since sync mode defaults to auto
-        expect(screen.getByText('Disable AMS Estimated Weight Sync')).toBeInTheDocument();
-      });
-    });
   });
   });
 
 
   describe('partial usage toggle', () => {
   describe('partial usage toggle', () => {
@@ -335,49 +294,28 @@ describe('SpoolmanSettings', () => {
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
+        expect(screen.getByText('Filament Tracking')).toBeInTheDocument();
       });
       });
 
 
       expect(screen.queryByText('Report Partial Usage for Failed Prints')).not.toBeInTheDocument();
       expect(screen.queryByText('Report Partial Usage for Failed Prints')).not.toBeInTheDocument();
     });
     });
   });
   });
 
 
-  describe('sync mode options', () => {
-    it('shows Automatic option', async () => {
+  describe('mode switching', () => {
+    it('can switch to Spoolman mode', async () => {
+      const user = userEvent.setup();
       render(<SpoolmanSettings />);
       render(<SpoolmanSettings />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByRole('option', { name: 'Automatic' })).toBeInTheDocument();
+        expect(screen.getByText('Built-in Inventory')).toBeInTheDocument();
       });
       });
-    });
 
 
-    it('shows Manual Only option', async () => {
-      render(<SpoolmanSettings />);
+      // Click Spoolman card
+      await user.click(screen.getByText('Spoolman').closest('button')!);
 
 
+      // Spoolman settings should now be visible
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByRole('option', { name: 'Manual Only' })).toBeInTheDocument();
-      });
-    });
-  });
-
-  describe('info text', () => {
-    it('shows URL help text', async () => {
-      render(<SpoolmanSettings />);
-
-      await waitFor(() => {
-        expect(
-          screen.getByText('URL of your Spoolman server (e.g., http://localhost:7912)')
-        ).toBeInTheDocument();
-      });
-    });
-
-    it('shows sync mode description for auto mode', async () => {
-      render(<SpoolmanSettings />);
-
-      await waitFor(() => {
-        expect(
-          screen.getByText('AMS data syncs automatically when changes are detected')
-        ).toBeInTheDocument();
+        expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
       });
       });
     });
     });
   });
   });

+ 3 - 3
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -86,7 +86,7 @@ describe('SettingsPage', () => {
         expect(screen.getAllByText('General').length).toBeGreaterThan(0);
         expect(screen.getAllByText('General').length).toBeGreaterThan(0);
         expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
         expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
         expect(screen.getByText('Notifications')).toBeInTheDocument();
         expect(screen.getByText('Notifications')).toBeInTheDocument();
-        expect(screen.getByText('Filament')).toBeInTheDocument();
+        expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
         expect(screen.getByText('Network')).toBeInTheDocument();
         expect(screen.getByText('Network')).toBeInTheDocument();
         expect(screen.getByText('API Keys')).toBeInTheDocument();
         expect(screen.getByText('API Keys')).toBeInTheDocument();
       });
       });
@@ -207,10 +207,10 @@ describe('SettingsPage', () => {
       render(<SettingsPage />);
       render(<SettingsPage />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('Filament')).toBeInTheDocument();
+        expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
       });
       });
 
 
-      await user.click(screen.getByText('Filament'));
+      await user.click(screen.getAllByText('Filament')[0]);
 
 
       await waitFor(() => {
       await waitFor(() => {
         expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
         expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();

+ 34 - 6
frontend/src/components/AssignSpoolModal.tsx

@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { X, Loader2, Package, Check, Search } from 'lucide-react';
 import { X, Loader2, Package, Check, Search } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { InventorySpool } from '../api/client';
+import type { InventorySpool, SpoolAssignment } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
@@ -33,12 +33,25 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     enabled: isOpen,
     enabled: isOpen,
   });
   });
 
 
+  const { data: assignments } = useQuery({
+    queryKey: ['spool-assignments'],
+    queryFn: () => api.getAssignments(),
+    enabled: isOpen,
+  });
+
   const assignMutation = useMutation({
   const assignMutation = useMutation({
     mutationFn: (spoolId: number) =>
     mutationFn: (spoolId: number) =>
       api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),
       api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),
-    onSuccess: () => {
+    onSuccess: (newAssignment) => {
+      // Immediately update cache so UI reflects the new assignment without waiting for refetch
+      queryClient.setQueryData<SpoolAssignment[]>(['spool-assignments'], (old) => {
+        const filtered = (old || []).filter(a =>
+          !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId)
+        );
+        filtered.push(newAssignment);
+        return filtered;
+      });
       queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
       queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
-      queryClient.invalidateQueries({ queryKey: ['printer-status'] });
       showToast(t('inventory.assignSuccess'), 'success');
       showToast(t('inventory.assignSuccess'), 'success');
       onClose();
       onClose();
     },
     },
@@ -49,7 +62,18 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
 
 
   if (!isOpen) return null;
   if (!isOpen) return null;
 
 
-  const filteredSpools = spools?.filter((spool: InventorySpool) => {
+  // Filter out Bambu Lab spools (identified by RFID tag_uid or tray_uuid)
+  // and spools already assigned to other slots
+  const assignedSpoolIds = new Set(
+    (assignments || [])
+      .filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
+      .map(a => a.spool_id)
+  );
+  const manualSpools = spools?.filter((spool: InventorySpool) =>
+    !spool.tag_uid && !spool.tray_uuid && !assignedSpoolIds.has(spool.id)
+  );
+
+  const filteredSpools = manualSpools?.filter((spool: InventorySpool) => {
     if (!searchFilter) return true;
     if (!searchFilter) return true;
     const q = searchFilter.toLowerCase();
     const q = searchFilter.toLowerCase();
     return (
     return (
@@ -101,7 +125,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                     style={{ backgroundColor: `#${trayInfo.color}` }}
                     style={{ backgroundColor: `#${trayInfo.color}` }}
                   />
                   />
                 )}
                 )}
-                <span className="text-white font-medium">{trayInfo.type || 'Empty'}</span>
+                <span className="text-white font-medium">{trayInfo.type || t('ams.emptySlot')}</span>
                 <span className="text-bambu-gray">({trayInfo.location})</span>
                 <span className="text-bambu-gray">({trayInfo.location})</span>
               </div>
               </div>
             </div>
             </div>
@@ -161,9 +185,13 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                   </button>
                   </button>
                 ))}
                 ))}
               </div>
               </div>
+            ) : manualSpools && manualSpools.length === 0 ? (
+              <div className="text-center py-8 text-bambu-gray">
+                <p>{t('inventory.noManualSpools')}</p>
+              </div>
             ) : (
             ) : (
               <div className="text-center py-8 text-bambu-gray">
               <div className="text-center py-8 text-bambu-gray">
-                <p>{t('inventory.noSpools')}</p>
+                <p>{t('inventory.noSpoolsMatch')}</p>
               </div>
               </div>
             )}
             )}
           </div>
           </div>

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

@@ -442,9 +442,9 @@ export function ColorCatalogSettings() {
                 <tr>
                 <tr>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium w-12"></th>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium w-12"></th>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.manufacturer')}</th>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.manufacturer')}</th>
+                  <th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('inventory.material')}</th>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.colorName')}</th>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('settings.colorCatalog.colorName')}</th>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium w-24">{t('settings.colorCatalog.hex')}</th>
                   <th className="px-3 py-2 text-left text-bambu-gray font-medium w-24">{t('settings.colorCatalog.hex')}</th>
-                  <th className="px-3 py-2 text-left text-bambu-gray font-medium">{t('inventory.material')}</th>
                   <th className="px-3 py-2 w-16"></th>
                   <th className="px-3 py-2 w-16"></th>
                 </tr>
                 </tr>
               </thead>
               </thead>
@@ -480,24 +480,24 @@ export function ColorCatalogSettings() {
                             <input
                             <input
                               type="text"
                               type="text"
                               className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
                               className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
-                              value={formColorName}
-                              onChange={(e) => setFormColorName(e.target.value)}
+                              value={formMaterial}
+                              onChange={(e) => setFormMaterial(e.target.value)}
                             />
                             />
                           </td>
                           </td>
                           <td className="px-3 py-2">
                           <td className="px-3 py-2">
                             <input
                             <input
                               type="text"
                               type="text"
                               className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
                               className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
-                              value={formHexColor}
-                              onChange={(e) => setFormHexColor(e.target.value)}
+                              value={formColorName}
+                              onChange={(e) => setFormColorName(e.target.value)}
                             />
                             />
                           </td>
                           </td>
                           <td className="px-3 py-2">
                           <td className="px-3 py-2">
                             <input
                             <input
                               type="text"
                               type="text"
                               className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
                               className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
-                              value={formMaterial}
-                              onChange={(e) => setFormMaterial(e.target.value)}
+                              value={formHexColor}
+                              onChange={(e) => setFormHexColor(e.target.value)}
                             />
                             />
                           </td>
                           </td>
                           <td className="px-3 py-2">
                           <td className="px-3 py-2">
@@ -525,9 +525,9 @@ export function ColorCatalogSettings() {
                             />
                             />
                           </td>
                           </td>
                           <td className="px-3 py-2 text-white">{entry.manufacturer}</td>
                           <td className="px-3 py-2 text-white">{entry.manufacturer}</td>
+                          <td className="px-3 py-2 text-bambu-gray">{entry.material || '-'}</td>
                           <td className="px-3 py-2 text-white">{entry.color_name}</td>
                           <td className="px-3 py-2 text-white">{entry.color_name}</td>
                           <td className="px-3 py-2 font-mono text-xs text-bambu-gray">{entry.hex_color}</td>
                           <td className="px-3 py-2 font-mono text-xs text-bambu-gray">{entry.hex_color}</td>
-                          <td className="px-3 py-2 text-bambu-gray">{entry.material || '-'}</td>
                           <td className="px-3 py-2">
                           <td className="px-3 py-2">
                             <div className="flex justify-end gap-1">
                             <div className="flex justify-end gap-1">
                               <button
                               <button

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

@@ -329,8 +329,8 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                 </div>
                 </div>
               )}
               )}
 
 
-              {/* Inventory section - assign/unassign for non-Bambu spools */}
-              {inventory && (data.vendor !== 'Bambu Lab' || !data.trayUuid) && (
+              {/* Inventory section - only for non-Bambu spools */}
+              {inventory && data.vendor !== 'Bambu Lab' && (
                 <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
                 <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
                   {inventory.assignedSpool ? (
                   {inventory.assignedSpool ? (
                     <>
                     <>

+ 30 - 10
frontend/src/components/SpoolFormModal.tsx

@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { X, Loader2, Save, Beaker, Palette } from 'lucide-react';
 import { X, Loader2, Save, Beaker, Palette } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { InventorySpool, SlicerSetting, SpoolCatalogEntry } from '../api/client';
+import type { InventorySpool, SlicerSetting, SpoolCatalogEntry, LocalPreset } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
 import type { SpoolFormData, PrinterWithCalibrations, ColorPreset } from './spool-form/types';
@@ -45,6 +45,12 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
   // Spool catalog
   // Spool catalog
   const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
   const [spoolCatalog, setSpoolCatalog] = useState<SpoolCatalogEntry[]>([]);
 
 
+  // Local presets (OrcaSlicer imports)
+  const [localPresets, setLocalPresets] = useState<LocalPreset[]>([]);
+
+  // Color catalog
+  const [colorCatalog, setColorCatalog] = useState<{ manufacturer: string; color_name: string; hex_color: string; material: string | null }[]>([]);
+
   // Color state
   // Color state
   const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
   const [recentColors, setRecentColors] = useState<ColorPreset[]>([]);
 
 
@@ -89,6 +95,8 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
       };
       };
       fetchData();
       fetchData();
       api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
       api.getSpoolCatalog().then(setSpoolCatalog).catch(console.error);
+      api.getColorCatalog().then(setColorCatalog).catch(console.error);
+      api.getLocalPresets().then(r => setLocalPresets(r.filament)).catch(console.error);
 
 
       // Fetch printer calibrations if not provided via props
       // Fetch printer calibrations if not provided via props
       if (printersWithCalibrations.length === 0) {
       if (printersWithCalibrations.length === 0) {
@@ -130,18 +138,18 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
         })();
         })();
       }
       }
     }
     }
-  }, [isOpen]);
+  }, [isOpen, printersWithCalibrations.length]);
 
 
-  // Build filament options from cloud presets
+  // Build filament options: cloud → local → fallback
   const filamentOptions = useMemo(
   const filamentOptions = useMemo(
-    () => buildFilamentOptions(cloudPresets, new Set()),
-    [cloudPresets],
+    () => buildFilamentOptions(cloudPresets, new Set(), localPresets),
+    [cloudPresets, localPresets],
   );
   );
 
 
   // Extract brands from presets
   // Extract brands from presets
   const availableBrands = useMemo(
   const availableBrands = useMemo(
-    () => extractBrandsFromPresets(cloudPresets),
-    [cloudPresets],
+    () => extractBrandsFromPresets(cloudPresets, localPresets),
+    [cloudPresets, localPresets],
   );
   );
 
 
   // Find selected preset option
   // Find selected preset option
@@ -162,6 +170,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
           rgba: spool.rgba || '808080FF',
           rgba: spool.rgba || '808080FF',
           label_weight: spool.label_weight || 1000,
           label_weight: spool.label_weight || 1000,
           core_weight: spool.core_weight || 250,
           core_weight: spool.core_weight || 250,
+          weight_used: spool.weight_used || 0,
           slicer_filament: spool.slicer_filament || '',
           slicer_filament: spool.slicer_filament || '',
           note: spool.note || '',
           note: spool.note || '',
         });
         });
@@ -214,11 +223,11 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
     mutationFn: (data: Record<string, unknown>) =>
     mutationFn: (data: Record<string, unknown>) =>
       api.createSpool(data as Parameters<typeof api.createSpool>[0]),
       api.createSpool(data as Parameters<typeof api.createSpool>[0]),
     onSuccess: async (newSpool) => {
     onSuccess: async (newSpool) => {
-      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       // Save K-profiles if any selected
       // Save K-profiles if any selected
       if (selectedProfiles.size > 0 && newSpool?.id) {
       if (selectedProfiles.size > 0 && newSpool?.id) {
         await saveKProfiles(newSpool.id);
         await saveKProfiles(newSpool.id);
       }
       }
+      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       showToast(t('inventory.spoolCreated'), 'success');
       showToast(t('inventory.spoolCreated'), 'success');
       onClose();
       onClose();
     },
     },
@@ -231,11 +240,11 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
     mutationFn: (data: Record<string, unknown>) =>
     mutationFn: (data: Record<string, unknown>) =>
       api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
       api.updateSpool(spool!.id, data as Parameters<typeof api.updateSpool>[1]),
     onSuccess: async () => {
     onSuccess: async () => {
-      queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       // Save K-profiles
       // Save K-profiles
       if (spool?.id) {
       if (spool?.id) {
         await saveKProfiles(spool.id);
         await saveKProfiles(spool.id);
       }
       }
+      await queryClient.invalidateQueries({ queryKey: ['inventory-spools'] });
       showToast(t('inventory.spoolUpdated'), 'success');
       showToast(t('inventory.spoolUpdated'), 'success');
       onClose();
       onClose();
     },
     },
@@ -290,6 +299,16 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
     }
     }
   };
   };
 
 
+  // Close on Escape key
+  useEffect(() => {
+    if (!isOpen) return;
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    document.addEventListener('keydown', handleKeyDown);
+    return () => document.removeEventListener('keydown', handleKeyDown);
+  }, [isOpen, onClose]);
+
   if (!isOpen) return null;
   if (!isOpen) return null;
 
 
   const handleSubmit = () => {
   const handleSubmit = () => {
@@ -314,7 +333,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
       rgba: formData.rgba || null,
       rgba: formData.rgba || null,
       label_weight: formData.label_weight,
       label_weight: formData.label_weight,
       core_weight: formData.core_weight,
       core_weight: formData.core_weight,
-      weight_used: spool?.weight_used ?? 0,
+      weight_used: formData.weight_used,
       slicer_filament: formData.slicer_filament || null,
       slicer_filament: formData.slicer_filament || null,
       slicer_filament_name: presetName,
       slicer_filament_name: presetName,
       nozzle_temp_min: null,
       nozzle_temp_min: null,
@@ -421,6 +440,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
                   updateField={updateField}
                   updateField={updateField}
                   recentColors={recentColors}
                   recentColors={recentColors}
                   onColorUsed={handleColorUsed}
                   onColorUsed={handleColorUsed}
+                  catalogColors={colorCatalog}
                 />
                 />
               </div>
               </div>
 
 

+ 22 - 0
frontend/src/components/spool-form/AdditionalSection.tsx

@@ -139,6 +139,28 @@ export function AdditionalSection({
         onChange={(weight) => updateField('core_weight', weight)}
         onChange={(weight) => updateField('core_weight', weight)}
       />
       />
 
 
+      {/* Current Weight (remaining filament) */}
+      <div>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.currentWeight')}</label>
+        <div className="flex items-center gap-2">
+          <div className="relative flex-1">
+            <input
+              type="number"
+              value={Math.max(0, formData.label_weight - formData.weight_used)}
+              min={0}
+              max={formData.label_weight}
+              onChange={(e) => {
+                const remaining = parseInt(e.target.value) || 0;
+                updateField('weight_used', Math.max(0, formData.label_weight - remaining));
+              }}
+              className="w-full px-3 py-2 pr-7 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+            />
+            <span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-bambu-gray">g</span>
+          </div>
+          <span className="text-xs text-bambu-gray shrink-0">/ {formData.label_weight}g</span>
+        </div>
+      </div>
+
       {/* Note */}
       {/* Note */}
       <div>
       <div>
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>
         <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.note')}</label>

+ 155 - 42
frontend/src/components/spool-form/ColorSection.tsx

@@ -9,6 +9,7 @@ export function ColorSection({
   updateField,
   updateField,
   recentColors,
   recentColors,
   onColorUsed,
   onColorUsed,
+  catalogColors,
 }: ColorSectionProps) {
 }: ColorSectionProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [showAllColors, setShowAllColors] = useState(false);
   const [showAllColors, setShowAllColors] = useState(false);
@@ -28,8 +29,87 @@ export function ColorSection({
     onColorUsed({ name, hex });
     onColorUsed({ name, hex });
   };
   };
 
 
-  // Colors to show based on search/expand state
-  const filteredColors = useMemo(() => {
+  // Filter catalog colors by the selected brand + material + subtype
+  // Brand matching is word-based: "mz - Bambu" matches "Bambu Lab" because both contain "Bambu"
+  // Material matching: try exact "PETG Basic" first, fall back to base material "PETG" prefix
+  const matchedCatalogColors = useMemo(() => {
+    if (catalogColors.length === 0) return [];
+    const brand = formData.brand?.trim();
+    const material = formData.material?.toLowerCase().trim();
+    const subtype = formData.subtype?.toLowerCase().trim();
+    if (!brand && !material) return [];
+
+    // Split brand into words (>= 2 chars) for word-based matching
+    const brandWords = brand
+      ? brand.toLowerCase().split(/[\s\-_]+/).filter(w => w.length >= 2)
+      : [];
+
+    const brandMatches = (manufacturer: string) => {
+      if (brandWords.length === 0) return true; // no brand filter
+      const mfrLower = manufacturer.toLowerCase();
+      // Any significant brand word found in manufacturer name
+      return brandWords.some(w => mfrLower.includes(w));
+    };
+
+    // Build the combined material+subtype string to match catalog entries
+    const fullMaterial = material && subtype ? `${material} ${subtype}` : '';
+
+    // First pass: try exact fullMaterial match (e.g. "PETG Basic")
+    if (fullMaterial) {
+      const exact = catalogColors.filter(c =>
+        brandMatches(c.manufacturer) &&
+        c.material?.toLowerCase() === fullMaterial,
+      );
+      if (exact.length > 0) {
+        return exact.map(c => ({
+          name: c.color_name,
+          hex: c.hex_color.replace('#', '').substring(0, 6),
+        }));
+      }
+      // Try without trailing "+" (e.g. "PLA Silk+" -> "PLA Silk")
+      const normalized = fullMaterial.replace(/\+$/, '');
+      if (normalized !== fullMaterial) {
+        const normMatch = catalogColors.filter(c =>
+          brandMatches(c.manufacturer) &&
+          c.material?.toLowerCase() === normalized,
+        );
+        if (normMatch.length > 0) {
+          return normMatch.map(c => ({
+            name: c.color_name,
+            hex: c.hex_color.replace('#', '').substring(0, 6),
+          }));
+        }
+      }
+    }
+
+    // Second pass: match base material prefix (e.g. "PETG" matches "PETG Basic", "PETG-HF")
+    if (material) {
+      const byMaterial = catalogColors.filter(c =>
+        brandMatches(c.manufacturer) &&
+        (!c.material || c.material.toLowerCase().startsWith(material)),
+      );
+      if (byMaterial.length > 0) {
+        return byMaterial.map(c => ({
+          name: c.color_name,
+          hex: c.hex_color.replace('#', '').substring(0, 6),
+        }));
+      }
+    }
+
+    return [];
+  }, [catalogColors, formData.brand, formData.material, formData.subtype]);
+
+  const hasCatalogMatch = matchedCatalogColors.length > 0;
+
+  // Search within matched catalog colors
+  const filteredCatalogColors = useMemo(() => {
+    if (!colorSearch) return matchedCatalogColors;
+    const q = colorSearch.toLowerCase();
+    return matchedCatalogColors.filter(c => c.name.toLowerCase().includes(q));
+  }, [matchedCatalogColors, colorSearch]);
+
+  // Fallback hardcoded colors for search/expand
+  const filteredFallbackColors = useMemo(() => {
     if (colorSearch) {
     if (colorSearch) {
       return ALL_COLORS.filter(c =>
       return ALL_COLORS.filter(c =>
         c.name.toLowerCase().includes(colorSearch.toLowerCase()),
         c.name.toLowerCase().includes(colorSearch.toLowerCase()),
@@ -84,48 +164,81 @@ export function ColorSection({
         />
         />
       </div>
       </div>
 
 
-      {/* Color Swatches Grid */}
-      <div className="space-y-1.5">
-        <div className="flex items-center justify-between text-xs text-bambu-gray">
-          <span>{colorSearch ? t('inventory.searchResults') : (showAllColors ? t('inventory.allColors') : t('inventory.commonColors'))}</span>
-          {!colorSearch && (
-            <button
-              type="button"
-              onClick={() => setShowAllColors(!showAllColors)}
-              className="flex items-center gap-1 hover:text-white transition-colors"
-            >
-              {showAllColors ? (
-                <>{t('inventory.showLess')} <ChevronUp className="w-3 h-3" /></>
-              ) : (
-                <>{t('inventory.showAll')} <ChevronDown className="w-3 h-3" /></>
-              )}
-            </button>
-          )}
+      {/* Color Swatches */}
+      {hasCatalogMatch ? (
+        /* Catalog colors matching selected brand/material */
+        <div className="space-y-1.5">
+          <span className="text-xs text-bambu-gray">
+            {colorSearch ? t('inventory.searchResults') : `${formData.brand}${formData.material ? ` ${formData.material}` : ''}`}
+          </span>
+          <div className="flex flex-wrap gap-1.5">
+            {filteredCatalogColors.map(color => (
+              <button
+                key={`${color.hex}-${color.name}`}
+                type="button"
+                onClick={() => selectColor(color.hex, color.name)}
+                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
+                  isSelected(color.hex)
+                    ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
+                    : 'border-bambu-dark-tertiary'
+                }`}
+                style={{ backgroundColor: `#${color.hex}` }}
+                title={color.name}
+              >
+                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
+                  {color.name}
+                </span>
+              </button>
+            ))}
+            {filteredCatalogColors.length === 0 && (
+              <p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
+            )}
+          </div>
         </div>
         </div>
-        <div className="flex flex-wrap gap-1.5">
-          {filteredColors.map(color => (
-            <button
-              key={color.hex}
-              type="button"
-              onClick={() => selectColor(color.hex, color.name)}
-              className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
-                isSelected(color.hex)
-                  ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
-                  : 'border-bambu-dark-tertiary'
-              }`}
-              style={{ backgroundColor: `#${color.hex}` }}
-              title={color.name}
-            >
-              <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
-                {color.name}
-              </span>
-            </button>
-          ))}
-          {filteredColors.length === 0 && (
-            <p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
-          )}
+      ) : (
+        /* Fallback: hardcoded color palette (no brand/material selected or no catalog matches) */
+        <div className="space-y-1.5">
+          <div className="flex items-center justify-between text-xs text-bambu-gray">
+            <span>{colorSearch ? t('inventory.searchResults') : (showAllColors ? t('inventory.allColors') : t('inventory.commonColors'))}</span>
+            {!colorSearch && (
+              <button
+                type="button"
+                onClick={() => setShowAllColors(!showAllColors)}
+                className="flex items-center gap-1 hover:text-white transition-colors"
+              >
+                {showAllColors ? (
+                  <>{t('inventory.showLess')} <ChevronUp className="w-3 h-3" /></>
+                ) : (
+                  <>{t('inventory.showAll')} <ChevronDown className="w-3 h-3" /></>
+                )}
+              </button>
+            )}
+          </div>
+          <div className="flex flex-wrap gap-1.5">
+            {filteredFallbackColors.map(color => (
+              <button
+                key={color.hex}
+                type="button"
+                onClick={() => selectColor(color.hex, color.name)}
+                className={`w-6 h-6 rounded border-2 transition-all hover:scale-110 relative group ${
+                  isSelected(color.hex)
+                    ? 'border-bambu-green ring-1 ring-bambu-green/30 scale-110'
+                    : 'border-bambu-dark-tertiary'
+                }`}
+                style={{ backgroundColor: `#${color.hex}` }}
+                title={color.name}
+              >
+                <span className="absolute -bottom-7 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-xs whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg text-white">
+                  {color.name}
+                </span>
+              </button>
+            ))}
+            {filteredFallbackColors.length === 0 && (
+              <p className="text-sm text-bambu-gray py-1">{t('inventory.noColorsFound')}</p>
+            )}
+          </div>
         </div>
         </div>
-      </div>
+      )}
 
 
       {/* Manual Color Input */}
       {/* Manual Color Input */}
       <div className="grid grid-cols-2 gap-3">
       <div className="grid grid-cols-2 gap-3">

+ 3 - 0
frontend/src/components/spool-form/types.ts

@@ -9,6 +9,7 @@ export interface SpoolFormData {
   rgba: string;
   rgba: string;
   label_weight: number;
   label_weight: number;
   core_weight: number;
   core_weight: number;
+  weight_used: number;
   slicer_filament: string;
   slicer_filament: string;
   note: string;
   note: string;
 }
 }
@@ -21,6 +22,7 @@ export const defaultFormData: SpoolFormData = {
   rgba: '808080FF',
   rgba: '808080FF',
   label_weight: 1000,
   label_weight: 1000,
   core_weight: 250,
   core_weight: 250,
+  weight_used: 0,
   slicer_filament: '',
   slicer_filament: '',
   note: '',
   note: '',
 };
 };
@@ -79,6 +81,7 @@ export interface FilamentSectionProps extends SectionProps {
 export interface ColorSectionProps extends SectionProps {
 export interface ColorSectionProps extends SectionProps {
   recentColors: ColorPreset[];
   recentColors: ColorPreset[];
   onColorUsed: (color: ColorPreset) => void;
   onColorUsed: (color: ColorPreset) => void;
+  catalogColors: { manufacturer: string; color_name: string; hex_color: string; material: string | null }[];
 }
 }
 
 
 // Additional section props
 // Additional section props

+ 53 - 5
frontend/src/components/spool-form/utils.ts

@@ -1,4 +1,4 @@
-import type { SlicerSetting } from '../../api/client';
+import type { SlicerSetting, LocalPreset } from '../../api/client';
 import type { ColorPreset, FilamentOption } from './types';
 import type { ColorPreset, FilamentOption } from './types';
 import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
 import { KNOWN_VARIANTS, DEFAULT_BRANDS, RECENT_COLORS_KEY, MAX_RECENT_COLORS } from './constants';
 
 
@@ -91,8 +91,8 @@ export function parsePresetName(name: string): { brand: string; material: string
   return { brand, material, variant };
   return { brand, material, variant };
 }
 }
 
 
-// Extract unique brands from cloud presets
-export function extractBrandsFromPresets(presets: SlicerSetting[]): string[] {
+// Extract unique brands from cloud presets and local presets
+export function extractBrandsFromPresets(presets: SlicerSetting[], localPresets?: LocalPreset[]): string[] {
   const brandSet = new Set<string>(DEFAULT_BRANDS);
   const brandSet = new Set<string>(DEFAULT_BRANDS);
 
 
   for (const preset of presets) {
   for (const preset of presets) {
@@ -102,14 +102,56 @@ export function extractBrandsFromPresets(presets: SlicerSetting[]): string[] {
     }
     }
   }
   }
 
 
+  // Also extract brands from local presets
+  if (localPresets) {
+    for (const preset of localPresets) {
+      if (preset.filament_vendor && preset.filament_vendor.length > 1) {
+        brandSet.add(preset.filament_vendor);
+      } else {
+        const { brand } = parsePresetName(preset.name);
+        if (brand && brand.length > 1) {
+          brandSet.add(brand);
+        }
+      }
+    }
+  }
+
   return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
   return Array.from(brandSet).sort((a, b) => a.localeCompare(b));
 }
 }
 
 
-// Build filament options from cloud presets
+// Build filament options from local presets (OrcaSlicer imports)
+function buildLocalFilamentOptions(localPresets: LocalPreset[]): FilamentOption[] {
+  const filamentPresets = localPresets.filter(p => p.preset_type === 'filament');
+  if (filamentPresets.length === 0) return [];
+
+  const presetsMap = new Map<string, FilamentOption>();
+  for (const preset of filamentPresets) {
+    const baseName = preset.name.replace(/@.*$/, '').trim();
+    const existing = presetsMap.get(baseName);
+    if (existing) {
+      existing.allCodes.push(String(preset.id));
+    } else {
+      // Use filament_type as the code if available (e.g. "GFL00"), otherwise use the id
+      const code = preset.filament_type || String(preset.id);
+      presetsMap.set(baseName, {
+        code,
+        name: baseName,
+        displayName: baseName,
+        isCustom: false,
+        allCodes: [code],
+      });
+    }
+  }
+  return Array.from(presetsMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName));
+}
+
+// Build filament options: cloud presets → local presets → hardcoded fallback
 export function buildFilamentOptions(
 export function buildFilamentOptions(
   cloudPresets: SlicerSetting[],
   cloudPresets: SlicerSetting[],
   configuredPrinterModels: Set<string>,
   configuredPrinterModels: Set<string>,
+  localPresets?: LocalPreset[],
 ): FilamentOption[] {
 ): FilamentOption[] {
+  // 1. Cloud presets (highest priority)
   if (cloudPresets.length > 0) {
   if (cloudPresets.length > 0) {
     const customPresets: FilamentOption[] = [];
     const customPresets: FilamentOption[] = [];
     const defaultPresetsMap = new Map<string, FilamentOption>();
     const defaultPresetsMap = new Map<string, FilamentOption>();
@@ -155,7 +197,13 @@ export function buildFilamentOptions(
     ].sort((a, b) => a.displayName.localeCompare(b.displayName));
     ].sort((a, b) => a.displayName.localeCompare(b.displayName));
   }
   }
 
 
-  // Fallback to hardcoded defaults
+  // 2. Local presets (OrcaSlicer imports)
+  if (localPresets && localPresets.length > 0) {
+    const localOptions = buildLocalFilamentOptions(localPresets);
+    if (localOptions.length > 0) return localOptions;
+  }
+
+  // 3. Hardcoded fallback
   return FALLBACK_PRESETS;
   return FALLBACK_PRESETS;
 }
 }
 
 

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

@@ -8,7 +8,7 @@ export default {
     profiles: 'Profile',
     profiles: 'Profile',
     maintenance: 'Wartung',
     maintenance: 'Wartung',
     projects: 'Projekte',
     projects: 'Projekte',
-    inventory: 'Inventar',
+    inventory: 'Filament',
     files: 'Dateimanager',
     files: 'Dateimanager',
     settings: 'Einstellungen',
     settings: 'Einstellungen',
     system: 'System',
     system: 'System',
@@ -2447,6 +2447,7 @@ export default {
     coreWeight: 'Leergewicht der Spule',
     coreWeight: 'Leergewicht der Spule',
     searchSpoolWeight: 'Spulengewicht suchen...',
     searchSpoolWeight: 'Spulengewicht suchen...',
     weightUsed: 'Verbraucht',
     weightUsed: 'Verbraucht',
+    currentWeight: 'Restgewicht',
     slicerFilament: 'Slicer-Filament',
     slicerFilament: 'Slicer-Filament',
     slicerFilamentName: 'Slicer-Preset-Name',
     slicerFilamentName: 'Slicer-Preset-Name',
     slicerPreset: 'Slicer-Preset',
     slicerPreset: 'Slicer-Preset',
@@ -2459,6 +2460,7 @@ export default {
     archive: 'Archivieren',
     archive: 'Archivieren',
     restore: 'Wiederherstellen',
     restore: 'Wiederherstellen',
     noSpools: 'Noch keine Spulen. Fügen Sie Ihre erste Spule hinzu.',
     noSpools: 'Noch keine Spulen. Fügen Sie Ihre erste Spule hinzu.',
+    noManualSpools: 'Keine manuell hinzugefügten Spulen verfügbar. Fügen Sie zuerst eine Spule zum Inventar hinzu.',
     kProfiles: 'K-Profile',
     kProfiles: 'K-Profile',
     addKProfile: 'K-Profil hinzufügen',
     addKProfile: 'K-Profil hinzufügen',
     assignSpool: 'Spule zuweisen',
     assignSpool: 'Spule zuweisen',

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

@@ -8,7 +8,7 @@ export default {
     profiles: 'Profiles',
     profiles: 'Profiles',
     maintenance: 'Maintenance',
     maintenance: 'Maintenance',
     projects: 'Projects',
     projects: 'Projects',
-    inventory: 'Inventory',
+    inventory: 'Filament',
     files: 'File Manager',
     files: 'File Manager',
     settings: 'Settings',
     settings: 'Settings',
     system: 'System',
     system: 'System',
@@ -2447,6 +2447,7 @@ export default {
     coreWeight: 'Empty Spool Weight',
     coreWeight: 'Empty Spool Weight',
     searchSpoolWeight: 'Search spool weight...',
     searchSpoolWeight: 'Search spool weight...',
     weightUsed: 'Used',
     weightUsed: 'Used',
+    currentWeight: 'Remaining Weight',
     slicerFilament: 'Slicer Filament',
     slicerFilament: 'Slicer Filament',
     slicerFilamentName: 'Slicer Preset Name',
     slicerFilamentName: 'Slicer Preset Name',
     slicerPreset: 'Slicer Preset',
     slicerPreset: 'Slicer Preset',
@@ -2459,6 +2460,7 @@ export default {
     archive: 'Archive',
     archive: 'Archive',
     restore: 'Restore',
     restore: 'Restore',
     noSpools: 'No spools yet. Add your first spool to get started.',
     noSpools: 'No spools yet. Add your first spool to get started.',
+    noManualSpools: 'No manually added spools available. Add a spool to your inventory first.',
     kProfiles: 'K-Profiles',
     kProfiles: 'K-Profiles',
     addKProfile: 'Add K-Profile',
     addKProfile: 'Add K-Profile',
     assignSpool: 'Assign Spool',
     assignSpool: 'Assign Spool',

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

@@ -7,7 +7,7 @@ export default {
     profiles: 'プロファイル',
     profiles: 'プロファイル',
     maintenance: 'メンテナンス',
     maintenance: 'メンテナンス',
     projects: 'プロジェクト',
     projects: 'プロジェクト',
-    inventory: 'インベントリ',
+    inventory: 'フィラメント',
     files: 'ファイル管理',
     files: 'ファイル管理',
     settings: '設定',
     settings: '設定',
     system: 'システム',
     system: 'システム',
@@ -2378,6 +2378,7 @@ export default {
     coreWeight: '空スプール重量',
     coreWeight: '空スプール重量',
     searchSpoolWeight: 'スプール重量を検索...',
     searchSpoolWeight: 'スプール重量を検索...',
     weightUsed: '使用量',
     weightUsed: '使用量',
+    currentWeight: '残量',
     slicerFilament: 'スライサーフィラメント',
     slicerFilament: 'スライサーフィラメント',
     slicerFilamentName: 'スライサープリセット名',
     slicerFilamentName: 'スライサープリセット名',
     slicerPreset: 'スライサープリセット',
     slicerPreset: 'スライサープリセット',
@@ -2390,6 +2391,7 @@ export default {
     archive: 'アーカイブ',
     archive: 'アーカイブ',
     restore: '復元',
     restore: '復元',
     noSpools: 'スプールがありません。最初のスプールを追加してください。',
     noSpools: 'スプールがありません。最初のスプールを追加してください。',
+    noManualSpools: '手動で追加されたスプールがありません。先にインベントリにスプールを追加してください。',
     kProfiles: 'Kプロファイル',
     kProfiles: 'Kプロファイル',
     addKProfile: 'Kプロファイルを追加',
     addKProfile: 'Kプロファイルを追加',
     assignSpool: 'スプールを割り当て',
     assignSpool: 'スプールを割り当て',

+ 235 - 99
frontend/src/pages/InventoryPage.tsx

@@ -5,6 +5,7 @@ import {
   Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
   Plus, Loader2, Trash2, Archive, RotateCcw, Edit2, Package,
   Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
   Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
   TrendingDown, Layers, Printer, AlertTriangle, X, Clock, LayoutGrid, TableProperties, Columns,
+  ArrowUp, ArrowDown, ArrowUpDown,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { InventorySpool, SpoolAssignment } from '../api/client';
 import type { InventorySpool, SpoolAssignment } from '../api/client';
@@ -17,6 +18,8 @@ import { resolveSpoolColorName } from '../utils/colors';
 type ArchiveFilter = 'active' | 'archived';
 type ArchiveFilter = 'active' | 'archived';
 type UsageFilter = 'all' | 'used' | 'new';
 type UsageFilter = 'all' | 'used' | 'new';
 type ViewMode = 'table' | 'cards';
 type ViewMode = 'table' | 'cards';
+type SortDirection = 'asc' | 'desc';
+type SortState = { column: string; direction: SortDirection } | null;
 
 
 // Column definitions for the inventory table
 // Column definitions for the inventory table
 const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';
 const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';
@@ -249,6 +252,52 @@ const columnCells: Record<string, (ctx: CellCtx) => ReactNode> = {
   ),
   ),
 };
 };
 
 
+// Sort value extractors — return a comparable value for each sortable column
+const columnSortValues: Record<string, (spool: InventorySpool, assignmentMap: Record<number, SpoolAssignment>) => string | number> = {
+  id: (s) => s.id,
+  added_time: (s) => s.created_at || '',
+  encode_time: (s) => s.encode_time || '',
+  last_used_time: (s) => s.last_used || '',
+  material: (s) => (s.material || '').toLowerCase(),
+  subtype: (s) => (s.subtype || '').toLowerCase(),
+  color_name: (s) => (s.color_name || '').toLowerCase(),
+  brand: (s) => (s.brand || '').toLowerCase(),
+  slicer_filament: (s) => (s.slicer_filament_name || s.slicer_filament || '').toLowerCase(),
+  location: (s, am) => {
+    const a = am[s.id];
+    if (!a) return '';
+    return `${a.printer_name || ''} ${String.fromCharCode(65 + a.ams_id)}${a.tray_id + 1}`;
+  },
+  label_weight: (s) => s.label_weight,
+  net: (s) => Math.max(0, s.label_weight - s.weight_used),
+  gross: (s) => Math.max(0, s.label_weight - s.weight_used) + s.core_weight,
+  used: (s) => s.weight_used,
+  remaining: (s) => s.label_weight > 0 ? Math.max(0, s.label_weight - s.weight_used) / s.label_weight : 0,
+  note: (s) => (s.note || '').toLowerCase(),
+  data_origin: (s) => (s.data_origin || '').toLowerCase(),
+  tag_type: (s) => (s.tag_type || '').toLowerCase(),
+};
+
+const SORT_STATE_KEY = 'bambuddy-inventory-sort';
+
+function loadSortState(): SortState {
+  try {
+    const stored = localStorage.getItem(SORT_STATE_KEY);
+    if (stored) return JSON.parse(stored);
+  } catch { /* ignore */ }
+  return null;
+}
+
+function saveSortState(state: SortState) {
+  try {
+    if (state) {
+      localStorage.setItem(SORT_STATE_KEY, JSON.stringify(state));
+    } else {
+      localStorage.removeItem(SORT_STATE_KEY);
+    }
+  } catch { /* ignore */ }
+}
+
 export default function InventoryPage() {
 export default function InventoryPage() {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -262,21 +311,33 @@ export default function InventoryPage() {
   const [brandFilter, setBrandFilter] = useState('');
   const [brandFilter, setBrandFilter] = useState('');
   const [search, setSearch] = useState('');
   const [search, setSearch] = useState('');
   const [viewMode, setViewMode] = useState<ViewMode>('table');
   const [viewMode, setViewMode] = useState<ViewMode>('table');
+  const [sortState, setSortState] = useState<SortState>(loadSortState);
   const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(loadColumnConfig);
   const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(loadColumnConfig);
   const [showColumnModal, setShowColumnModal] = useState(false);
   const [showColumnModal, setShowColumnModal] = useState(false);
 
 
-  // Pagination state
+  // Pagination state (pageSize persisted to localStorage)
   const [pageIndex, setPageIndex] = useState(0);
   const [pageIndex, setPageIndex] = useState(0);
-  const [pageSize, setPageSize] = useState(15);
+  const [pageSize, setPageSize] = useState(() => {
+    try {
+      const stored = localStorage.getItem('bambuddy-inventory-pageSize');
+      if (stored) {
+        const n = Number(stored);
+        if ([15, 30, 50, 100, -1].includes(n)) return n;
+      }
+    } catch { /* ignore */ }
+    return 15;
+  });
 
 
   const { data: spools, isLoading } = useQuery({
   const { data: spools, isLoading } = useQuery({
     queryKey: ['inventory-spools'],
     queryKey: ['inventory-spools'],
     queryFn: () => api.getSpools(true), // Always fetch all, filter client-side
     queryFn: () => api.getSpools(true), // Always fetch all, filter client-side
+    refetchInterval: 30000,
   });
   });
 
 
   const { data: assignments } = useQuery({
   const { data: assignments } = useQuery({
     queryKey: ['spool-assignments'],
     queryKey: ['spool-assignments'],
     queryFn: () => api.getAssignments(),
     queryFn: () => api.getAssignments(),
+    refetchInterval: 30000,
   });
   });
 
 
   const deleteMutation = useMutation({
   const deleteMutation = useMutation({
@@ -390,11 +451,6 @@ export default function InventoryPage() {
     return filtered;
     return filtered;
   }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, search]);
   }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, search]);
 
 
-  // Pagination
-  const totalPages = Math.max(1, Math.ceil(filteredSpools.length / pageSize));
-  const safePageIndex = Math.min(pageIndex, totalPages - 1);
-  const pagedSpools = filteredSpools.slice(safePageIndex * pageSize, (safePageIndex + 1) * pageSize);
-
   // Reset page on filter changes
   // Reset page on filter changes
   const resetPage = () => setPageIndex(0);
   const resetPage = () => setPageIndex(0);
 
 
@@ -416,6 +472,50 @@ export default function InventoryPage() {
     [columnConfig]
     [columnConfig]
   );
   );
 
 
+  const handleSort = (colId: string) => {
+    if (!columnSortValues[colId]) return; // Not sortable
+    setSortState((prev) => {
+      let next: SortState;
+      if (prev?.column === colId) {
+        // Toggle direction, or clear on third click
+        next = prev.direction === 'asc' ? { column: colId, direction: 'desc' } : null;
+      } else {
+        next = { column: colId, direction: 'asc' };
+      }
+      saveSortState(next);
+      return next;
+    });
+    resetPage();
+  };
+
+  // Sort filtered spools
+  const sortedSpools = useMemo(() => {
+    if (!sortState) return filteredSpools;
+    const extractor = columnSortValues[sortState.column];
+    if (!extractor) return filteredSpools;
+    const sorted = [...filteredSpools].sort((a, b) => {
+      const va = extractor(a, assignmentMap);
+      const vb = extractor(b, assignmentMap);
+      if (va < vb) return sortState.direction === 'asc' ? -1 : 1;
+      if (va > vb) return sortState.direction === 'asc' ? 1 : -1;
+      return 0;
+    });
+    return sorted;
+  }, [filteredSpools, sortState, assignmentMap]);
+
+  // Pagination (after sorting) — pageSize -1 means "All"
+  const showAll = pageSize === -1;
+  const effectivePageSize = showAll ? sortedSpools.length || 1 : pageSize;
+  const totalPages = Math.max(1, Math.ceil(sortedSpools.length / effectivePageSize));
+  const safePageIndex = showAll ? 0 : Math.min(pageIndex, totalPages - 1);
+  const pagedSpools = showAll ? sortedSpools : sortedSpools.slice(safePageIndex * effectivePageSize, (safePageIndex + 1) * effectivePageSize);
+
+  const handlePageSizeChange = (size: number) => {
+    setPageSize(size);
+    setPageIndex(0);
+    try { localStorage.setItem('bambuddy-inventory-pageSize', String(size)); } catch { /* ignore */ }
+  };
+
   const clearAllFilters = () => {
   const clearAllFilters = () => {
     setArchiveFilter('active');
     setArchiveFilter('active');
     setUsageFilter('all');
     setUsageFilter('all');
@@ -527,15 +627,17 @@ export default function InventoryPage() {
         </div>
         </div>
 
 
         <div className="flex items-center gap-2">
         <div className="flex items-center gap-2">
-          {/* Columns button */}
-          <button
-            onClick={() => setShowColumnModal(true)}
-            className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-bambu-gray border border-bambu-dark-tertiary rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
-            title={t('inventory.configureColumns')}
-          >
-            <Columns className="w-4 h-4" />
-            <span className="hidden sm:inline">{t('inventory.columns')}</span>
-          </button>
+          {/* Columns button (table view only) */}
+          {viewMode === 'table' && (
+            <button
+              onClick={() => setShowColumnModal(true)}
+              className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-bambu-gray border border-bambu-dark-tertiary rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
+              title={t('inventory.configureColumns')}
+            >
+              <Columns className="w-4 h-4" />
+              <span className="hidden sm:inline">{t('inventory.columns')}</span>
+            </button>
+          )}
           {/* Table / Cards toggle */}
           {/* Table / Cards toggle */}
           <div className="flex bg-bambu-dark-primary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
           <div className="flex bg-bambu-dark-primary border border-bambu-dark-tertiary rounded-lg overflow-hidden">
             <button
             <button
@@ -679,7 +781,7 @@ export default function InventoryPage() {
 
 
         {/* Results count */}
         {/* Results count */}
         <span className="ml-auto text-xs text-bambu-gray">
         <span className="ml-auto text-xs text-bambu-gray">
-          {filteredSpools.length} {filteredSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}
+          {sortedSpools.length} {sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}
         </span>
         </span>
       </div>
       </div>
 
 
@@ -760,10 +862,10 @@ export default function InventoryPage() {
             <PaginationBar
             <PaginationBar
               pageIndex={safePageIndex}
               pageIndex={safePageIndex}
               pageSize={pageSize}
               pageSize={pageSize}
-              totalRows={filteredSpools.length}
+              totalRows={sortedSpools.length}
               totalPages={totalPages}
               totalPages={totalPages}
               onPageChange={setPageIndex}
               onPageChange={setPageIndex}
-              onPageSizeChange={(size) => { setPageSize(size); resetPage(); }}
+              onPageSizeChange={handlePageSizeChange}
               t={t}
               t={t}
             />
             />
           </>
           </>
@@ -782,14 +884,30 @@ export default function InventoryPage() {
               <table className="w-full">
               <table className="w-full">
                 <thead>
                 <thead>
                   <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
                   <tr className="border-b border-bambu-dark-tertiary bg-bambu-dark-tertiary/30">
-                    {visibleColumns.map((colId) => (
-                      <th
-                        key={colId}
-                        className={`text-left py-3 px-4 text-xs font-medium text-bambu-gray uppercase tracking-wide ${colId === 'remaining' ? 'min-w-[150px]' : ''}`}
-                      >
-                        {columnHeaders[colId]?.(t) ?? colId}
-                      </th>
-                    ))}
+                    {visibleColumns.map((colId) => {
+                      const sortable = !!columnSortValues[colId];
+                      const isActive = sortState?.column === colId;
+                      return (
+                        <th
+                          key={colId}
+                          className={`text-left py-3 px-4 text-xs font-medium uppercase tracking-wide select-none ${colId === 'remaining' ? 'min-w-[150px]' : ''} ${
+                            sortable ? 'cursor-pointer hover:text-bambu-green transition-colors' : ''
+                          } ${isActive ? 'text-bambu-green' : 'text-bambu-gray'}`}
+                          onClick={sortable ? () => handleSort(colId) : undefined}
+                        >
+                          <span className="inline-flex items-center gap-1">
+                            {columnHeaders[colId]?.(t) ?? colId}
+                            {sortable && (
+                              isActive
+                                ? sortState.direction === 'asc'
+                                  ? <ArrowUp className="w-3 h-3" />
+                                  : <ArrowDown className="w-3 h-3" />
+                                : <ArrowUpDown className="w-3 h-3 opacity-30" />
+                            )}
+                          </span>
+                        </th>
+                      );
+                    })}
                     <th className="text-right py-3 px-4 text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('common.actions')}</th>
                     <th className="text-right py-3 px-4 text-xs font-medium text-bambu-gray uppercase tracking-wide">{t('common.actions')}</th>
                   </tr>
                   </tr>
                 </thead>
                 </thead>
@@ -859,56 +977,64 @@ export default function InventoryPage() {
             {/* Pagination inside card footer */}
             {/* Pagination inside card footer */}
             <div className="flex items-center justify-between px-4 py-3 bg-bambu-dark-tertiary/50 border-t border-bambu-dark-tertiary text-sm">
             <div className="flex items-center justify-between px-4 py-3 bg-bambu-dark-tertiary/50 border-t border-bambu-dark-tertiary text-sm">
               <span className="text-bambu-gray">
               <span className="text-bambu-gray">
-                {t('inventory.showing')} {safePageIndex * pageSize + 1} {t('inventory.to')}{' '}
-                {Math.min((safePageIndex + 1) * pageSize, filteredSpools.length)}{' '}
-                {t('inventory.of')} {filteredSpools.length} {t('inventory.spools')}
+                {showAll
+                  ? `${sortedSpools.length} ${sortedSpools.length !== 1 ? t('inventory.spools') : t('inventory.spool')}`
+                  : <>{t('inventory.showing')} {safePageIndex * effectivePageSize + 1} {t('inventory.to')}{' '}
+                    {Math.min((safePageIndex + 1) * effectivePageSize, sortedSpools.length)}{' '}
+                    {t('inventory.of')} {sortedSpools.length} {t('inventory.spools')}</>
+                }
               </span>
               </span>
 
 
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
                 <span className="text-bambu-gray">{t('inventory.show')}</span>
                 <span className="text-bambu-gray">{t('inventory.show')}</span>
                 <select
                 <select
                   value={pageSize}
                   value={pageSize}
-                  onChange={(e) => { setPageSize(Number(e.target.value)); resetPage(); }}
+                  onChange={(e) => handlePageSizeChange(Number(e.target.value))}
                   className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
                   className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
                 >
                 >
                   {[15, 30, 50, 100].map((n) => (
                   {[15, 30, 50, 100].map((n) => (
                     <option key={n} value={n}>{n}</option>
                     <option key={n} value={n}>{n}</option>
                   ))}
                   ))}
+                  <option value={-1}>{t('inventory.all')}</option>
                 </select>
                 </select>
 
 
-                <button
-                  onClick={() => setPageIndex(0)}
-                  disabled={safePageIndex === 0}
-                  className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-                  title="First page"
-                >
-                  <ChevronsLeft className="w-4 h-4" />
-                </button>
-                <button
-                  onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
-                  disabled={safePageIndex === 0}
-                  className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-                >
-                  <ChevronLeft className="w-4 h-4" />
-                </button>
-                <span className="text-bambu-gray px-2 whitespace-nowrap">
-                  {t('inventory.page')} {safePageIndex + 1} {t('inventory.of')} {totalPages}
-                </span>
-                <button
-                  onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}
-                  disabled={safePageIndex >= totalPages - 1}
-                  className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-                >
-                  <ChevronRight className="w-4 h-4" />
-                </button>
-                <button
-                  onClick={() => setPageIndex(totalPages - 1)}
-                  disabled={safePageIndex >= totalPages - 1}
-                  className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-                  title="Last page"
-                >
-                  <ChevronsRight className="w-4 h-4" />
-                </button>
+                {!showAll && (
+                  <>
+                    <button
+                      onClick={() => setPageIndex(0)}
+                      disabled={safePageIndex === 0}
+                      className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                      title="First page"
+                    >
+                      <ChevronsLeft className="w-4 h-4" />
+                    </button>
+                    <button
+                      onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
+                      disabled={safePageIndex === 0}
+                      className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                    >
+                      <ChevronLeft className="w-4 h-4" />
+                    </button>
+                    <span className="text-bambu-gray px-2 whitespace-nowrap">
+                      {t('inventory.page')} {safePageIndex + 1} {t('inventory.of')} {totalPages}
+                    </span>
+                    <button
+                      onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}
+                      disabled={safePageIndex >= totalPages - 1}
+                      className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                    >
+                      <ChevronRight className="w-4 h-4" />
+                    </button>
+                    <button
+                      onClick={() => setPageIndex(totalPages - 1)}
+                      disabled={safePageIndex >= totalPages - 1}
+                      className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+                      title="Last page"
+                    >
+                      <ChevronsRight className="w-4 h-4" />
+                    </button>
+                  </>
+                )}
               </div>
               </div>
             </div>
             </div>
           </div>
           </div>
@@ -954,13 +1080,18 @@ function PaginationBar({
   onPageSizeChange: (size: number) => void;
   onPageSizeChange: (size: number) => void;
   t: (key: string) => string;
   t: (key: string) => string;
 }) {
 }) {
-  if (totalPages <= 1) return null;
+  const isShowAll = pageSize === -1;
+  if (totalPages <= 1 && !isShowAll) return null;
+  const effectiveSize = isShowAll ? totalRows || 1 : pageSize;
   return (
   return (
     <div className="flex items-center justify-between pt-2 text-sm">
     <div className="flex items-center justify-between pt-2 text-sm">
       <span className="text-bambu-gray">
       <span className="text-bambu-gray">
-        {t('inventory.showing')} {pageIndex * pageSize + 1} {t('inventory.to')}{' '}
-        {Math.min((pageIndex + 1) * pageSize, totalRows)}{' '}
-        {t('inventory.of')} {totalRows} {t('inventory.spools')}
+        {isShowAll
+          ? `${totalRows} ${totalRows !== 1 ? t('inventory.spools') : t('inventory.spool')}`
+          : <>{t('inventory.showing')} {pageIndex * effectiveSize + 1} {t('inventory.to')}{' '}
+              {Math.min((pageIndex + 1) * effectiveSize, totalRows)}{' '}
+              {t('inventory.of')} {totalRows} {t('inventory.spools')}</>
+        }
       </span>
       </span>
       <div className="flex items-center gap-2">
       <div className="flex items-center gap-2">
         <span className="text-bambu-gray">{t('inventory.show')}</span>
         <span className="text-bambu-gray">{t('inventory.show')}</span>
@@ -972,38 +1103,43 @@ function PaginationBar({
           {[15, 30, 50, 100].map((n) => (
           {[15, 30, 50, 100].map((n) => (
             <option key={n} value={n}>{n}</option>
             <option key={n} value={n}>{n}</option>
           ))}
           ))}
+          <option value={-1}>{t('inventory.all')}</option>
         </select>
         </select>
-        <button
-          onClick={() => onPageChange(0)}
-          disabled={pageIndex === 0}
-          className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-        >
-          <ChevronsLeft className="w-4 h-4" />
-        </button>
-        <button
-          onClick={() => onPageChange(Math.max(0, pageIndex - 1))}
-          disabled={pageIndex === 0}
-          className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-        >
-          <ChevronLeft className="w-4 h-4" />
-        </button>
-        <span className="text-bambu-gray px-2 whitespace-nowrap">
-          {t('inventory.page')} {pageIndex + 1} {t('inventory.of')} {totalPages}
-        </span>
-        <button
-          onClick={() => onPageChange(Math.min(totalPages - 1, pageIndex + 1))}
-          disabled={pageIndex >= totalPages - 1}
-          className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-        >
-          <ChevronRight className="w-4 h-4" />
-        </button>
-        <button
-          onClick={() => onPageChange(totalPages - 1)}
-          disabled={pageIndex >= totalPages - 1}
-          className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-        >
-          <ChevronsRight className="w-4 h-4" />
-        </button>
+        {!isShowAll && (
+          <>
+            <button
+              onClick={() => onPageChange(0)}
+              disabled={pageIndex === 0}
+              className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+            >
+              <ChevronsLeft className="w-4 h-4" />
+            </button>
+            <button
+              onClick={() => onPageChange(Math.max(0, pageIndex - 1))}
+              disabled={pageIndex === 0}
+              className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+            >
+              <ChevronLeft className="w-4 h-4" />
+            </button>
+            <span className="text-bambu-gray px-2 whitespace-nowrap">
+              {t('inventory.page')} {pageIndex + 1} {t('inventory.of')} {totalPages}
+            </span>
+            <button
+              onClick={() => onPageChange(Math.min(totalPages - 1, pageIndex + 1))}
+              disabled={pageIndex >= totalPages - 1}
+              className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+            >
+              <ChevronRight className="w-4 h-4" />
+            </button>
+            <button
+              onClick={() => onPageChange(totalPages - 1)}
+              disabled={pageIndex >= totalPages - 1}
+              className="p-1.5 rounded text-bambu-gray hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
+            >
+              <ChevronsRight className="w-4 h-4" />
+            </button>
+          </>
+        )}
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 11 - 11
frontend/src/pages/PrintersPage.tsx

@@ -2834,7 +2834,7 @@ function PrinterCard({
                                               brand: assignment.spool.brand,
                                               brand: assignment.spool.brand,
                                               color_name: assignment.spool.color_name,
                                               color_name: assignment.spool.color_name,
                                             } : null,
                                             } : null,
-                                            onAssignSpool: () => setAssignSpoolModal({
+                                            onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
                                               printerId: printer.id,
                                               printerId: printer.id,
                                               amsId: ams.id,
                                               amsId: ams.id,
                                               trayId: slotIdx,
                                               trayId: slotIdx,
@@ -2843,8 +2843,8 @@ function PrinterCard({
                                                 color: filamentData.colorHex || '',
                                                 color: filamentData.colorHex || '',
                                                 location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
                                                 location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
                                               },
                                               },
-                                            }),
-                                            onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined,
+                                            }) : undefined,
+                                            onUnassignSpool: assignment && filamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, ams.id, slotIdx) : undefined,
                                           };
                                           };
                                         })()}
                                         })()}
                                         configureSlot={{
                                         configureSlot={{
@@ -3068,7 +3068,7 @@ function PrinterCard({
                                           brand: assignment.spool.brand,
                                           brand: assignment.spool.brand,
                                           color_name: assignment.spool.color_name,
                                           color_name: assignment.spool.color_name,
                                         } : null,
                                         } : null,
-                                        onAssignSpool: () => setAssignSpoolModal({
+                                        onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
                                           printerId: printer.id,
                                           printerId: printer.id,
                                           amsId: ams.id,
                                           amsId: ams.id,
                                           trayId: htSlotId,
                                           trayId: htSlotId,
@@ -3077,8 +3077,8 @@ function PrinterCard({
                                             color: filamentData.colorHex || '',
                                             color: filamentData.colorHex || '',
                                             location: getAmsLabel(ams.id, ams.tray.length),
                                             location: getAmsLabel(ams.id, ams.tray.length),
                                           },
                                           },
-                                        }),
-                                        onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, ams.id, htSlotId) : undefined,
+                                        }) : undefined,
+                                        onUnassignSpool: assignment && filamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, ams.id, htSlotId) : undefined,
                                       };
                                       };
                                     })()}
                                     })()}
                                     configureSlot={{
                                     configureSlot={{
@@ -3247,7 +3247,7 @@ function PrinterCard({
                                     brand: assignment.spool.brand,
                                     brand: assignment.spool.brand,
                                     color_name: assignment.spool.color_name,
                                     color_name: assignment.spool.color_name,
                                   } : null,
                                   } : null,
-                                  onAssignSpool: () => setAssignSpoolModal({
+                                  onAssignSpool: extFilamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
                                     printerId: printer.id,
                                     printerId: printer.id,
                                     amsId: 255,
                                     amsId: 255,
                                     trayId: 0,
                                     trayId: 0,
@@ -3256,8 +3256,8 @@ function PrinterCard({
                                       color: extFilamentData.colorHex || '',
                                       color: extFilamentData.colorHex || '',
                                       location: 'External Spool',
                                       location: 'External Spool',
                                     },
                                     },
-                                  }),
-                                  onUnassignSpool: assignment ? () => onUnassignSpool?.(printer.id, 255, 0) : undefined,
+                                  }) : undefined,
+                                  onUnassignSpool: assignment && extFilamentData.vendor !== 'Bambu Lab' ? () => onUnassignSpool?.(printer.id, 255, 0) : undefined,
                                 };
                                 };
                               })()}
                               })()}
                               configureSlot={{
                               configureSlot={{
@@ -4948,9 +4948,9 @@ export function PrintersPage() {
   });
   });
 
 
   // Helper to find assignment for a specific slot
   // Helper to find assignment for a specific slot
-  const getAssignment = (printerId: number, amsId: number, trayId: number): SpoolAssignment | undefined => {
+  const getAssignment = (printerId: number, amsId: number | string, trayId: number | string): SpoolAssignment | undefined => {
     return spoolAssignments?.find(
     return spoolAssignments?.find(
-      (a) => a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId
+      (a) => a.printer_id === printerId && a.ams_id === Number(amsId) && a.tray_id === Number(trayId)
     );
     );
   };
   };
 
 

+ 3 - 1
requirements.txt

@@ -17,7 +17,7 @@ aioftp>=0.22.0
 
 
 # Virtual Printer (emulates Bambu printer for slicer uploads)
 # Virtual Printer (emulates Bambu printer for slicer uploads)
 pyftpdlib>=2.0.0
 pyftpdlib>=2.0.0
-cryptography>=41.0.0
+cryptography>=46.0.5
 
 
 # 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
 # 3MF Processing (standard zipfile is sufficient for Bambu 3MF files)
 defusedxml>=0.7.0  # Safe XML parsing (prevents XXE attacks)
 defusedxml>=0.7.0  # Safe XML parsing (prevents XXE attacks)
@@ -56,3 +56,5 @@ pytest>=8.0.0
 pytest-asyncio>=0.23.0
 pytest-asyncio>=0.23.0
 httpx>=0.26.0
 httpx>=0.26.0
 ruff>=0.2.0
 ruff>=0.2.0
+
+pillow>=12.1.1

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


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CR7xbPFt.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-C-jcYP-o.js"></script>
+    <script type="module" crossorigin src="/assets/index-CR7xbPFt.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