Browse Source

Clear Spoolman location when spool is removed from AMS

  Previously, when syncing to Spoolman, the location field was set to the
  printer/AMS slot but was never cleared when the spool was removed from
  the AMS.

  Now during sync, the system tracks which tray UUIDs are currently in the
  AMS and clears the location for any spools that were previously at this
  printer but are no longer present.

  Changes:
  - Add clear_location parameter to update_spool() method
  - Add find_spools_by_location_prefix() to find spools at a printer
  - Add clear_location_for_removed_spools() to clear stale locations
  - Update sync_printer_ams and sync_all_printers endpoints to clear
    locations for removed spools after syncing
maziggy 4 months ago
parent
commit
b4bc046684
2 changed files with 97 additions and 2 deletions
  1. 30 0
      backend/app/api/routes/spoolman.py
  2. 67 2
      backend/app/services/spoolman.py

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

@@ -169,6 +169,8 @@ async def sync_printer_ams(
     synced = 0
     synced = 0
     skipped: list[SkippedSpool] = []
     skipped: list[SkippedSpool] = []
     errors = []
     errors = []
+    # Track tray UUIDs currently in the AMS (for clearing removed spools)
+    current_tray_uuids: set[str] = set()
 
 
     # Handle different AMS data structures
     # Handle different AMS data structures
     # Traditional AMS: list of {"id": N, "tray": [...]} dicts
     # Traditional AMS: list of {"id": N, "tray": [...]} dicts
@@ -222,6 +224,9 @@ async def sync_printer_ams(
                 )
                 )
                 continue
                 continue
 
 
+            # Track this tray UUID as currently present in the AMS
+            current_tray_uuids.add(tray.tray_uuid.upper())
+
             try:
             try:
                 sync_result = await client.sync_ams_tray(tray, printer.name)
                 sync_result = await client.sync_ams_tray(tray, printer.name)
                 if sync_result:
                 if sync_result:
