Jelajahi Sumber

Added Spoolman support to add unknown Bambu Lab spools and track them

Martin Ziegler 5 bulan lalu
induk
melakukan
a862958deb

+ 67 - 1
README.md

@@ -88,6 +88,12 @@ v∆v
   - Configurable event triggers (start, complete, failed, stopped, progress milestones)
   - Quiet hours to suppress notifications during sleep
   - Per-printer filtering
+- **Spoolman Integration** - Sync AMS filament data with your Spoolman server
+  - Automatic or manual sync modes
+  - Per-printer or all-printer sync
+  - Matches Bambu Lab spools by unique tray UUID
+  - Tracks filament usage during prints
+  - Third-party spools (SpoolEase, etc.) gracefully skipped
 - **Cloud Profiles Sync** - Access your Bambu Cloud slicer presets
 - **File Manager** - Browse and manage files on your printer's SD card
 - **Re-print** - Send archived prints back to any connected printer
@@ -598,6 +604,66 @@ By default, notifications are sent for all printers. To limit notifications to a
 2. Select a printer from the **Printer** dropdown
 3. Only events from that printer will trigger notifications
 
+### Spoolman Integration
+
+Bambusy integrates with [Spoolman](https://github.com/Donkie/Spoolman) for filament inventory management. When enabled, AMS filament data syncs with your Spoolman server, allowing you to track remaining filament across all your spools.
+
+#### Prerequisites
+
+- A running Spoolman server (self-hosted or Docker)
+- Bambu Lab spools with original RFID tags in your AMS
+- Spools registered in Spoolman with matching filament types
+
+#### Setting Up Spoolman
+
+1. Go to **Settings** > scroll to **Spoolman Integration**
+2. Enable the **Enable Spoolman** toggle
+3. Enter your Spoolman server URL (e.g., `http://192.168.1.100:7912`)
+4. Click **Save**
+5. Click **Connect** to establish the connection
+
+#### Sync Modes
+
+| Mode | Description |
+|------|-------------|
+| **Automatic** | AMS data syncs automatically when changes are detected (filament loaded/unloaded, usage during prints) |
+| **Manual Only** | Only sync when you click the Sync button |
+
+#### Manual Sync
+
+When connected:
+1. Select a specific printer from the dropdown, or "All Printers"
+2. Click **Sync** to sync AMS data to Spoolman
+3. Results show how many trays were synced
+
+#### How Syncing Works
+
+Bambusy matches AMS spools to Spoolman spools using the **tray UUID** - a unique 32-character identifier that Bambu Lab assigns to each original spool. This ensures consistent matching across different printer models.
+
+**What gets synced:**
+- Remaining filament weight (from AMS sensor)
+- Filament usage during prints (deducted from Spoolman inventory)
+
+**Limitations:**
+- Only **Bambu Lab original spools** can be synced (they have valid tray UUIDs)
+- Third-party spools (SpoolEase, refilled spools, etc.) are gracefully skipped - they won't cause errors
+- Spools must exist in Spoolman before syncing - Bambusy doesn't create new spools automatically
+
+#### Troubleshooting Spoolman
+
+**"Spool not found in Spoolman" errors:**
+- The Bambu Lab spool exists in your AMS but hasn't been added to Spoolman yet
+- Add the spool in Spoolman's web interface, matching the filament type and color
+
+**Third-party spools showing in AMS:**
+- SpoolEase and other third-party spools are automatically skipped during sync
+- This is normal behavior - they don't have Bambu Lab tray UUIDs
+
+**Connection issues:**
+- Verify the Spoolman URL is accessible from your Bambusy server
+- Check that no firewall is blocking port 7912 (or your custom port)
+- Ensure Spoolman is running and healthy
+
 #### Provider Setup Guides
 
 **WhatsApp (CallMeBot):**
@@ -799,9 +865,9 @@ To fix the printer's clock:
 - [x] Automatic finish photo capture
 - [x] K-Profiles management (pressure advance)
 - [x] Push notifications (WhatsApp, ntfy, Pushover, Telegram, Email)
+- [x] Spoolman integration (filament inventory sync)
 - [ ] Maintenance tracker
 - [ ] Full printer control
-- [ ] Spoolman support
 - [ ] Mobile-optimized UI
 - [ ] docs: readme -> wiki
 

+ 34 - 1
backend/app/api/routes/settings.py

@@ -46,7 +46,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
     for setting in db_settings:
         if setting.key in settings_dict:
             # Parse the value based on the expected type
-            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo"]:
+            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled"]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh"]:
                 settings_dict[setting.key] = float(setting.value)
@@ -99,3 +99,36 @@ async def check_ffmpeg():
         "installed": ffmpeg_path is not None,
         "path": ffmpeg_path,
     }
