Browse Source

- Spoolman: Add link spool feature, UUID display, and sync feedback
- Link to Spoolman: Manually link existing Spoolman spools to AMS trays
- Hover over AMS slot to see "Link to Spoolman" button
- Select from list of unlinked Spoolman spools
- Automatically sets extra.tag field in Spoolman
- Spool UUID display: View and copy Bambu Lab spool UUID in AMS hover card
- Shows first 8 chars with copy button (full UUID to clipboard)
- Fallback copy method for HTTP contexts
- Sync feedback: Shows synced count and skipped spools with reasons
- Expandable list when more than 5 skipped
- Color swatches for identification
- Fixed Spoolman 400 Bad Request: extra field values must be valid JSON
- Updated documentation (wiki, website)

maziggy 4 months ago
parent
commit
5b8d24f2ce

+ 21 - 0
CHANGELOG.md

@@ -5,6 +5,20 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6b5] - 2026-01-01
 
 ### Added
+- **Spoolman Link Spool feature** - Manually link existing Spoolman spools to AMS trays:
+  - Hover over any AMS slot to see "Link to Spoolman" button (when Spoolman enabled)
+  - Select from list of unlinked Spoolman spools
+  - Automatically sets the `extra.tag` field in Spoolman for proper sync
+  - Useful for connecting existing Spoolman inventory to physical spools
+- **Spool UUID display** - View and copy Bambu Lab spool UUID in AMS hover card:
+  - Shows first 8 chars of UUID with copy button
+  - Full UUID copied to clipboard (with fallback for HTTP contexts)
+  - Only visible when Spoolman integration is enabled
+- **Spoolman sync feedback** - Improved sync result display:
+  - Shows count of successfully synced spools
+  - Lists skipped spools with reasons (e.g., "Non-Bambu Lab spool")
+  - Expandable list when more than 5 spools skipped
+  - Color swatches for easy identification
 - **Printer control buttons** - Stop and Pause/Resume buttons on printer cards when printing:
   - Stop button cancels the current print job
   - Pause/Resume toggle for pausing and resuming prints
@@ -32,6 +46,13 @@ All notable changes to Bambuddy will be documented in this file.
 ### Changed
 - **Temperature cards layout** - Refactored printer card layout with slimmer temperature displays to make room for control buttons
 - **Cover image availability** - Print cover image now shown in PAUSE/PAUSED states (not just RUNNING) for skip objects modal
+- **Spoolman info banner** - Updated settings UI with clearer sync documentation
+
+### Fixed
+- **Spoolman spool creation** - Fixed 400 Bad Request error when creating new spools:
+  - Spoolman `extra` field values must be valid JSON
+  - Now properly JSON-encodes the `tag` value
+  - Affects both auto-sync and manual link operations
 
 ### Tests
 - Added integration tests for printer control endpoints (stop, pause, resume)

+ 152 - 12
backend/app/api/routes/spoolman.py

@@ -1,21 +1,21 @@
 """Spoolman integration API routes."""
 
 import logging
+
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
 from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.database import get_db
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
+from backend.app.services.printer_manager import printer_manager
 from backend.app.services.spoolman import (
-    SpoolmanClient,
+    close_spoolman_client,
     get_spoolman_client,
     init_spoolman_client,
-    close_spoolman_client,
 )
-from backend.app.services.printer_manager import printer_manager
 
 logger = logging.getLogger(__name__)
 
@@ -30,11 +30,22 @@ class SpoolmanStatus(BaseModel):
     url: str | None
 
 
+class SkippedSpool(BaseModel):
+    """Information about a skipped spool during sync."""
+
+    location: str  # e.g., "AMS A1" or "External Spool"
+    reason: str  # e.g., "Not a Bambu Lab spool", "Empty tray"
+    filament_type: str | None = None  # e.g., "PLA", "PETG"
+    color: str | None = None  # Hex color
+
+
 class SyncResult(BaseModel):
     """Result of a Spoolman sync operation."""
 
     success: bool
     synced_count: int
+    skipped_count: int = 0
+    skipped: list[SkippedSpool] = []
     errors: list[str]
 
 