@@ -235,6 +240,14 @@ async def sync_printer_ams(
                 logger.error(error_msg)
                 logger.error(error_msg)
                 errors.append(error_msg)
                 errors.append(error_msg)
 
 
+    # Clear location for spools that were removed from this printer's AMS
+    try:
+        cleared = await client.clear_location_for_removed_spools(printer.name, current_tray_uuids)
+        if cleared > 0:
+            logger.info(f"Cleared location for {cleared} spools removed from {printer.name}")
+    except Exception as e:
+        logger.error(f"Error clearing locations for removed spools: {e}")
+
     return SyncResult(
     return SyncResult(
         success=len(errors) == 0,
         success=len(errors) == 0,
         synced_count=synced,
         synced_count=synced,
@@ -269,6 +282,8 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
     total_synced = 0
     total_synced = 0
     all_skipped: list[SkippedSpool] = []
     all_skipped: list[SkippedSpool] = []
     all_errors = []
     all_errors = []
+    # Track tray UUIDs per printer (for clearing removed spools)
+    printer_tray_uuids: dict[str, set[str]] = {}
 
 
     for printer in printers:
     for printer in printers:
         state = printer_manager.get_status(printer.id)
         state = printer_manager.get_status(printer.id)
@@ -279,6 +294,9 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
         if not ams_data:
         if not ams_data:
             continue
             continue
 
 
+        # Initialize tray UUID set for this printer
+        printer_tray_uuids[printer.name] = set()
+
         # Handle different AMS data structures
         # Handle different AMS data structures
         # Traditional AMS: list of {"id": N, "tray": [...]} dicts
         # Traditional AMS: list of {"id": N, "tray": [...]} dicts
         # H2D/newer printers: dict with different structure
         # H2D/newer printers: dict with different structure
@@ -330,6 +348,9 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
                     )
                     )
                     continue
                     continue
 
 
+                # Track this tray UUID as currently present in the AMS
+                printer_tray_uuids[printer.name].add(tray.tray_uuid.upper())
+
                 try:
                 try:
                     sync_result = await client.sync_ams_tray(tray, printer.name)
                     sync_result = await client.sync_ams_tray(tray, printer.name)
                     if sync_result:
                     if sync_result:
@@ -337,6 +358,15 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
                 except Exception as e:
                 except Exception as e:
                     all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
                     all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
 
 
+    # Clear location for spools that were removed from each printer's AMS
+    for printer_name, current_tray_uuids in printer_tray_uuids.items():
+        try:
+            cleared = await client.clear_location_for_removed_spools(printer_name, current_tray_uuids)
+            if cleared > 0:
+                logger.info(f"Cleared location for {cleared} spools removed from {printer_name}")
+        except Exception as e:
+            logger.error(f"Error clearing locations for {printer_name}: {e}")
+
     return SyncResult(
     return SyncResult(
         success=len(all_errors) == 0,
         success=len(all_errors) == 0,
         synced_count=total_synced,
         synced_count=total_synced,

+ 67 - 2
backend/app/services/spoolman.py

@@ -327,6 +327,7 @@ class SpoolmanClient:
         spool_id: int,
         spool_id: int,
         remaining_weight: float | None = None,
         remaining_weight: float | None = None,
         location: str | None = None,
         location: str | None = None,
+        clear_location: bool = False,
         extra: dict | None = None,
         extra: dict | None = None,
     ) -> dict | None:
     ) -> dict | None:
         """Update an existing spool in Spoolman.
         """Update an existing spool in Spoolman.
@@ -334,7 +335,8 @@ class SpoolmanClient:
         Args:
         Args:
             spool_id: ID of the spool to update
             spool_id: ID of the spool to update
             remaining_weight: New remaining weight in grams
             remaining_weight: New remaining weight in grams
-            location: New location
+            location: New location (ignored if clear_location is True)
+            clear_location: If True, clears the location field
             extra: Extra fields to update
             extra: Extra fields to update
 
 
         Returns:
         Returns:
@@ -344,7 +346,9 @@ class SpoolmanClient:
             data = {}
             data = {}
             if remaining_weight is not None:
             if remaining_weight is not None:
                 data["remaining_weight"] = remaining_weight
                 data["remaining_weight"] = remaining_weight
-            if location:
+            if clear_location:
+                data["location"] = None
+            elif location:
                 data["location"] = location
                 data["location"] = location
             if extra:
             if extra:
                 data["extra"] = extra
                 data["extra"] = extra
@@ -407,6 +411,67 @@ class SpoolmanClient:
                         return spool
                         return spool
         return None
         return None
 
 
+    async def find_spools_by_location_prefix(self, location_prefix: str) -> list[dict]:
+        """Find all spools with locations starting with a given prefix.
+
+        Args:
+            location_prefix: The location prefix to search for (e.g., "PrinterName - ")
+
+        Returns:
+            List of spool dictionaries with matching locations.
+        """
+        spools = await self.get_spools()
+        matching = []
+        for spool in spools:
+            location = spool.get("location", "")
+            if location and location.startswith(location_prefix):
+                matching.append(spool)
+        return matching
+
+    async def clear_location_for_removed_spools(
+        self,
+        printer_name: str,
+        current_tray_uuids: set[str],
+    ) -> int:
+        """Clear location for spools that are no longer in the AMS.
+
+        When a spool is removed from the AMS, its location should be cleared
+        in Spoolman. This method finds all spools with locations for this printer
+        and clears the location for any that are not in the current_tray_uuids set.
+
+        Args:
+            printer_name: The printer name used as location prefix
+            current_tray_uuids: Set of tray_uuids currently in the AMS
+
+        Returns:
+            Number of spools whose location was cleared.
+        """
+        location_prefix = f"{printer_name} - "
+        spools_at_printer = await self.find_spools_by_location_prefix(location_prefix)
+        cleared_count = 0
+
+        for spool in spools_at_printer:
+            # Get the tray_uuid (stored as "tag" in extra field)
+            extra = spool.get("extra", {}) or {}
+            stored_tag = extra.get("tag", "")
+            if stored_tag:
+                # Normalize: strip quotes and uppercase
+                spool_uuid = stored_tag.strip('"').upper()
+            else:
+                spool_uuid = ""
+
+            # If this spool's UUID is not in the current AMS, clear its location
+            if spool_uuid not in current_tray_uuids:
+                logger.info(
+                    f"Clearing location for spool {spool['id']} "
+                    f"(was: {spool.get('location')}, uuid: {spool_uuid[:16] if spool_uuid else 'none'}...)"
+                )
+                result = await self.update_spool(spool_id=spool["id"], clear_location=True)
+                if result:
+                    cleared_count += 1
+
+        return cleared_count
+
     async def ensure_bambu_vendor(self) -> int | None:
     async def ensure_bambu_vendor(self) -> int | None:
         """Ensure Bambu Lab vendor exists and return its ID.
         """Ensure Bambu Lab vendor exists and return its ID.