+
+
+@router.get("/spoolman")
+async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
+    """Get Spoolman integration settings."""
+    spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
+    spoolman_url = await get_setting(db, "spoolman_url") or ""
+    spoolman_sync_mode = await get_setting(db, "spoolman_sync_mode") or "auto"
+
+    return {
+        "spoolman_enabled": spoolman_enabled,
+        "spoolman_url": spoolman_url,
+        "spoolman_sync_mode": spoolman_sync_mode,
+    }
+
+
+@router.put("/spoolman")
+async def update_spoolman_settings(
+    settings: dict,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update Spoolman integration settings."""
+    if "spoolman_enabled" in settings:
+        await set_setting(db, "spoolman_enabled", settings["spoolman_enabled"])
+    if "spoolman_url" in settings:
+        await set_setting(db, "spoolman_url", settings["spoolman_url"])
+    if "spoolman_sync_mode" in settings:
+        await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
+
+    await db.commit()
+
+    # Return updated settings
+    return await get_spoolman_settings(db)

+ 351 - 0
backend/app/api/routes/spoolman.py

@@ -0,0 +1,351 @@
+"""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 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.spoolman import (
+    SpoolmanClient,
+    get_spoolman_client,
+    init_spoolman_client,
+    close_spoolman_client,
+)
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/spoolman", tags=["spoolman"])
+
+
+class SpoolmanStatus(BaseModel):
+    """Spoolman connection status."""
+
+    enabled: bool
+    connected: bool
+    url: str | None
+
+
+class SyncResult(BaseModel):
+    """Result of a Spoolman sync operation."""
+
+    success: bool
+    synced_count: int
+    errors: list[str]
+
+
+async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
+    """Get Spoolman settings from database.
+
+    Returns:
+        Tuple of (enabled, url, sync_mode)
+    """
+    enabled = False
+    url = ""
+    sync_mode = "auto"
+
+    result = await db.execute(select(Settings))
+    for setting in result.scalars().all():
+        if setting.key == "spoolman_enabled":
+            enabled = setting.value.lower() == "true"
+        elif setting.key == "spoolman_url":
+            url = setting.value
+        elif setting.key == "spoolman_sync_mode":
+            sync_mode = setting.value
+
+    return enabled, url, sync_mode
+
+
+@router.get("/status", response_model=SpoolmanStatus)
+async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
+    """Get Spoolman integration status."""
+    enabled, url, _ = await get_spoolman_settings(db)
+
+    client = await get_spoolman_client()
+    connected = False
+    if client:
+        connected = await client.health_check()
+
+    return SpoolmanStatus(
+        enabled=enabled,
+        connected=connected,
+        url=url if url else None,
+    )
+
+
+@router.post("/connect")
+async def connect_spoolman(db: AsyncSession = Depends(get_db)):
+    """Connect to Spoolman server using configured URL."""
+    enabled, url, _ = await get_spoolman_settings(db)
+
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    if not url:
+        raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    try:
+        client = await init_spoolman_client(url)
+        connected = await client.health_check()
+
+        if not connected:
+            raise HTTPException(
+                status_code=503,
+                detail=f"Could not connect to Spoolman at {url}",
+            )
+
+        return {"success": True, "message": f"Connected to Spoolman at {url}"}
+    except Exception as e:
+        logger.error(f"Failed to connect to Spoolman: {e}")
+        raise HTTPException(status_code=503, detail=str(e))
+
+
+@router.post("/disconnect")
+async def disconnect_spoolman():
+    """Disconnect from Spoolman server."""
+    await close_spoolman_client()
+    return {"success": True, "message": "Disconnected from Spoolman"}
+
+
+@router.post("/sync/{printer_id}", response_model=SyncResult)
+async def sync_printer_ams(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Sync AMS data from a specific printer to Spoolman."""
+    # Check if Spoolman is enabled and connected
+    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:
+        # Try to connect
+        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")
+
+    # Get printer info
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(status_code=404, detail="Printer not found")
+
+    # Get current printer state with AMS data
+    state = printer_manager.get_status(printer_id)
+    if not state:
+        raise HTTPException(status_code=404, detail="Printer not connected")
+
+    if not state.raw_data:
+        raise HTTPException(status_code=400, detail="No AMS data available")
+
+    ams_data = state.raw_data.get("ams")
+    if not ams_data:
+        raise HTTPException(
+            status_code=400,
+            detail="No AMS data in printer state. Try triggering a slot re-read on the printer.",
+        )
+
+    # Sync each AMS tray to Spoolman
+    synced = 0
+    errors = []
+
+    # Handle different AMS data structures
+    # Traditional AMS: list of {"id": N, "tray": [...]} dicts
+    # H2D/newer printers: dict with different structure
+    ams_units = []
+    if isinstance(ams_data, list):
+        ams_units = ams_data
+    elif isinstance(ams_data, dict):
+        # H2D format: check for "ams" key containing list, or "tray" key directly
+        if "ams" in ams_data and isinstance(ams_data["ams"], list):
+            ams_units = ams_data["ams"]
+        elif "tray" in ams_data:
+            # Single AMS unit format - wrap in list
+            ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
+        else:
+            logger.info(f"AMS dict keys for debugging: {list(ams_data.keys())}")
+
+    if not ams_units:
+        raise HTTPException(
+            status_code=400,
+            detail=f"AMS data format not supported. Keys: {list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}",
+        )
+
+    for ams_unit in ams_units:
+        if not isinstance(ams_unit, dict):
+            continue
+
+        ams_id = int(ams_unit.get("id", 0))
+        trays = ams_unit.get("tray", [])
+
+        for tray_data in trays:
+            if not isinstance(tray_data, dict):
+                continue
+
+            tray = client.parse_ams_tray(ams_id, tray_data)
+            if not tray:
+                continue  # Empty tray
+
+            # Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
+            if not client.is_bambu_lab_spool(tray.tray_uuid):
+                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}"
+                    )
+                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}")
+            except Exception as e:
+                error_msg = f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}"
+                logger.error(error_msg)
+                errors.append(error_msg)
+
+    return SyncResult(
+        success=len(errors) == 0,
+        synced_count=synced,
+        errors=errors,
+    )
+
+
+@router.post("/sync-all", response_model=SyncResult)
+async def sync_all_printers(db: AsyncSession = Depends(get_db)):
+    """Sync AMS data from all connected printers to Spoolman."""
+    # Check if Spoolman is enabled
+    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")
+
+    # Get all active printers
+    result = await db.execute(select(Printer).where(Printer.is_active == True))
+    printers = result.scalars().all()
+
+    total_synced = 0
+    all_errors = []
+
+    for printer in printers:
+        state = printer_manager.get_status(printer.id)
+        if not state or not state.raw_data:
+            continue
+
+        ams_data = state.raw_data.get("ams")
+        if not ams_data:
+            continue
+
+        # Handle different AMS data structures
+        # Traditional AMS: list of {"id": N, "tray": [...]} dicts
+        # H2D/newer printers: dict with different structure
+        ams_units = []
+        if isinstance(ams_data, list):
+            ams_units = ams_data
+        elif isinstance(ams_data, dict):
+            # H2D format: check for "ams" key containing list, or "tray" key directly
+            if "ams" in ams_data and isinstance(ams_data["ams"], list):
+                ams_units = ams_data["ams"]
+            elif "tray" in ams_data:
+                # Single AMS unit format - wrap in list
+                ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
+            else:
+                logger.debug(f"Printer {printer.name} AMS dict keys: {list(ams_data.keys())}")
+
+        if not ams_units:
+            logger.debug(f"Printer {printer.name} has no AMS units to sync (type: {type(ams_data).__name__})")
+            continue
+
+        for ams_unit in ams_units:
+            if not isinstance(ams_unit, dict):
+                logger.debug(f"Skipping non-dict AMS unit: {type(ams_unit)}")
+                continue
+
+            ams_id = int(ams_unit.get("id", 0))
+            trays = ams_unit.get("tray", [])
+
+            for tray_data in trays:
+                if not isinstance(tray_data, dict):
+                    continue
+
+                tray = client.parse_ams_tray(ams_id, tray_data)
+                if not tray:
+                    continue
+
+                # Skip non-Bambu Lab spools (SpoolEase/third-party) - this is not an error
+                if not client.is_bambu_lab_spool(tray.tray_uuid):
+                    continue
+
+                try:
+                    sync_result = await client.sync_ams_tray(tray, printer.name)
+                    if sync_result:
+                        total_synced += 1
+                except Exception as e:
+                    all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
+
+    return SyncResult(
+        success=len(all_errors) == 0,
+        synced_count=total_synced,
+        errors=all_errors,
+    )
+
+
+@router.get("/spools")
+async def get_spools(db: AsyncSession = Depends(get_db)):
+    """Get all spools from Spoolman."""
+    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()
+    return {"spools": spools}
+
+
+@router.get("/filaments")
+async def get_filaments(db: AsyncSession = Depends(get_db)):
+    """Get all filaments from Spoolman."""
+    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")
+
+    filaments = await client.get_filaments()
+    return {"filaments": filaments}

+ 165 - 1
backend/app/main.py

@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
 from backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -69,6 +69,7 @@ from backend.app.services.bambu_ftp import download_file_async
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.services.tasmota import tasmota_service
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client
 
 
 # Track active prints: {(printer_id, filename): archive_id}
@@ -99,6 +100,92 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int):
 _last_status_broadcast: dict[int, str] = {}
 _nozzle_count_updated: set[int] = set()  # Track printers where we've updated nozzle_count
 
+
+async def _report_spoolman_usage(printer_id: int, archive_id: int, logger):
+    """Report filament usage to Spoolman after print completion.
+
+    This finds the spool by RFID tag_uid from current AMS state and reports
+    the filament_used_grams from the archive metadata.
+    """
+    async with async_session() as db:
+        from backend.app.api.routes.settings import get_setting
+        from backend.app.models.archive import PrintArchive
+
+        # Check if Spoolman is enabled
+        spoolman_enabled = await get_setting(db, "spoolman_enabled")
+        if not spoolman_enabled or spoolman_enabled.lower() != "true":
+            return
+
+        # Get Spoolman URL
+        spoolman_url = await get_setting(db, "spoolman_url")
+        if not spoolman_url:
+            return
+
+        # Get or create Spoolman client
+        client = await get_spoolman_client()
+        if not client:
+            client = await init_spoolman_client(spoolman_url)
+
+        # Check if Spoolman is reachable
+        if not await client.health_check():
+            logger.warning(f"Spoolman not reachable for usage reporting")
+            return
+
+        # Get archive to find filament usage
+        result = await db.execute(
+            select(PrintArchive).where(PrintArchive.id == archive_id)
+        )
+        archive = result.scalar_one_or_none()
+        if not archive or not archive.filament_used_grams:
+            logger.debug(f"No filament usage data for archive {archive_id}")
+            return
+
+        filament_used = archive.filament_used_grams
+        logger.info(f"[SPOOLMAN] Archive {archive_id} used {filament_used}g of filament")
+
+        # Get current AMS state from printer to find the active spool
+        state = printer_manager.get_status(printer_id)
+        if not state or not state.raw_data:
+            logger.debug(f"No printer state available for usage reporting")
+            return
+
+        ams_data = state.raw_data.get("ams")
+        if not ams_data:
+            logger.debug(f"No AMS data available for usage reporting")
+            return
+
+        # Find spools with RFID tags in Spoolman and report usage
+        # For now, we report usage to the first spool found with a matching tag
+        # TODO: In future, track which specific trays were used during the print
+        spools_updated = 0
+        for ams_unit in ams_data:
+            ams_id = int(ams_unit.get("id", 0))
+            trays = ams_unit.get("tray", [])
+
+            for tray_data in trays:
+                tag_uid = tray_data.get("tag_uid")
+                if not tag_uid:
+                    continue
+
+                # Find spool in Spoolman by tag
+                spool = await client.find_spool_by_tag(tag_uid)
+                if spool:
+                    # Report usage to Spoolman
+                    result = await client.use_spool(spool["id"], filament_used)
+                    if result:
+                        logger.info(
+                            f"[SPOOLMAN] Reported {filament_used}g usage to spool {spool['id']} "
+                            f"(tag: {tag_uid})"
+                        )
+                        spools_updated += 1
+                        # Only report to one spool for single-material prints
+                        # Multi-material prints would need more sophisticated tracking
+                        return
+
+        if spools_updated == 0:
+            logger.debug(f"No matching Spoolman spools found for printer {printer_id}")
+
+
 async def on_printer_status_change(printer_id: int, state: PrinterState):
     """Handle printer status changes - broadcast via WebSocket."""
     # Only broadcast if something meaningful changed (reduce WebSocket spam)
@@ -141,6 +228,74 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     )
 
 
+async def on_ams_change(printer_id: int, ams_data: list):
+    """Handle AMS data changes - sync to Spoolman if enabled and auto mode."""
+    import logging
+    logger = logging.getLogger(__name__)
+
+    try:
+        async with async_session() as db:
+            from backend.app.api.routes.settings import get_setting
+            from backend.app.models.printer import Printer
+
+            # Check if Spoolman is enabled
+            spoolman_enabled = await get_setting(db, "spoolman_enabled")
+            if not spoolman_enabled or spoolman_enabled.lower() != "true":
+                return
+
+            # Check sync mode
+            sync_mode = await get_setting(db, "spoolman_sync_mode")
+            if sync_mode and sync_mode != "auto":
+                return  # Only sync on auto mode
+
+            # Get Spoolman URL
+            spoolman_url = await get_setting(db, "spoolman_url")
+            if not spoolman_url:
+                return
+
+            # Get or create Spoolman client
+            client = await get_spoolman_client()
+            if not client:
+                client = await init_spoolman_client(spoolman_url)
+
+            # Check if Spoolman is reachable
+            if not await client.health_check():
+                logger.warning(f"Spoolman not reachable at {spoolman_url}")
+                return
+
+            # Get printer name for location
+            result = await db.execute(
+                select(Printer).where(Printer.id == printer_id)
+            )
+            printer = result.scalar_one_or_none()
+            printer_name = printer.name if printer else f"Printer {printer_id}"
+
+            # Sync each AMS tray
+            synced = 0
+            for ams_unit in ams_data:
+                ams_id = int(ams_unit.get("id", 0))
+                trays = ams_unit.get("tray", [])
+
+                for tray_data in trays:
+                    tray = client.parse_ams_tray(ams_id, tray_data)
+                    if not tray:
+                        continue  # Empty tray
+
+                    try:
+                        result = await client.sync_ams_tray(tray, printer_name)
+                        if result:
+                            synced += 1
+                    except Exception as e:
+                        logger.error(f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}")
+
+            if synced > 0:
+                logger.info(f"Auto-synced {synced} AMS trays to Spoolman for printer {printer_id}")
+
+    except Exception as e:
+        import logging
+        logging.getLogger(__name__).warning(f"Spoolman AMS sync failed: {e}")
+
+
 async def on_print_start(printer_id: int, data: dict):
     """Handle print start - archive the 3MF file immediately."""
     import logging
@@ -570,6 +725,13 @@ async def on_print_complete(printer_id: int, data: dict):
             "status": status,
         })
 
+    # Report filament usage to Spoolman if print completed successfully
+    if data.get("status") == "completed":
+        try:
+            await _report_spoolman_usage(printer_id, archive_id, logger)
+        except Exception as e:
+            logger.warning(f"Spoolman usage reporting failed: {e}")
+
     # Calculate energy used for this print (always per-print: end - start)
     try:
         starting_kwh = _print_energy_start.pop(archive_id, None)
@@ -765,6 +927,7 @@ async def lifespan(app: FastAPI):
     printer_manager.set_status_change_callback(on_printer_status_change)
     printer_manager.set_print_start_callback(on_print_start)
     printer_manager.set_print_complete_callback(on_print_complete)
+    printer_manager.set_ams_change_callback(on_ams_change)
 
     # Connect to all active printers
     async with async_session() as db:
@@ -797,6 +960,7 @@ app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)
+app.include_router(spoolman.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 

+ 8 - 0
backend/app/schemas/settings.py

@@ -12,6 +12,11 @@ class AppSettings(BaseModel):
     energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
     energy_tracking_mode: str = Field(default="total", description="Energy display mode on stats: 'print' shows sum of per-print energy, 'total' shows lifetime plug consumption")
 
+    # Spoolman integration
+    spoolman_enabled: bool = Field(default=False, description="Enable Spoolman integration for filament tracking")
+    spoolman_url: str = Field(default="", description="Spoolman server URL (e.g., http://localhost:7912)")
+    spoolman_sync_mode: str = Field(default="auto", description="Sync mode: 'auto' syncs immediately, 'manual' requires button press")
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -23,3 +28,6 @@ class AppSettingsUpdate(BaseModel):
     currency: str | None = None
     energy_cost_per_kwh: float | None = None
     energy_tracking_mode: str | None = None
+    spoolman_enabled: bool | None = None
+    spoolman_url: str | None = None
+    spoolman_sync_mode: str | None = None

+ 47 - 0
backend/app/services/bambu_mqtt.py

@@ -78,6 +78,7 @@ class BambuMQTTClient:
         on_state_change: Callable[[PrinterState], None] | None = None,
         on_print_start: Callable[[dict], None] | None = None,
         on_print_complete: Callable[[dict], None] | None = None,
+        on_ams_change: Callable[[list], None] | None = None,
     ):
         self.ip_address = ip_address
         self.serial_number = serial_number
@@ -85,6 +86,7 @@ class BambuMQTTClient:
         self.on_state_change = on_state_change
         self.on_print_start = on_print_start
         self.on_print_complete = on_print_complete
+        self.on_ams_change = on_ams_change
 
         self.state = PrinterState()
         self._client: mqtt.Client | None = None
@@ -96,6 +98,7 @@ class BambuMQTTClient:
         self._message_log: deque[MQTTLogEntry] = deque(maxlen=100)
         self._logging_enabled: bool = False
         self._last_message_time: float = 0.0  # Track when we last received a message
+        self._previous_ams_hash: str | None = None  # Track AMS changes
 
         # K-profile command tracking
         self._sequence_id: int = 0
@@ -157,6 +160,14 @@ class BambuMQTTClient:
 
     def _process_message(self, payload: dict):
         """Process incoming MQTT message from printer."""
+        # Handle top-level AMS data (comes outside of "print" key)
+        # Wrap in try/except to prevent breaking the MQTT connection
+        if "ams" in payload:
+            try:
+                self._handle_ams_data(payload["ams"])
+            except Exception as e:
+                logger.error(f"[{self.serial_number}] Error handling AMS data: {e}")
+
         if "print" in payload:
             print_data = payload["print"]
             # Log when we see gcode_state changes
@@ -172,6 +183,38 @@ class BambuMQTTClient:
 
             self._update_state(print_data)
 
+    def _handle_ams_data(self, ams_data: list):
+        """Handle AMS data changes for Spoolman integration.
+
+        This is called when we receive top-level AMS data in MQTT messages.
+        It detects changes and triggers the callback for Spoolman sync.
+        """
+        import hashlib
+
+        # Store AMS data in raw_data so it's accessible via API
+        if "ams" not in self.state.raw_data:
+            self.state.raw_data["ams"] = ams_data
+        else:
+            self.state.raw_data["ams"] = ams_data
+
+        # Create a hash of relevant AMS data to detect changes
+        ams_hash_data = []
+        for ams_unit in ams_data:
+            for tray in ams_unit.get("tray", []):
+                # Include fields that matter for filament tracking
+                ams_hash_data.append(
+                    f"{ams_unit.get('id')}:{tray.get('id')}:"
+                    f"{tray.get('tray_type')}:{tray.get('tag_uid')}:{tray.get('remain')}"
+                )
+        ams_hash = hashlib.md5(":".join(ams_hash_data).encode()).hexdigest()
+
+        # Only trigger callback if AMS data actually changed
+        if ams_hash != self._previous_ams_hash:
+            self._previous_ams_hash = ams_hash
+            if self.on_ams_change:
+                logger.info(f"[{self.serial_number}] AMS data changed, triggering sync callback")
+                self.on_ams_change(ams_data)
+
     def _update_state(self, data: dict):
         """Update printer state from message data."""
         previous_state = self.state.state
@@ -259,7 +302,11 @@ class BambuMQTTClient:
                             severity=severity if severity > 0 else 3,
                         ))
 
+        # Preserve AMS data when updating raw_data (AMS comes at top level, not in print)
+        ams_data = self.state.raw_data.get("ams")
         self.state.raw_data = data
+        if ams_data is not None:
+            self.state.raw_data["ams"] = ams_data
 
         # Log state transitions for debugging
         if "gcode_state" in data:

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

@@ -18,6 +18,7 @@ class PrinterManager:
         self._on_print_start: Callable[[int, dict], None] | None = None
         self._on_print_complete: Callable[[int, dict], None] | None = None
         self._on_status_change: Callable[[int, PrinterState], None] | None = None
+        self._on_ams_change: Callable[[int, list], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
 
     def set_event_loop(self, loop: asyncio.AbstractEventLoop):
@@ -36,6 +37,10 @@ class PrinterManager:
         """Set callback for status change events."""
         self._on_status_change = callback
 
+    def set_ams_change_callback(self, callback: Callable[[int, list], None]):
+        """Set callback for AMS data change events."""
+        self._on_ams_change = callback
+
     def _schedule_async(self, coro):
         """Schedule an async coroutine from a sync context."""
         if self._loop and self._loop.is_running():
@@ -66,6 +71,12 @@ class PrinterManager:
                     self._on_print_complete(printer_id, data)
                 )
 
+        def on_ams_change(ams_data: list):
+            if self._on_ams_change:
+                self._schedule_async(
+                    self._on_ams_change(printer_id, ams_data)
+                )
+
         client = BambuMQTTClient(
             ip_address=printer.ip_address,
             serial_number=printer.serial_number,
@@ -73,6 +84,7 @@ class PrinterManager:
             on_state_change=on_state_change,
             on_print_start=on_print_start,
             on_print_complete=on_print_complete,
+            on_ams_change=on_ams_change,
         )
 
         client.connect()

+ 698 - 0
backend/app/services/spoolman.py

@@ -0,0 +1,698 @@
+"""Spoolman integration service for syncing AMS filament data."""
+
+import logging
+from dataclasses import dataclass
+from datetime import datetime, timezone
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class SpoolmanSpool:
+    """Represents a spool in Spoolman."""
+
+    id: int
+    filament_id: int | None
+    remaining_weight: float | None
+    used_weight: float
+    first_used: str | None
+    last_used: str | None
+    location: str | None
+    lot_nr: str | None
+    comment: str | None
+    extra: dict | None  # Contains tag_uid in extra.tag
+
+
+@dataclass
+class SpoolmanFilament:
+    """Represents a filament type in Spoolman."""
+
+    id: int
+    name: str
+    vendor_id: int | None
+    material: str | None
+    color_hex: str | None
+    weight: float | None  # Net weight in grams
+
+
+@dataclass
+class AMSTray:
+    """Represents an AMS tray with filament data from Bambu printer."""
+
+    ams_id: int  # 0-3 for regular AMS, 128-135 for external spool
+    tray_id: int  # 0-3
+    tray_type: str  # PLA, PETG, ABS, etc.
+    tray_sub_brands: str  # Full name like "PLA Basic", "PETG HF"
+    tray_color: str  # Hex color like "FEC600FF"
+    remain: int  # Remaining percentage (0-100)
+    tag_uid: str  # RFID tag UID
+    tray_uuid: str  # Spool UUID
+    tray_weight: int  # Spool weight in grams (usually 1000)
+
+
+class SpoolmanClient:
+    """Client for interacting with Spoolman API."""
+
+    def __init__(self, base_url: str):
+        """Initialize the Spoolman client.
+
+        Args:
+            base_url: The base URL of the Spoolman server (e.g., http://localhost:7912)
+        """
+        self.base_url = base_url.rstrip("/")
+        self.api_url = f"{self.base_url}/api/v1"
+        self._client: httpx.AsyncClient | None = None
+        self._connected = False
+
+    async def _get_client(self) -> httpx.AsyncClient:
+        """Get or create the HTTP client."""
+        if self._client is None:
+            self._client = httpx.AsyncClient(timeout=10.0)
+        return self._client
+
+    async def close(self):
+        """Close the HTTP client."""
+        if self._client:
+            await self._client.aclose()
+            self._client = None
+
+    async def health_check(self) -> bool:
+        """Check if Spoolman server is reachable.
+
+        Returns:
+            True if server is healthy, False otherwise.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.get(f"{self.api_url}/health")
+            self._connected = response.status_code == 200
+            return self._connected
+        except Exception as e:
+            logger.warning(f"Spoolman health check failed: {e}")
+            self._connected = False
+            return False
+
+    @property
+    def is_connected(self) -> bool:
+        """Check if client is connected to Spoolman."""
+        return self._connected
+
+    async def get_spools(self) -> list[dict]:
+        """Get all spools from Spoolman.
+
+        Returns:
+            List of spool dictionaries.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.get(f"{self.api_url}/spool")
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to get spools from Spoolman: {e}")
+            return []
+
+    async def get_filaments(self) -> list[dict]:
+        """Get all internal filaments from Spoolman.
+
+        Returns:
+            List of filament dictionaries.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.get(f"{self.api_url}/filament")
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to get filaments from Spoolman: {e}")
+            return []
+
+    async def get_external_filaments(self) -> list[dict]:
+        """Get external/library filaments from Spoolman.
+
+        Returns:
+            List of external filament dictionaries.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.get(f"{self.api_url}/external/filament")
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to get external filaments from Spoolman: {e}")
+            return []
+
+    async def get_vendors(self) -> list[dict]:
+        """Get all vendors from Spoolman.
+
+        Returns:
+            List of vendor dictionaries.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.get(f"{self.api_url}/vendor")
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to get vendors from Spoolman: {e}")
+            return []
+
+    async def create_vendor(self, name: str) -> dict | None:
+        """Create a new vendor in Spoolman.
+
+        Args:
+            name: Vendor name (e.g., "Bambu Lab")
+
+        Returns:
+            Created vendor dictionary or None on failure.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.post(
+                f"{self.api_url}/vendor", json={"name": name}
+            )
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to create vendor in Spoolman: {e}")
+            return None
+
+    def _get_material_density(self, material: str | None) -> float:
+        """Get typical density for a filament material type.
+
+        Args:
+            material: Material type (PLA, PETG, ABS, etc.)
+
+        Returns:
+            Density in g/cm³
+        """
+        # Typical densities for common filament materials
+        densities = {
+            "PLA": 1.24,
+            "PLA-CF": 1.29,
+            "PLA-S": 1.24,
+            "PETG": 1.27,
+            "ABS": 1.04,
+            "ASA": 1.07,
+            "TPU": 1.21,
+            "PA": 1.14,  # Nylon
+            "PA-CF": 1.20,
+            "PC": 1.20,
+            "PVA": 1.23,
+            "HIPS": 1.04,
+            "PP": 0.90,
+            "PET": 1.38,
+        }
+        if material:
+            # Try exact match first, then uppercase
+            mat_upper = material.upper()
+            for key, density in densities.items():
+                if key.upper() == mat_upper or mat_upper.startswith(key.upper()):
+                    return density
+        return 1.24  # Default to PLA density
+
+    async def create_filament(
+        self,
+        name: str,
+        vendor_id: int | None = None,
+        material: str | None = None,
+        color_hex: str | None = None,
+        weight: float | None = None,
+        diameter: float = 1.75,
+        density: float | None = None,
+    ) -> dict | None:
+        """Create a new filament in Spoolman.
+
+        Args:
+            name: Filament name
+            vendor_id: Vendor ID
+            material: Material type (PLA, PETG, etc.)
+            color_hex: Color in hex format (without #)
+            weight: Net weight in grams
+            diameter: Filament diameter in mm (default 1.75)
+            density: Filament density in g/cm³ (auto-calculated if not provided)
+
+        Returns:
+            Created filament dictionary or None on failure.
+        """
+        # Validate required fields
+        if not name or not name.strip():
+            logger.error("Cannot create filament: name is required")
+            return None
+
+        try:
+            # Calculate density from material if not provided
+            if density is None:
+                density = self._get_material_density(material)
+
+            data = {
+                "name": name.strip(),
+                "diameter": diameter,
+                "density": density,
+            }
+            if vendor_id:
+                data["vendor_id"] = vendor_id
+            if material:
+                data["material"] = material
+            if color_hex:
+                # Strip alpha channel if present (RRGGBBAA -> RRGGBB)
+                color_hex = color_hex[:6] if len(color_hex) >= 6 else color_hex
+                data["color_hex"] = color_hex
+            if weight:
+                data["weight"] = weight
+
+            logger.debug(f"Creating filament in Spoolman: {data}")
+            client = await self._get_client()
+            response = await client.post(f"{self.api_url}/filament", json=data)
+            response.raise_for_status()
+            return response.json()
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Failed to create filament in Spoolman: {e}, response: {e.response.text}")
+            return None
+        except Exception as e:
+            logger.error(f"Failed to create filament in Spoolman: {e}")
+            return None
+
+    async def create_spool(
+        self,
+        filament_id: int,
+        remaining_weight: float | None = None,
+        location: str | None = None,
+        lot_nr: str | None = None,
+        comment: str | None = None,
+        extra: dict | None = None,
+    ) -> dict | None:
+        """Create a new spool in Spoolman.
+
+        Args:
+            filament_id: ID of the filament type
+            remaining_weight: Remaining weight in grams
+            location: Physical location description
+            lot_nr: Lot/batch number
+            comment: Optional comment
+            extra: Extra fields (e.g., {"tag": "RFID_TAG_UID"})
+
+        Returns:
+            Created spool dictionary or None on failure.
+        """
+        try:
+            data = {"filament_id": filament_id}
+            if remaining_weight is not None:
+                data["remaining_weight"] = remaining_weight
+            if location:
+                data["location"] = location
+            if lot_nr:
+                data["lot_nr"] = lot_nr
+            if comment:
+                data["comment"] = comment
+            if extra:
+                data["extra"] = extra
+
+            logger.debug(f"Creating spool in Spoolman: {data}")
+            client = await self._get_client()
+            response = await client.post(f"{self.api_url}/spool", json=data)
+            response.raise_for_status()
+            return response.json()
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Failed to create spool in Spoolman: {e}, response: {e.response.text}")
+            return None
+        except Exception as e:
+            logger.error(f"Failed to create spool in Spoolman: {e}")
+            return None
+
+    async def update_spool(
+        self,
+        spool_id: int,
+        remaining_weight: float | None = None,
+        location: str | None = None,
+        extra: dict | None = None,
+    ) -> dict | None:
+        """Update an existing spool in Spoolman.
+
+        Args:
+            spool_id: ID of the spool to update
+            remaining_weight: New remaining weight in grams
+            location: New location
+            extra: Extra fields to update
+
+        Returns:
+            Updated spool dictionary or None on failure.
+        """
+        try:
+            data = {}
+            if remaining_weight is not None:
+                data["remaining_weight"] = remaining_weight
+            if location:
+                data["location"] = location
+            if extra:
+                data["extra"] = extra
+
+            # Always update last_used
+            data["last_used"] = datetime.now(timezone.utc).isoformat()
+
+            client = await self._get_client()
+            response = await client.patch(
+                f"{self.api_url}/spool/{spool_id}", json=data
+            )
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to update spool in Spoolman: {e}")
+            return None
+
+    async def use_spool(self, spool_id: int, used_weight: float) -> dict | None:
+        """Record filament usage for a spool.
+
+        Args:
+            spool_id: ID of the spool
+            used_weight: Amount of filament used in grams
+
+        Returns:
+            Updated spool dictionary or None on failure.
+        """
+        try:
+            client = await self._get_client()
+            response = await client.put(
+                f"{self.api_url}/spool/{spool_id}/use",
+                json={"use_weight": used_weight},
+            )
+            response.raise_for_status()
+            return response.json()
+        except Exception as e:
+            logger.error(f"Failed to record spool usage in Spoolman: {e}")
+            return None
+
+    async def find_spool_by_tag(self, tag_uid: str) -> dict | None:
+        """Find a spool by its RFID tag UID.
+
+        Args:
+            tag_uid: The RFID tag UID to search for
+
+        Returns:
+            Spool dictionary or None if not found.
+        """
+        spools = await self.get_spools()
+        # Normalize tag_uid for comparison (uppercase, strip quotes)
+        search_tag = tag_uid.strip('"').upper()
+
+        for spool in spools:
+            extra = spool.get("extra", {})
+            if extra:
+                stored_tag = extra.get("tag", "")
+                # Normalize stored tag (strip quotes, uppercase)
+                if stored_tag:
+                    normalized_tag = stored_tag.strip('"').upper()
+                    if normalized_tag == search_tag:
+                        logger.debug(f"Found spool {spool['id']} matching tag {tag_uid}")
+                        return spool
+        return None
+
+    async def ensure_bambu_vendor(self) -> int | None:
+        """Ensure Bambu Lab vendor exists and return its ID.
+
+        Returns:
+            Vendor ID or None on failure.
+        """
+        vendors = await self.get_vendors()
+        for vendor in vendors:
+            if vendor.get("name", "").lower() == "bambu lab":
+                return vendor["id"]
+
+        # Create Bambu Lab vendor if not exists
+        vendor = await self.create_vendor("Bambu Lab")
+        return vendor["id"] if vendor else None
+
+    def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
+        """Parse AMS tray data into AMSTray object.
+
+        Args:
+            ams_id: The AMS unit ID (0-3 for regular, 128-135 for external)
+            tray_data: Raw tray data from MQTT
+
+        Returns:
+            AMSTray object or None if tray is empty or invalid.
+        """
+        # Skip empty trays - check for valid tray_type
+        tray_type = tray_data.get("tray_type", "")
+        if not tray_type or tray_type.strip() == "":
+            return None
+
+        # Also need valid color to create filament (000000FF = unset/empty)
+        tray_color = tray_data.get("tray_color", "")
+        if not tray_color or tray_color in ("", "000000FF", "00000000"):
+            logger.debug(f"Skipping tray with invalid color: {tray_color}")
+            return None
+
+        # Get sub_brands, falling back to tray_type
+        tray_sub_brands = tray_data.get("tray_sub_brands", "")
+        if not tray_sub_brands or tray_sub_brands.strip() == "":
+            tray_sub_brands = tray_type
+
+        # Get tag_uid and tray_uuid, filtering out empty/invalid values
+        tag_uid = tray_data.get("tag_uid", "")
+        if tag_uid in ("", "0000000000000000"):
+            tag_uid = ""
+        tray_uuid = tray_data.get("tray_uuid", "")
+        if tray_uuid in ("", "00000000000000000000000000000000"):
+            tray_uuid = ""
+
+        # Get remaining percentage, ensure non-negative
+        remain = max(0, int(tray_data.get("remain", 0)))
+
+        return AMSTray(
+            ams_id=ams_id,
+            tray_id=int(tray_data.get("id", 0)),
+            tray_type=tray_type.strip(),
+            tray_sub_brands=tray_sub_brands.strip(),
+            tray_color=tray_color,
+            remain=remain,
+            tag_uid=tag_uid,
+            tray_uuid=tray_uuid,
+            tray_weight=int(tray_data.get("tray_weight", 1000)),
+        )
+
+    def convert_ams_slot_to_location(self, ams_id: int, tray_id: int) -> str:
+        """Convert AMS ID and tray ID to human-readable location.
+
+        Args:
+            ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for external)
+            tray_id: Tray ID within the AMS (0-3)
+
+        Returns:
+            Location string like "AMS A1", "AMS B2", "External"
+        """
+        if ams_id >= 128:
+            return "External Spool"
+
+        ams_letter = chr(ord("A") + ams_id)
+        return f"AMS {ams_letter}{tray_id + 1}"
+
+    def is_bambu_lab_spool(self, tray_uuid: str) -> bool:
+        """Check if a tray has a valid Bambu Lab spool UUID.
+
+        Bambu Lab spools have a tray_uuid which is a 32-character hex string.
+        This UUID is consistent across all printer models (unlike tag_uid which
+        varies between X1C/H2D readers).
+
+        Non-Bambu Lab spools (SpoolEase, third-party) won't have a valid tray_uuid.
+
+        Args:
+            tray_uuid: The tray UUID to check
+
+        Returns:
+            True if the spool has a valid Bambu Lab UUID, False otherwise.
+        """
+        if not tray_uuid:
+            return False
+        # Bambu Lab tray_uuid is always 32 hex characters
+        uuid = tray_uuid.strip()
+        if len(uuid) != 32:
+            return False
+        # Verify it's all hex characters and not empty/zero
+        if uuid == "00000000000000000000000000000000":
+            return False
+        try:
+            int(uuid, 16)
+            return True
+        except ValueError:
+            return False
+
+    def calculate_remaining_weight(
+        self, remain_percent: int, spool_weight: int
+    ) -> float:
+        """Calculate remaining weight from percentage.
+
+        Args:
+            remain_percent: Remaining percentage (0-100)
+            spool_weight: Total spool weight in grams
+
+        Returns:
+            Remaining weight in grams.
+        """
+        return (remain_percent / 100.0) * spool_weight
+
+    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).
+        Non-Bambu Lab spools (SpoolEase/third-party) are skipped.
+
+        Uses tray_uuid for matching, as it's consistent across all printer models
+        (unlike tag_uid which varies between X1C/H2D readers).
+
+        Args:
+            tray: The AMSTray to sync
+            printer_name: Name of the printer for location
+
+        Returns:
+            Synced spool dictionary or None if skipped or failed.
+        """
+        logger.debug(
+            f"Processing {printer_name} AMS {tray.ams_id} tray {tray.tray_id}: "
+            f"type={tray.tray_type}, uuid={tray.tray_uuid[:16] if tray.tray_uuid else 'none'}..."
+        )
+
+        # Only sync trays with valid Bambu Lab tray_uuid
+        if not self.is_bambu_lab_spool(tray.tray_uuid):
+            if tray.tray_uuid or tray.tag_uid:
+                logger.info(
+                    f"Skipping non-Bambu Lab spool: {printer_name} AMS {tray.ams_id} tray {tray.tray_id} "
+                    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}"
+                )
+            return None
+
+        # Calculate remaining weight
+        remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
+        location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
+
+        # Find existing spool by tray_uuid (stored as "tag" in Spoolman)
+        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}"
+            )
+            return await self.update_spool(
+                spool_id=existing["id"],
+                remaining_weight=remaining,
+                location=location,
+            )
+
+        # Spool not found in Spoolman - log warning but don't create new
+        logger.warning(
+            f"Bambu Lab spool with tray_uuid {tray.tray_uuid} not found in Spoolman. "
+            f"Add this spool to Spoolman first to enable syncing."
+        )
+        return None
+
+    async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
+        """Find existing filament or create new one.
+
+        Args:
+            tray: The AMSTray containing filament info
+
+        Returns:
+            Filament dictionary or None on failure.
+        """
+        # Search internal filaments first
+        filaments = await self.get_filaments()
+        color_hex = tray.tray_color[:6]  # Strip alpha channel
+
+        for filament in filaments:
+            # 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()
+            ):
+                return filament
+
+        # Search external filaments (Bambu library)
+        external = await self.get_external_filaments()
+        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()
+            ):
+                # Found in external library - need to create internal copy
+                return await self._create_filament_from_external(filament, tray)
+
+        # Not found - create new filament
+        vendor_id = await self.ensure_bambu_vendor()
+        return await self.create_filament(
+            name=tray.tray_sub_brands or tray.tray_type,
+            vendor_id=vendor_id,
+            material=tray.tray_type,
+            color_hex=color_hex,
+            weight=tray.tray_weight,
+        )
+
+    async def _create_filament_from_external(
+        self, external: dict, tray: AMSTray
+    ) -> dict | None:
+        """Create internal filament from external library entry.
+
+        Args:
+            external: External filament dictionary
+            tray: The AMSTray for additional info
+
+        Returns:
+            Created filament dictionary or None on failure.
+        """
+        vendor_id = await self.ensure_bambu_vendor()
+        return await self.create_filament(
+            name=external.get("name", tray.tray_sub_brands),
+            vendor_id=vendor_id,
+            material=external.get("material", tray.tray_type),
+            color_hex=external.get("color_hex", tray.tray_color[:6]),
+            weight=external.get("weight", tray.tray_weight),
+        )
+
+
+# Global client instance (initialized when settings are loaded)
+_spoolman_client: SpoolmanClient | None = None
+
+
+async def get_spoolman_client() -> SpoolmanClient | None:
+    """Get the global Spoolman client instance.
+
+    Returns:
+        SpoolmanClient instance or None if not configured.
+    """
+    return _spoolman_client
+
+
+async def init_spoolman_client(url: str) -> SpoolmanClient:
+    """Initialize the global Spoolman client.
+
+    Args:
+        url: Spoolman server URL
+
+    Returns:
+        Initialized SpoolmanClient instance.
+    """
+    global _spoolman_client
+    if _spoolman_client:
+        await _spoolman_client.close()
+
+    _spoolman_client = SpoolmanClient(url)
+    return _spoolman_client
+
+
+async def close_spoolman_client():
+    """Close the global Spoolman client."""
+    global _spoolman_client
+    if _spoolman_client:
+        await _spoolman_client.close()
+        _spoolman_client = None

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