@@ -156,6 +167,7 @@ async def sync_printer_ams(
 
     # Sync each AMS tray to Spoolman
     synced = 0
+    skipped: list[SkippedSpool] = []
     errors = []
 
     # Handle different AMS data structures
@@ -193,19 +205,28 @@ async def sync_printer_ams(
 
             tray = client.parse_ams_tray(ams_id, tray_data)
             if not tray:
-                continue  # Empty tray
+                continue  # Empty tray - nothing to sync
+
+            # Build location string for reporting
+            location = client.convert_ams_slot_to_location(ams_id, tray.tray_id)
 
-            # Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
+            # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
             if not client.is_bambu_lab_spool(tray.tray_uuid):
+                skipped.append(
+                    SkippedSpool(
+                        location=location,
+                        reason="Non-Bambu Lab spool (no RFID tag)",
+                        filament_type=tray.tray_type if tray.tray_type else None,
+                        color=tray.tray_color[:6] if tray.tray_color else None,
+                    )
+                )
                 continue
 
             try:
                 sync_result = await client.sync_ams_tray(tray, printer.name)
                 if sync_result:
                     synced += 1
-                    logger.info(
-                        f"Synced {tray.tray_sub_brands} from {printer.name} AMS {ams_id} tray {tray.tray_id}"
-                    )
+                    logger.info(f"Synced {tray.tray_sub_brands} from {printer.name} AMS {ams_id} tray {tray.tray_id}")
                 else:
                     # Bambu Lab spool that wasn't synced (not found in Spoolman)
                     errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
@@ -217,6 +238,8 @@ async def sync_printer_ams(
     return SyncResult(
         success=len(errors) == 0,
         synced_count=synced,
+        skipped_count=len(skipped),
+        skipped=skipped,
         errors=errors,
     )
 
@@ -240,10 +263,11 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
     # Get all active printers
-    result = await db.execute(select(Printer).where(Printer.is_active == True))
+    result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
     printers = result.scalars().all()
 
     total_synced = 0
+    all_skipped: list[SkippedSpool] = []
     all_errors = []
 
     for printer in printers:
@@ -291,8 +315,19 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
                 if not tray:
                     continue
 
-                # Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
+                # Build location string for reporting
+                location = f"{printer.name} - {client.convert_ams_slot_to_location(ams_id, tray.tray_id)}"
+
+                # Skip non-Bambu Lab spools (SpoolEase/third-party) - track as skipped
                 if not client.is_bambu_lab_spool(tray.tray_uuid):
+                    all_skipped.append(
+                        SkippedSpool(
+                            location=location,
+                            reason="Non-Bambu Lab spool (no RFID tag)",
+                            filament_type=tray.tray_type if tray.tray_type else None,
+                            color=tray.tray_color[:6] if tray.tray_color else None,
+                        )
+                    )
                     continue
 
                 try:
@@ -305,6 +340,8 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
     return SyncResult(
         success=len(all_errors) == 0,
         synced_count=total_synced,
+        skipped_count=len(all_skipped),
+        skipped=all_skipped,
         errors=all_errors,
     )
 
@@ -349,3 +386,106 @@ async def get_filaments(db: AsyncSession = Depends(get_db)):
 
     filaments = await client.get_filaments()
     return {"filaments": filaments}
+
+
+class UnlinkedSpool(BaseModel):
+    """A Spoolman spool that is not linked to any AMS tray."""
+
+    id: int
+    filament_name: str | None
+    filament_material: str | None
+    filament_color_hex: str | None
+    remaining_weight: float | None
+    location: str | None
+
+
+@router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
+async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
+    """Get all Spoolman spools that don't have a tag (not linked to AMS)."""
+    enabled, url, _ = await get_spoolman_settings(db)
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if not client:
+        if url:
+            client = await init_spoolman_client(url)
+        else:
+            raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    if not await client.health_check():
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+
+    spools = await client.get_spools()
+    unlinked = []
+
+    for spool in spools:
+        # Check if spool has a tag in extra field
+        extra = spool.get("extra", {}) or {}
+        tag = extra.get("tag", "")
+        if not tag:
+            filament = spool.get("filament", {}) or {}
+            unlinked.append(
+                UnlinkedSpool(
+                    id=spool["id"],
+                    filament_name=filament.get("name"),
+                    filament_material=filament.get("material"),
+                    filament_color_hex=filament.get("color_hex"),
+                    remaining_weight=spool.get("remaining_weight"),
+                    location=spool.get("location"),
+                )
+            )
+
+    return unlinked
+
+
+class LinkSpoolRequest(BaseModel):
+    """Request to link a Spoolman spool to an AMS tray."""
+
+    tray_uuid: str
+
+
+@router.post("/spools/{spool_id}/link")
+async def link_spool(
+    spool_id: int,
+    request: LinkSpoolRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    """Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
+    enabled, url, _ = await get_spoolman_settings(db)
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if not client:
+        if url:
+            client = await init_spoolman_client(url)
+        else:
+            raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    if not await client.health_check():
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+
+    # Validate tray_uuid format (32 hex characters)
+    tray_uuid = request.tray_uuid.strip()
+    if len(tray_uuid) != 32:
+        raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be 32 hex characters)")
+    try:
+        int(tray_uuid, 16)
+    except ValueError:
+        raise HTTPException(status_code=400, detail="Invalid tray_uuid format (must be hex)")
+
+    # Update spool with tag
+    # Note: Spoolman extra field values must be valid JSON, so we encode the string
+    import json
+
+    result = await client.update_spool(
+        spool_id=spool_id,
+        extra={"tag": json.dumps(tray_uuid)},
+    )
+
+    if result:
+        logger.info(f"Linked Spoolman spool {spool_id} to tray_uuid {tray_uuid}")
+        return {"success": True, "message": f"Spool {spool_id} linked to AMS tray"}
+    else:
+        raise HTTPException(status_code=500, detail="Failed to update spool")

+ 19 - 35
backend/app/services/spoolman.py

@@ -2,7 +2,7 @@
 
 import logging
 from dataclasses import dataclass
-from datetime import datetime, timezone
+from datetime import UTC, datetime
 
 import httpx
 
@@ -170,9 +170,7 @@ class SpoolmanClient:
         """
         try:
             client = await self._get_client()
-            response = await client.post(
-                f"{self.api_url}/vendor", json={"name": name}
-            )
+            response = await client.post(f"{self.api_url}/vendor", json={"name": name})
             response.raise_for_status()
             return response.json()
         except Exception as e:
@@ -314,7 +312,9 @@ class SpoolmanClient:
             client = await self._get_client()
             response = await client.post(f"{self.api_url}/spool", json=data)
             response.raise_for_status()
-            return response.json()
+            result = response.json()
+            logger.info(f"Created spool {result.get('id')} in Spoolman")
+            return result
         except httpx.HTTPStatusError as e:
             logger.error(f"Failed to create spool in Spoolman: {e}, response: {e.response.text}")
             return None
@@ -350,12 +350,10 @@ class SpoolmanClient:
                 data["extra"] = extra
 
             # Always update last_used
-            data["last_used"] = datetime.now(timezone.utc).isoformat()
+            data["last_used"] = datetime.now(UTC).isoformat()
 
             client = await self._get_client()
-            response = await client.patch(
-                f"{self.api_url}/spool/{spool_id}", json=data
-            )
+            response = await client.patch(f"{self.api_url}/spool/{spool_id}", json=data)
             response.raise_for_status()
             return response.json()
         except Exception as e:
@@ -519,9 +517,7 @@ class SpoolmanClient:
         except ValueError:
             return False
 
-    def calculate_remaining_weight(
-        self, remain_percent: int, spool_weight: int
-    ) -> float:
+    def calculate_remaining_weight(self, remain_percent: int, spool_weight: int) -> float:
         """Calculate remaining weight from percentage.
 
         Args:
@@ -533,9 +529,7 @@ class SpoolmanClient:
         """
         return (remain_percent / 100.0) * spool_weight
 
-    async def sync_ams_tray(
-        self, tray: AMSTray, printer_name: str
-    ) -> dict | None:
+    async def sync_ams_tray(self, tray: AMSTray, printer_name: str) -> dict | None:
         """Sync a single AMS tray to Spoolman.
 
         Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters).
@@ -564,9 +558,7 @@ class SpoolmanClient:
                     f"(tray_uuid={tray.tray_uuid}, tag_uid={tray.tag_uid})"
                 )
             else:
-                logger.debug(
-                    f"Skipping tray without RFID tag: AMS {tray.ams_id} tray {tray.tray_id}"
-                )
+                logger.debug(f"Skipping tray without RFID tag: AMS {tray.ams_id} tray {tray.tray_id}")
             return None
 
         # Calculate remaining weight
@@ -577,9 +569,7 @@ class SpoolmanClient:
         existing = await self.find_spool_by_tag(tray.tray_uuid)
         if existing:
             # Update existing spool
-            logger.info(
-                f"Updating existing spool {existing['id']} for tray_uuid {tray.tray_uuid}"
-            )
+            logger.info(f"Updating existing spool {existing['id']} for tray_uuid {tray.tray_uuid}")
             return await self.update_spool(
                 spool_id=existing["id"],
                 remaining_weight=remaining,
@@ -588,8 +578,7 @@ class SpoolmanClient:
 
         # Spool not found - auto-create it
         logger.info(
-            f"Creating new spool in Spoolman for {tray.tray_sub_brands} "
-            f"(tray_uuid: {tray.tray_uuid[:16]}...)"
+            f"Creating new spool in Spoolman for {tray.tray_sub_brands} " f"(tray_uuid: {tray.tray_uuid[:16]}...)"
         )
 
         # First find or create the filament type
@@ -599,12 +588,15 @@ class SpoolmanClient:
             return None
 
         # Create the spool with tray_uuid stored as "tag" in extra field
+        # Note: Spoolman extra field values must be valid JSON, so we encode the string
+        import json
+
         return await self.create_spool(
             filament_id=filament["id"],
             remaining_weight=remaining,
             location=location,
             comment=f"Auto-created from {printer_name} AMS",
-            extra={"tag": tray.tray_uuid},
+            extra={"tag": json.dumps(tray.tray_uuid)},
         )
 
     async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
@@ -624,10 +616,7 @@ class SpoolmanClient:
             # Match by material and color (handle None values)
             fil_material = filament.get("material") or ""
             fil_color = filament.get("color_hex") or ""
-            if (
-                fil_material.upper() == tray.tray_type.upper()
-                and fil_color.upper() == color_hex.upper()
-            ):
+            if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
                 return filament
 
         # Search external filaments (Bambu library)
@@ -635,10 +624,7 @@ class SpoolmanClient:
         for filament in external:
             fil_material = filament.get("material") or ""
             fil_color = filament.get("color_hex") or ""
-            if (
-                fil_material.upper() == tray.tray_type.upper()
-                and fil_color.upper() == color_hex.upper()
-            ):
+            if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
                 # Found in external library - need to create internal copy
                 return await self._create_filament_from_external(filament, tray)
 
@@ -652,9 +638,7 @@ class SpoolmanClient:
             weight=tray.tray_weight,
         )
 
-    async def _create_filament_from_external(
-        self, external: dict, tray: AMSTray
-    ) -> dict | None:
+    async def _create_filament_from_external(self, external: dict, tray: AMSTray) -> dict | None:
         """Create internal filament from external library entry.
 
         Args:

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

@@ -1119,12 +1119,30 @@ export interface SpoolmanStatus {
   url: string | null;
 }
 
+export interface SkippedSpool {
+  location: string;
+  reason: string;
+  filament_type: string | null;
+  color: string | null;
+}
+
 export interface SpoolmanSyncResult {
   success: boolean;
   synced_count: number;
+  skipped_count: number;
+  skipped: SkippedSpool[];
   errors: string[];
 }
 
+export interface UnlinkedSpool {
+  id: number;
+  filament_name: string | null;
+  filament_material: string | null;
+  filament_color_hex: string | null;
+  remaining_weight: number | null;
+  location: string | null;
+}
+
 // Update types
 export interface VersionInfo {
   version: string;
@@ -2037,6 +2055,13 @@ export const api = {
     request<{ spools: unknown[] }>('/spoolman/spools'),
   getSpoolmanFilaments: () =>
     request<{ filaments: unknown[] }>('/spoolman/filaments'),
+  getUnlinkedSpools: () =>
+    request<UnlinkedSpool[]>('/spoolman/spools/unlinked'),
+  linkSpool: (spoolId: number, trayUuid: string) =>
+    request<{ success: boolean; message: string }>(`/spoolman/spools/${spoolId}/link`, {
+      method: 'POST',
+      body: JSON.stringify({ tray_uuid: trayUuid }),
+    }),
 
   // Updates
   getVersion: () => request<VersionInfo>('/updates/version'),

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

@@ -1,5 +1,5 @@
 import { useState, useRef, useEffect, type ReactNode } from 'react';
-import { Droplets } from 'lucide-react';
+import { Droplets, Link2, Copy, Check } from 'lucide-react';
 
 interface FilamentData {
   vendor: 'Bambu Lab' | 'Generic';
@@ -8,6 +8,12 @@ interface FilamentData {
   colorHex: string | null;
   kFactor: string;
   fillLevel: number | null; // null = unknown
+  trayUuid?: string | null; // Bambu Lab spool UUID for Spoolman linking
+}
+
+interface SpoolmanConfig {
+  enabled: boolean;
+  onLinkSpool?: (trayUuid: string) => void;
 }
 
 interface FilamentHoverCardProps {
@@ -15,19 +21,56 @@ interface FilamentHoverCardProps {
   children: ReactNode;
   disabled?: boolean;
   className?: string;
+  spoolman?: SpoolmanConfig;
 }
 
 /**
  * A hover card that displays filament details when hovering over AMS slots.
  * Replaces the basic browser tooltip with a styled popover.
  */
-export function FilamentHoverCard({ data, children, disabled, className = '' }: FilamentHoverCardProps) {
+export function FilamentHoverCard({ data, children, disabled, className = '', spoolman }: FilamentHoverCardProps) {
   const [isVisible, setIsVisible] = useState(false);
   const [position, setPosition] = useState<'top' | 'bottom'>('top');
+  const [copied, setCopied] = useState(false);
   const triggerRef = useRef<HTMLDivElement>(null);
   const cardRef = useRef<HTMLDivElement>(null);
   const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
+  const handleCopyUuid = () => {
+    const uuid = data.trayUuid;
+    if (!uuid) return;
+
+    // Try modern clipboard API first, fallback to execCommand
+    if (navigator.clipboard && window.isSecureContext) {
+      navigator.clipboard.writeText(uuid).then(() => {
+        setCopied(true);
+        setTimeout(() => setCopied(false), 2000);
+      }).catch(() => {
+        // Fallback on error
+        fallbackCopy(uuid);
+      });
+    } else {
+      fallbackCopy(uuid);
+    }
+  };
+
+  const fallbackCopy = (text: string) => {
+    const textarea = document.createElement('textarea');
+    textarea.value = text;
+    textarea.style.position = 'fixed';
+    textarea.style.opacity = '0';
+    document.body.appendChild(textarea);
+    textarea.select();
+    try {
+      document.execCommand('copy');
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    } catch {
+      console.error('Failed to copy to clipboard');
+    }
+    document.body.removeChild(textarea);
+  };
+
   // Calculate position when showing
   useEffect(() => {
     if (isVisible && triggerRef.current && cardRef.current) {
@@ -192,6 +235,49 @@ export function FilamentHoverCard({ data, children, disabled, className = '' }:
                   )}
                 </div>
               </div>
+
+              {/* Spoolman section - only show if enabled */}
+              {spoolman?.enabled && data.trayUuid && (
+                <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
+                  {/* Tray UUID with copy button */}
+                  <div className="flex items-center justify-between">
+                    <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
+                      Spool ID
+                    </span>
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        handleCopyUuid();
+                      }}
+                      className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors"
+                      title="Copy spool UUID"
+                    >
+                      <span className="font-mono text-[10px] truncate max-w-[80px]">
+                        {data.trayUuid.slice(0, 8)}...
+                      </span>
+                      {copied ? (
+                        <Check className="w-3 h-3 text-bambu-green" />
+                      ) : (
+                        <Copy className="w-3 h-3" />
+                      )}
+                    </button>
+                  </div>
+
+                  {/* Link Spool button */}
+                  {spoolman.onLinkSpool && (
+                    <button
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        spoolman.onLinkSpool?.(data.trayUuid!);
+                      }}
+                      className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green text-xs font-medium rounded transition-colors"
+                    >
+                      <Link2 className="w-3.5 h-3.5" />
+                      Link to Spoolman
+                    </button>
+                  )}
+                </div>
+              )}
             </div>
           </div>
 

+ 183 - 0
frontend/src/components/LinkSpoolModal.tsx

@@ -0,0 +1,183 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Loader2, Link2, Check } from 'lucide-react';
+import { api } from '../api/client';
+import { Button } from './Button';
+
+interface LinkSpoolModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+  trayUuid: string;
+  trayInfo?: {
+    type: string;
+    color: string;
+    location: string;
+  };
+}
+
+export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) {
+  const queryClient = useQueryClient();
+  const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
+
+  // Fetch unlinked spools
+  const { data: unlinkedSpools, isLoading } = useQuery({
+    queryKey: ['unlinked-spools'],
+    queryFn: api.getUnlinkedSpools,
+    enabled: isOpen,
+  });
+
+  // Link mutation
+  const linkMutation = useMutation({
+    mutationFn: (spoolId: number) => api.linkSpool(spoolId, trayUuid),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
+      queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
+      onClose();
+    },
+  });
+
+  if (!isOpen) return null;
+
+  const handleLink = () => {
+    if (selectedSpoolId) {
+      linkMutation.mutate(selectedSpoolId);
+    }
+  };
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center">
+      {/* Backdrop */}
+      <div
+        className="absolute inset-0 bg-black/60 backdrop-blur-sm"
+        onClick={onClose}
+      />
+
+      {/* Modal */}
+      <div className="relative w-full max-w-md mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl">
+        {/* Header */}
+        <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2">
+            <Link2 className="w-5 h-5 text-bambu-green" />
+            <h2 className="text-lg font-semibold text-white">Link to Spoolman</h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="p-1 text-bambu-gray hover:text-white rounded transition-colors"
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-4 space-y-4">
+          {/* Tray info */}
+          {trayInfo && (
+            <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+              <p className="text-xs text-bambu-gray mb-1">Linking AMS tray:</p>
+              <div className="flex items-center gap-2">
+                {trayInfo.color && (
+                  <span
+                    className="w-4 h-4 rounded-full border border-white/20"
+                    style={{ backgroundColor: `#${trayInfo.color}` }}
+                  />
+                )}
+                <span className="text-white font-medium">{trayInfo.type}</span>
+                <span className="text-bambu-gray">({trayInfo.location})</span>
+              </div>
+            </div>
+          )}
+
+          {/* Spool UUID */}
+          <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+            <p className="text-xs text-bambu-gray mb-1">Spool UUID:</p>
+            <code className="text-xs text-bambu-green font-mono break-all">{trayUuid}</code>
+          </div>
+
+          {/* Spool list */}
+          <div>
+            <p className="text-sm text-bambu-gray mb-2">
+              Select a Spoolman spool to link:
+            </p>
+
+            {isLoading ? (
+              <div className="flex justify-center py-8">
+                <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+              </div>
+            ) : unlinkedSpools && unlinkedSpools.length > 0 ? (
+              <div className="max-h-64 overflow-y-auto space-y-2">
+                {unlinkedSpools.map((spool) => (
+                  <button
+                    key={spool.id}
+                    onClick={() => setSelectedSpoolId(spool.id)}
+                    className={`w-full p-3 rounded-lg border text-left transition-colors ${
+                      selectedSpoolId === spool.id
+                        ? 'bg-bambu-green/20 border-bambu-green'
+                        : 'bg-bambu-dark border-bambu-dark-tertiary hover:border-bambu-gray'
+                    }`}
+                  >
+                    <div className="flex items-center gap-2">
+                      {spool.filament_color_hex && (
+                        <span
+                          className="w-4 h-4 rounded-full border border-white/20 flex-shrink-0"
+                          style={{ backgroundColor: `#${spool.filament_color_hex}` }}
+                        />
+                      )}
+                      <div className="flex-1 min-w-0">
+                        <p className="text-white font-medium truncate">
+                          {spool.filament_name || 'Unknown filament'}
+                        </p>
+                        <p className="text-xs text-bambu-gray">
+                          {spool.filament_material || 'Unknown'}
+                          {spool.remaining_weight !== null && ` - ${Math.round(spool.remaining_weight)}g`}
+                          {spool.location && ` - ${spool.location}`}
+                        </p>
+                      </div>
+                      {selectedSpoolId === spool.id && (
+                        <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
+                      )}
+                    </div>
+                  </button>
+                ))}
+              </div>
+            ) : (
+              <div className="text-center py-8 text-bambu-gray">
+                <p>No unlinked spools found in Spoolman.</p>
+                <p className="text-xs mt-1">All spools are already linked to AMS trays.</p>
+              </div>
+            )}
+          </div>
+        </div>
+
+        {/* Footer */}
+        <div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
+          <Button variant="secondary" onClick={onClose}>
+            Cancel
+          </Button>
+          <Button
+            onClick={handleLink}
+            disabled={!selectedSpoolId || linkMutation.isPending}
+          >
+            {linkMutation.isPending ? (
+              <>
+                <Loader2 className="w-4 h-4 animate-spin" />
+                Linking...
+              </>
+            ) : (
+              <>
+                <Link2 className="w-4 h-4" />
+                Link Spool
+              </>
+            )}
+          </Button>
+        </div>
+
+        {/* Error */}
+        {linkMutation.isError && (
+          <div className="mx-4 mb-4 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
+            {(linkMutation.error as Error).message}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 87 - 11
frontend/src/components/SpoolmanSettings.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown } from 'lucide-react';
+import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown, Info, AlertTriangle } from 'lucide-react';
 import { api } from '../api/client';
 import type { SpoolmanSyncResult, Printer } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
@@ -40,6 +40,7 @@ export function SpoolmanSettings() {
   const [showSaved, setShowSaved] = useState(false);
   const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
   const [isInitialized, setIsInitialized] = useState(false);
+  const [showAllSkipped, setShowAllSkipped] = useState(false);
 
   // Fetch Spoolman settings
   const { data: settings, isLoading: settingsLoading } = useQuery({
@@ -192,6 +193,25 @@ export function SpoolmanSettings() {
           Connect to Spoolman for filament inventory tracking. AMS data will sync automatically.
         </p>
 
+        {/* Info banner about sync requirements */}
+        <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
+          <div className="flex gap-2">
+            <Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
+            <div className="text-xs text-blue-300">
+              <p className="font-medium mb-1">How Sync Works</p>
+              <ul className="list-disc list-inside space-y-0.5 text-blue-300/80">
+                <li>Only official Bambu Lab spools with RFID are synced</li>
+                <li>New spools are auto-created in Spoolman on first sync</li>
+                <li>Non-Bambu Lab spools (third-party, refilled) are skipped</li>
+              </ul>
+              <p className="font-medium mt-2 mb-1">Linking Existing Spools</p>
+              <p className="text-blue-300/80">
+                To link existing Spoolman spools to your AMS, hover over an AMS slot and click "Link to Spoolman".
+              </p>
+            </div>
+          </div>
+        </div>
+
         {/* Enable toggle */}
         <div className="flex items-center justify-between">
           <div>
@@ -355,16 +375,72 @@ export function SpoolmanSettings() {
 
             {/* Sync result */}
             {syncSuccess && syncResult && (
-              <div
-                className={`mt-2 p-2 rounded text-sm ${
-                  syncResult.success
-                    ? 'bg-green-500/20 border border-green-500/50 text-green-400'
-                    : 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'
-                }`}
-              >
-                {syncResult.success
-                  ? `Synced ${syncResult.synced_count} trays successfully`
-                  : `Synced ${syncResult.synced_count} trays with ${syncResult.errors.length} errors`}
+              <div className="mt-3 space-y-2">
+                {/* Main result */}
+                <div
+                  className={`p-2 rounded text-sm ${
+                    syncResult.success
+                      ? 'bg-green-500/20 border border-green-500/50 text-green-400'
+                      : 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'
+                  }`}
+                >
+                  {syncResult.success
+                    ? `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} successfully`
+                    : `Synced ${syncResult.synced_count} spool${syncResult.synced_count !== 1 ? 's' : ''} with ${syncResult.errors.length} error${syncResult.errors.length !== 1 ? 's' : ''}`}
+                </div>
+
+                {/* Skipped spools */}
+                {syncResult.skipped_count > 0 && (
+                  <div className="p-2 bg-amber-500/10 border border-amber-500/30 rounded text-sm">
+                    <div className="flex items-center justify-between text-amber-400 mb-1">
+                      <div className="flex items-center gap-1.5">
+                        <AlertTriangle className="w-3.5 h-3.5" />
+                        <span className="font-medium">
+                          {syncResult.skipped_count} spool{syncResult.skipped_count !== 1 ? 's' : ''} skipped
+                        </span>
+                      </div>
+                      {syncResult.skipped_count > 5 && (
+                        <button
+                          onClick={() => setShowAllSkipped(!showAllSkipped)}
+                          className="text-xs text-amber-400 hover:text-amber-300 underline"
+                        >
+                          {showAllSkipped ? 'Show less' : 'Show all'}
+                        </button>
+                      )}
+                    </div>
+                    <ul className="text-xs text-amber-300/80 space-y-0.5">
+                      {(showAllSkipped ? syncResult.skipped : syncResult.skipped.slice(0, 5)).map((s, i) => (
+                        <li key={i} className="flex items-center gap-2">
+                          {s.color && (
+                            <span
+                              className="w-3 h-3 rounded-full border border-white/20"
+                              style={{ backgroundColor: `#${s.color}` }}
+                            />
+                          )}
+                          <span>{s.location}</span>
+                          <span className="text-amber-300/60">- {s.reason}</span>
+                        </li>
+                      ))}
+                      {!showAllSkipped && syncResult.skipped_count > 5 && (
+                        <li className="text-amber-300/60 italic">
+                          ...and {syncResult.skipped_count - 5} more
+                        </li>
+                      )}
+                    </ul>
+                  </div>
+                )}
+
+                {/* Errors */}
+                {syncResult.errors.length > 0 && (
+                  <div className="p-2 bg-red-500/10 border border-red-500/30 rounded text-sm">
+                    <div className="text-red-400 font-medium mb-1">Errors:</div>
+                    <ul className="text-xs text-red-300/80 space-y-0.5">
+                      {syncResult.errors.map((err, i) => (
+                        <li key={i}>{err}</li>
+                      ))}
+                    </ul>
+                  </div>
+                )}
               </div>
             )}
           </div>

+ 78 - 3
frontend/src/pages/PrintersPage.tsx

@@ -60,6 +60,7 @@ import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal
 import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
 import { AMSHistoryModal } from '../components/AMSHistoryModal';
 import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
+import { LinkSpoolModal } from '../components/LinkSpoolModal';
 import { useToast } from '../contexts/ToastContext';
 
 // Bambu Lab color code mapping (color suffix from tray_id_name -> color name)
@@ -669,6 +670,7 @@ function PrinterCard({
   maintenanceInfo,
   viewMode = 'expanded',
   amsThresholds,
+  spoolmanEnabled = false,
 }: {
   printer: Printer;
   hideIfDisconnected?: boolean;
@@ -680,6 +682,7 @@ function PrinterCard({
     tempGood: number;
     tempFair: number;
   };
+  spoolmanEnabled?: boolean;
 }) {
   const queryClient = useQueryClient();
   const navigate = useNavigate();
@@ -702,6 +705,10 @@ function PrinterCard({
     amsLabel: string;
     mode: 'humidity' | 'temperature';
   } | null>(null);
+  const [linkSpoolModal, setLinkSpoolModal] = useState<{
+    trayUuid: string;
+    trayInfo: { type: string; color: string; location: string };
+  } | null>(null);
 
   const { data: status } = useQuery({
     queryKey: ['printerStatus', printer.id],
@@ -1581,6 +1588,7 @@ function PrinterCard({
                                   colorHex: tray.tray_color || null,
                                   kFactor: formatKValue(tray.k),
                                   fillLevel: hasFillLevel ? tray.remain : null,
+                                  trayUuid: tray.tray_uuid || null,
                                 } : null;
 
                                 // Check if this specific slot is being refreshed
@@ -1665,7 +1673,22 @@ function PrinterCard({
                                     )}
                                     {/* Hover card wraps only the visual content */}
                                     {filamentData ? (
-                                      <FilamentHoverCard data={filamentData}>
+                                      <FilamentHoverCard
+                                        data={filamentData}
+                                        spoolman={{
+                                          enabled: spoolmanEnabled,
+                                          onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
+                                            setLinkSpoolModal({
+                                              trayUuid: uuid,
+                                              trayInfo: {
+                                                type: filamentData.profile,
+                                                color: filamentData.colorHex || '',
+                                                location: `${getAmsLabel(ams.id, ams.tray.length)} Slot ${slotIdx + 1}`,
+                                              },
+                                            });
+                                          } : undefined,
+                                        }}
+                                      >
                                         {slotVisual}
                                       </FilamentHoverCard>
                                     ) : (
@@ -1711,6 +1734,7 @@ function PrinterCard({
                           colorHex: tray.tray_color || null,
                           kFactor: formatKValue(tray.k),
                           fillLevel: hasFillLevel ? tray.remain : null,
+                          trayUuid: tray.tray_uuid || null,
                         } : null;
 
                         const htSlotId = tray?.id ?? 0;
@@ -1808,7 +1832,22 @@ function PrinterCard({
                                 )}
                                 {/* Hover card wraps only the visual content */}
                                 {filamentData ? (
-                                  <FilamentHoverCard data={filamentData}>
+                                  <FilamentHoverCard
+                                    data={filamentData}
+                                    spoolman={{
+                                      enabled: spoolmanEnabled,
+                                      onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
+                                        setLinkSpoolModal({
+                                          trayUuid: uuid,
+                                          trayInfo: {
+                                            type: filamentData.profile,
+                                            color: filamentData.colorHex || '',
+                                            location: getAmsLabel(ams.id, ams.tray.length),
+                                          },
+                                        });
+                                      } : undefined,
+                                    }}
+                                  >
                                     {slotVisual}
                                   </FilamentHoverCard>
                                 ) : (
@@ -1868,6 +1907,7 @@ function PrinterCard({
                           colorHex: extTray.tray_color || null,
                           kFactor: formatKValue(extTray.k),
                           fillLevel: null, // External spool has unknown fill level
+                          trayUuid: extTray.tray_uuid || null,
                         };
 
                         const extSlotContent = (
@@ -1896,7 +1936,22 @@ function PrinterCard({
                               <span className="text-[10px] text-white font-medium">External</span>
                             </div>
                             {/* Row 2: Slot (full width since no stats) */}
-                            <FilamentHoverCard data={extFilamentData}>
+                            <FilamentHoverCard
+                              data={extFilamentData}
+                              spoolman={{
+                                enabled: spoolmanEnabled,
+                                onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
+                                  setLinkSpoolModal({
+                                    trayUuid: uuid,
+                                    trayInfo: {
+                                      type: extFilamentData.profile,
+                                      color: extFilamentData.colorHex || '',
+                                      location: 'External Spool',
+                                    },
+                                  });
+                                } : undefined,
+                              }}
+                            >
                               {extSlotContent}
                             </FilamentHoverCard>
                           </div>
@@ -2358,6 +2413,16 @@ function PrinterCard({
         />
       )}
 
+      {/* Link Spool Modal */}
+      {linkSpoolModal && (
+        <LinkSpoolModal
+          isOpen={!!linkSpoolModal}
+          onClose={() => setLinkSpoolModal(null)}
+          trayUuid={linkSpoolModal.trayUuid}
+          trayInfo={linkSpoolModal.trayInfo}
+        />
+      )}
+
       {/* Edit Printer Modal */}
       {showEditModal && (
         <EditPrinterModal
@@ -3036,6 +3101,14 @@ export function PrintersPage() {
     staleTime: 60 * 1000, // 1 minute
   });
 
+  // Fetch Spoolman status to enable link spool feature
+  const { data: spoolmanStatus } = useQuery({
+    queryKey: ['spoolman-status'],
+    queryFn: api.getSpoolmanStatus,
+    staleTime: 60 * 1000, // 1 minute
+  });
+  const spoolmanEnabled = spoolmanStatus?.enabled && spoolmanStatus?.connected;
+
   // Create a map of printer_id -> maintenance info for quick lookup
   const maintenanceByPrinter = maintenanceOverview?.reduce(
     (acc, overview) => {
@@ -3310,6 +3383,7 @@ export function PrintersPage() {
                       tempGood: Number(settings.ams_temp_good) || 28,
                       tempFair: Number(settings.ams_temp_fair) || 35,
                     } : undefined}
+                    spoolmanEnabled={spoolmanEnabled}
                   />
                 ))}
               </div>
@@ -3330,6 +3404,7 @@ export function PrintersPage() {
               hideIfDisconnected={hideDisconnected}
               maintenanceInfo={maintenanceByPrinter[printer.id]}
               viewMode={viewMode}
+              spoolmanEnabled={spoolmanEnabled}
               amsThresholds={settings ? {
                 humidityGood: Number(settings.ams_humidity_good) || 40,
                 humidityFair: Number(settings.ams_humidity_fair) || 60,

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


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


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


+ 2 - 2
static/index.html

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

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