@@ -479,6 +479,19 @@ export interface EmailConfig {
   use_tls?: boolean;
 }
 
+// Spoolman types
+export interface SpoolmanStatus {
+  enabled: boolean;
+  connected: boolean;
+  url: string | null;
+}
+
+export interface SpoolmanSyncResult {
+  success: boolean;
+  synced_count: number;
+  errors: string[];
+}
+
 // API functions
 export const api = {
   // Printers
@@ -870,4 +883,27 @@ export const api = {
       method: 'POST',
       body: JSON.stringify(data),
     }),
+
+  // Spoolman Integration
+  getSpoolmanStatus: () => request<SpoolmanStatus>('/spoolman/status'),
+  connectSpoolman: () =>
+    request<{ success: boolean; message: string }>('/spoolman/connect', {
+      method: 'POST',
+    }),
+  disconnectSpoolman: () =>
+    request<{ success: boolean; message: string }>('/spoolman/disconnect', {
+      method: 'POST',
+    }),
+  syncPrinterAms: (printerId: number) =>
+    request<SpoolmanSyncResult>(`/spoolman/sync/${printerId}`, {
+      method: 'POST',
+    }),
+  syncAllPrintersAms: () =>
+    request<SpoolmanSyncResult>('/spoolman/sync-all', {
+      method: 'POST',
+    }),
+  getSpoolmanSpools: () =>
+    request<{ spools: unknown[] }>('/spoolman/spools'),
+  getSpoolmanFilaments: () =>
+    request<{ filaments: unknown[] }>('/spoolman/filaments'),
 };

+ 378 - 0
frontend/src/components/SpoolmanSettings.tsx

@@ -0,0 +1,378 @@
+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 { api } from '../api/client';
+import type { SpoolmanSyncResult, Printer } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+
+interface SpoolmanSettingsData {
+  spoolman_enabled: string;
+  spoolman_url: string;
+  spoolman_sync_mode: string;
+}
+
+async function getSpoolmanSettings(): Promise<SpoolmanSettingsData> {
+  const response = await fetch('/api/v1/settings/spoolman');
+  if (!response.ok) {
+    throw new Error('Failed to load Spoolman settings');
+  }
+  return response.json();
+}
+
+async function updateSpoolmanSettings(data: Partial<SpoolmanSettingsData>): Promise<SpoolmanSettingsData> {
+  const response = await fetch('/api/v1/settings/spoolman', {
+    method: 'PUT',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(data),
+  });
+  if (!response.ok) {
+    throw new Error('Failed to save Spoolman settings');
+  }
+  return response.json();
+}
+
+export function SpoolmanSettings() {
+  const queryClient = useQueryClient();
+  const [localEnabled, setLocalEnabled] = useState(false);
+  const [localUrl, setLocalUrl] = useState('');
+  const [localSyncMode, setLocalSyncMode] = useState('auto');
+  const [hasChanges, setHasChanges] = useState(false);
+  const [showSaved, setShowSaved] = useState(false);
+  const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
+
+  // Fetch Spoolman settings
+  const { data: settings, isLoading: settingsLoading } = useQuery({
+    queryKey: ['spoolman-settings'],
+    queryFn: getSpoolmanSettings,
+  });
+
+  // Fetch Spoolman status
+  const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({
+    queryKey: ['spoolman-status'],
+    queryFn: api.getSpoolmanStatus,
+    refetchInterval: 30000, // Refresh every 30 seconds
+  });
+
+  // Fetch printers for the dropdown
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
+  // Initialize local state from settings
+  useEffect(() => {
+    if (settings) {
+      setLocalEnabled(settings.spoolman_enabled === 'true');
+      setLocalUrl(settings.spoolman_url || '');
+      setLocalSyncMode(settings.spoolman_sync_mode || 'auto');
+    }
+  }, [settings]);
+
+  // Track changes
+  useEffect(() => {
+    if (settings) {
+      const changed =
+        (settings.spoolman_enabled === 'true') !== localEnabled ||
+        (settings.spoolman_url || '') !== localUrl ||
+        (settings.spoolman_sync_mode || 'auto') !== localSyncMode;
+      setHasChanges(changed);
+    }
+  }, [settings, localEnabled, localUrl, localSyncMode]);
+
+  // Save mutation
+  const saveMutation = useMutation({
+    mutationFn: () =>
+      updateSpoolmanSettings({
+        spoolman_enabled: localEnabled ? 'true' : 'false',
+        spoolman_url: localUrl,
+        spoolman_sync_mode: localSyncMode,
+      }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
+      queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
+      setHasChanges(false);
+      setShowSaved(true);
+      setTimeout(() => setShowSaved(false), 2000);
+    },
+  });
+
+  // Connect mutation
+  const connectMutation = useMutation({
+    mutationFn: api.connectSpoolman,
+    onSuccess: () => {
+      refetchStatus();
+    },
+  });
+
+  // Disconnect mutation
+  const disconnectMutation = useMutation({
+    mutationFn: api.disconnectSpoolman,
+    onSuccess: () => {
+      refetchStatus();
+    },
+  });
+
+  // Sync all mutation
+  const syncAllMutation = useMutation({
+    mutationFn: api.syncAllPrintersAms,
+    onSuccess: (data: SpoolmanSyncResult) => {
+      if (data.success) {
+        // Show success message
+      }
+    },
+  });
+
+  // Sync single printer mutation
+  const syncPrinterMutation = useMutation({
+    mutationFn: (printerId: number) => api.syncPrinterAms(printerId),
+    onSuccess: (data: SpoolmanSyncResult) => {
+      if (data.success) {
+        // Show success message
+      }
+    },
+  });
+
+  // Helper to handle sync based on selection
+  const handleSync = () => {
+    if (selectedPrinterId === 'all') {
+      syncAllMutation.mutate();
+    } else {
+      syncPrinterMutation.mutate(selectedPrinterId);
+    }
+  };
+
+  // Combine mutation states
+  const isSyncing = syncAllMutation.isPending || syncPrinterMutation.isPending;
+  const syncResult = selectedPrinterId === 'all' ? syncAllMutation.data : syncPrinterMutation.data;
+  const syncSuccess = selectedPrinterId === 'all' ? syncAllMutation.isSuccess : syncPrinterMutation.isSuccess;
+
+  if (settingsLoading) {
+    return (
+      <Card>
+        <CardHeader>
+          <div className="flex items-center gap-2">
+            <Database className="w-5 h-5 text-bambu-green" />
+            <h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
+          </div>
+        </CardHeader>
+        <CardContent>
+          <div className="flex justify-center py-8">
+            <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
+          </div>
+        </CardContent>
+      </Card>
+    );
+  }
+
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex items-center justify-between">
+          <div className="flex items-center gap-2">
+            <Database className="w-5 h-5 text-bambu-green" />
+            <h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
+          </div>
+          {hasChanges && (
+            <Button
+              size="sm"
+              onClick={() => saveMutation.mutate()}
+              disabled={saveMutation.isPending}
+            >
+              {saveMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : showSaved ? (
+                <Check className="w-4 h-4" />
+              ) : (
+                'Save'
+              )}
+            </Button>
+          )}
+        </div>
+      </CardHeader>
+      <CardContent className="space-y-4">
+        <p className="text-sm text-bambu-gray">
+          Connect to Spoolman for filament inventory tracking. AMS data will sync automatically.
+        </p>
+
+        {/* Enable toggle */}
+        <div className="flex items-center justify-between">
+          <div>
+            <p className="text-white">Enable Spoolman</p>
+            <p className="text-sm text-bambu-gray">
+              Sync filament data with Spoolman server
+            </p>
+          </div>
+          <label className="relative inline-flex items-center cursor-pointer">
+            <input
+              type="checkbox"
+              checked={localEnabled}
+              onChange={(e) => setLocalEnabled(e.target.checked)}
+              className="sr-only peer"
+            />
+            <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+          </label>
+        </div>
+
+        {/* URL input */}
+        <div>
+          <label className="block text-sm text-bambu-gray mb-1">
+            Spoolman URL
+          </label>
+          <input
+            type="text"
+            placeholder="http://192.168.1.100:7912"
+            value={localUrl}
+            onChange={(e) => setLocalUrl(e.target.value)}
+            disabled={!localEnabled}
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none disabled:opacity-50"
+          />
+          <p className="text-xs text-bambu-gray mt-1">
+            URL of your Spoolman server (e.g., http://localhost:7912)
+          </p>
+        </div>
+
+        {/* Sync mode */}
+        <div>
+          <label className="block text-sm text-bambu-gray mb-1">
+            Sync Mode
+          </label>
+          <select
+            value={localSyncMode}
+            onChange={(e) => setLocalSyncMode(e.target.value)}
+            disabled={!localEnabled}
+            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50"
+          >
+            <option value="auto">Automatic</option>
+            <option value="manual">Manual Only</option>
+          </select>
+          <p className="text-xs text-bambu-gray mt-1">
+            {localSyncMode === 'auto'
+              ? 'AMS data syncs automatically when changes are detected'
+              : 'Only sync when manually triggered'}
+          </p>
+        </div>
+
+        {/* Connection status */}
+        {localEnabled && (
+          <div className="pt-2 border-t border-bambu-dark-tertiary">
+            <div className="flex items-center justify-between mb-3">
+              <div className="flex items-center gap-2">
+                <span className="text-sm text-bambu-gray">Status:</span>
+                {statusLoading ? (
+                  <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
+                ) : status?.connected ? (
+                  <span className="flex items-center gap-1 text-sm text-green-500">
+                    <Check className="w-4 h-4" />
+                    Connected
+                  </span>
+                ) : (
+                  <span className="flex items-center gap-1 text-sm text-red-500">
+                    <X className="w-4 h-4" />
+                    Disconnected
+                  </span>
+                )}
+              </div>
+              <div className="flex gap-2">
+                {status?.connected ? (
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    onClick={() => disconnectMutation.mutate()}
+                    disabled={disconnectMutation.isPending}
+                  >
+                    {disconnectMutation.isPending ? (
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                    ) : (
+                      <Link2Off className="w-4 h-4" />
+                    )}
+                    Disconnect
+                  </Button>
+                ) : (
+                  <Button
+                    size="sm"
+                    onClick={() => connectMutation.mutate()}
+                    disabled={connectMutation.isPending || !localUrl}
+                  >
+                    {connectMutation.isPending ? (
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                    ) : (
+                      <Link2 className="w-4 h-4" />
+                    )}
+                    Connect
+                  </Button>
+                )}
+              </div>
+            </div>
+
+            {/* Error display */}
+            {connectMutation.isError && (
+              <div className="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
+                {(connectMutation.error as Error).message}
+              </div>
+            )}
+
+            {/* Manual sync section */}
+            {status?.connected && (
+              <div className="space-y-3">
+                <div>
+                  <p className="text-sm text-white">Sync AMS Data</p>
+                  <p className="text-xs text-bambu-gray">
+                    Manually sync printer AMS data to Spoolman
+                  </p>
+                </div>
+                <div className="flex items-center gap-2">
+                  {/* Printer selector */}
+                  <div className="relative flex-1">
+                    <select
+                      value={selectedPrinterId}
+                      onChange={(e) => setSelectedPrinterId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
+                      className="w-full px-3 py-2 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
+                    >
+                      <option value="all">All Printers</option>
+                      {printers?.map((printer: Printer) => (
+                        <option key={printer.id} value={printer.id}>
+                          {printer.name}
+                        </option>
+                      ))}
+                    </select>
+                    <ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                  </div>
+                  {/* Sync button */}
+                  <Button
+                    variant="secondary"
+                    size="sm"
+                    onClick={handleSync}
+                    disabled={isSyncing}
+                  >
+                    {isSyncing ? (
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                    ) : (
+                      <RefreshCw className="w-4 h-4" />
+                    )}
+                    Sync
+                  </Button>
+                </div>
+              </div>
+            )}
+
+            {/* 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>
+            )}
+          </div>
+        )}
+      </CardContent>
+    </Card>
+  );
+}

+ 3 - 0
frontend/src/pages/SettingsPage.tsx

@@ -8,6 +8,7 @@ import { SmartPlugCard } from '../components/SmartPlugCard';
 import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
 import { NotificationProviderCard } from '../components/NotificationProviderCard';
 import { AddNotificationModal } from '../components/AddNotificationModal';
+import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { useState, useEffect } from 'react';
 
@@ -326,6 +327,8 @@ export function SettingsPage() {
             </CardContent>
           </Card>
 
+          <SpoolmanSettings />
+
           <Card>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">About</h2>

File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-CfyzT_2B.css


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-DJNRCg8M.css


File diff ditekan karena terlalu besar
+ 0 - 0
static/assets/index-zNwYBAHC.js


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-    <script type="module" crossorigin src="/assets/index-CP_GXmpo.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DJNRCg8M.css">
+    <script type="module" crossorigin src="/assets/index-zNwYBAHC.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CfyzT_2B.css">
   </head>
   <body>
     <div id="root"></div>

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini