maziggy 3 месяцев назад
Родитель
Сommit
a6f6df2e34

+ 11 - 3
backend/app/api/routes/spoolman.py

@@ -345,6 +345,8 @@ async def sync_all_printers(
     all_errors = []
     all_errors = []
     # Track tray UUIDs per printer (for clearing removed spools)
     # Track tray UUIDs per printer (for clearing removed spools)
     printer_tray_uuids: dict[str, set[str]] = {}
     printer_tray_uuids: dict[str, set[str]] = {}
+    # Track synced spool IDs per printer (for location-based cleanup when no UUIDs available)
+    printer_synced_ids: dict[str, set[int]] = {}
 
 
     # OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
     # OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
     # This eliminates redundant API calls across all printers
     # This eliminates redundant API calls across all printers
@@ -368,8 +370,9 @@ async def sync_all_printers(
         if not ams_data:
         if not ams_data:
             continue
             continue
 
 
-        # Initialize tray UUID set for this printer
+        # Initialize tracking sets for this printer
         printer_tray_uuids[printer.name] = set()
         printer_tray_uuids[printer.name] = set()
+        printer_synced_ids[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
@@ -440,8 +443,10 @@ async def sync_all_printers(
                     )
                     )
                     if sync_result:
                     if sync_result:
                         total_synced += 1
                         total_synced += 1
-                        # Add newly created spool to cache
+                        # Track synced spool ID for cleanup
                         if sync_result.get("id"):
                         if sync_result.get("id"):
+                            printer_synced_ids[printer.name].add(sync_result["id"])
+                            # Add newly created spool to cache
                             spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
                             spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
                             if not spool_exists:
                             if not spool_exists:
                                 cached_spools.append(sync_result)
                                 cached_spools.append(sync_result)
@@ -453,7 +458,10 @@ async def sync_all_printers(
     for printer_name, current_tray_uuids in printer_tray_uuids.items():
     for printer_name, current_tray_uuids in printer_tray_uuids.items():
         try:
         try:
             cleared = await client.clear_location_for_removed_spools(
             cleared = await client.clear_location_for_removed_spools(
-                printer_name, current_tray_uuids, cached_spools=cached_spools
+                printer_name,
+                current_tray_uuids,
+                cached_spools=cached_spools,
+                synced_spool_ids=printer_synced_ids.get(printer_name, set()),
             )
             )
             if cleared > 0:
             if cleared > 0:
                 logger.info("Cleared location for %s spools removed from %s", cleared, printer_name)
                 logger.info("Cleared location for %s spools removed from %s", cleared, printer_name)

+ 3 - 0
backend/app/core/database.py

@@ -1176,6 +1176,9 @@ async def run_migrations(conn):
             )
             )
         """)
         """)
         )
         )
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Add open_in_new_tab column to external_links
     # Migration: Add open_in_new_tab column to external_links
     try:
     try:
         await conn.execute(text("ALTER TABLE external_links ADD COLUMN open_in_new_tab BOOLEAN DEFAULT 0"))
         await conn.execute(text("ALTER TABLE external_links ADD COLUMN open_in_new_tab BOOLEAN DEFAULT 0"))

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

@@ -941,8 +941,9 @@ class BambuMQTTClient:
                                 # Fields that should always be updated (even with empty/zero values):
                                 # Fields that should always be updated (even with empty/zero values):
                                 # - remain, k, id, cali_idx: status indicators where 0 is valid
                                 # - remain, k, id, cali_idx: status indicators where 0 is valid
                                 # - tray_type, tray_sub_brands, tag_uid, tray_uuid, tray_info_idx,
                                 # - tray_type, tray_sub_brands, tag_uid, tray_uuid, tray_info_idx,
-                                #   tray_color, tray_id_name: slot content indicators that must be
-                                #   cleared when a spool is removed (fixes #147 - old AMS empty slot)
+                                #   tray_color, tray_id_name: slot content indicators that must
+                                #   be cleared when a spool is removed (fixes #147 - old AMS
+                                #   empty slot)
                                 always_update_fields = (
                                 always_update_fields = (
                                     "remain",
                                     "remain",
                                     "k",
                                     "k",

+ 82 - 26
backend/app/services/spoolman.py

@@ -468,6 +468,26 @@ class SpoolmanClient:
                         return spool
                         return spool
         return None
         return None
 
 
+    def _find_spool_by_location(self, location: str, cached_spools: list[dict] | None) -> dict | None:
+        """Find a spool by exact location match.
+
+        Used as fallback when RFID tag data is unavailable (e.g., newer firmware
+        that doesn't expose tray_uuid/tag_uid via MQTT).
+
+        Args:
+            location: Exact location string (e.g., "H2D-1 - AMS A1")
+            cached_spools: Pre-fetched list of spools to search
+
+        Returns:
+            Spool dictionary or None if not found.
+        """
+        if not cached_spools:
+            return None
+        for spool in cached_spools:
+            if spool.get("location") == location:
+                return spool
+        return None
+
     async def find_spools_by_location_prefix(
     async def find_spools_by_location_prefix(
         self, location_prefix: str, cached_spools: list[dict] | None = None
         self, location_prefix: str, cached_spools: list[dict] | None = None
     ) -> list[dict]:
     ) -> list[dict]:
@@ -494,17 +514,21 @@ class SpoolmanClient:
         printer_name: str,
         printer_name: str,
         current_tray_uuids: set[str],
         current_tray_uuids: set[str],
         cached_spools: list[dict] | None = None,
         cached_spools: list[dict] | None = None,
+        synced_spool_ids: set[int] | None = None,
     ) -> int:
     ) -> int:
         """Clear location for spools that are no longer in the AMS.
         """Clear location for spools that are no longer in the AMS.
 
 
         When a spool is removed from the AMS, its location should be cleared
         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
         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.
+        and clears the location for any that are not in the current_tray_uuids set
+        and were not synced in this cycle (synced_spool_ids).
 
 
         Args:
         Args:
             printer_name: The printer name used as location prefix
             printer_name: The printer name used as location prefix
             current_tray_uuids: Set of tray_uuids currently in the AMS
             current_tray_uuids: Set of tray_uuids currently in the AMS
             cached_spools: Optional pre-fetched list of spools to search (avoids API call)
             cached_spools: Optional pre-fetched list of spools to search (avoids API call)
+            synced_spool_ids: Set of spool IDs that were synced in this cycle
+                (protects location-matched spools when RFID data is unavailable)
 
 
         Returns:
         Returns:
             Number of spools whose location was cleared.
             Number of spools whose location was cleared.
@@ -514,6 +538,12 @@ class SpoolmanClient:
         cleared_count = 0
         cleared_count = 0
 
 
         for spool in spools_at_printer:
         for spool in spools_at_printer:
+            spool_id = spool.get("id")
+
+            # Skip spools that were just synced (matched by location or tag)
+            if synced_spool_ids and spool_id in synced_spool_ids:
+                continue
+
             # Get the tray_uuid (stored as "tag" in extra field)
             # Get the tray_uuid (stored as "tag" in extra field)
             extra = spool.get("extra", {}) or {}
             extra = spool.get("extra", {}) or {}
             stored_tag = extra.get("tag", "")
             stored_tag = extra.get("tag", "")
@@ -526,10 +556,10 @@ class SpoolmanClient:
             # If this spool's UUID is not in the current AMS, clear its location
             # If this spool's UUID is not in the current AMS, clear its location
             if spool_uuid not in current_tray_uuids:
             if spool_uuid not in current_tray_uuids:
                 logger.info(
                 logger.info(
-                    f"Clearing location for spool {spool['id']} "
+                    f"Clearing location for spool {spool_id} "
                     f"(was: {spool.get('location')}, uuid: {spool_uuid[:16] if spool_uuid else 'none'}...)"
                     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)
+                result = await self.update_spool(spool_id=spool_id, clear_location=True)
                 if result:
                 if result:
                     cleared_count += 1
                     cleared_count += 1
 
 
@@ -774,48 +804,74 @@ class SpoolmanClient:
             tray.tray_uuid if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000" else tray.tag_uid
             tray.tray_uuid if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000" else tray.tag_uid
         )
         )
 
 
-        # If no unique identifier available, we can't sync even if it's a Bambu Lab spool
-        if not spool_tag:
-            logger.warning(
-                f"Bambu Lab spool detected but no unique identifier for Spoolman: "
-                f"{printer_name} AMS {tray.ams_id} tray {tray.tray_id} (tray_info_idx={tray.tray_info_idx})"
-            )
-            return None
-
-        # Calculate remaining weight
-        remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
+        # Calculate remaining weight (skip if data is invalid/unavailable)
+        # Some firmware sends remain=-1 (→0 after max) and tray_weight=0, making weight unreliable
+        remaining = (
+            self.calculate_remaining_weight(tray.remain, tray.tray_weight)
+            if tray.remain > 0 and tray.tray_weight > 0
+            else None
+        )
         location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
         location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
 
 
-        # Find existing spool by tag (tray_uuid or tag_uid, stored as "tag" in Spoolman)
-        existing = await self.find_spool_by_tag(spool_tag, cached_spools=cached_spools)
+        if spool_tag:
+            # Primary path: match by RFID tag
+            existing = await self.find_spool_by_tag(spool_tag, cached_spools=cached_spools)
+            if existing:
+                logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])
+                return await self.update_spool(
+                    spool_id=existing["id"],
+                    remaining_weight=None if disable_weight_sync else remaining,
+                    location=location,
+                )
+
+            # Spool not found by tag - auto-create it
+            logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
+            filament = await self._find_or_create_filament(tray)
+            if not filament:
+                logger.error("Failed to find or create filament for %s", tray.tray_sub_brands)
+                return None
+
+            import json
+
+            return await self.create_spool(
+                filament_id=filament["id"],
+                remaining_weight=remaining,
+                location=location,
+                comment="Created by Bambuddy",
+                extra={"tag": json.dumps(spool_tag)},
+            )
+
+        # Fallback path: no RFID tag available (newer firmware may not expose UUIDs)
+        # Match existing Spoolman spools by their location (AMS slot position)
+        existing = self._find_spool_by_location(location, cached_spools)
         if existing:
         if existing:
-            # Update existing spool
-            logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])
+            logger.info(
+                "Updating spool %s by location match '%s' (no RFID tag available)",
+                existing["id"],
+                location,
+            )
             return await self.update_spool(
             return await self.update_spool(
                 spool_id=existing["id"],
                 spool_id=existing["id"],
                 remaining_weight=None if disable_weight_sync else remaining,
                 remaining_weight=None if disable_weight_sync else remaining,
                 location=location,
                 location=location,
             )
             )
 
 
-        # Spool not found - auto-create it
-        logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
-
-        # First find or create the filament type
+        # No existing spool at this location — create a new one without a tag
+        logger.info(
+            "Creating new spool in Spoolman for %s at %s (no RFID tag available)",
+            tray.tray_sub_brands,
+            location,
+        )
         filament = await self._find_or_create_filament(tray)
         filament = await self._find_or_create_filament(tray)
         if not filament:
         if not filament:
             logger.error("Failed to find or create filament for %s", tray.tray_sub_brands)
             logger.error("Failed to find or create filament for %s", tray.tray_sub_brands)
             return None
             return None
 
 
-        # Create the spool with identifier 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(
         return await self.create_spool(
             filament_id=filament["id"],
             filament_id=filament["id"],
             remaining_weight=remaining,
             remaining_weight=remaining,
             location=location,
             location=location,
             comment="Created by Bambuddy",
             comment="Created by Bambuddy",
-            extra={"tag": json.dumps(spool_tag)},
         )
         )
 
 
     async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
     async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-C8xaQF5N.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CDlz__Os.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CK9fZPad.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CxS9CTuG.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DMk3iz3Q.css


+ 2 - 2
static/index.html

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

Некоторые файлы не были показаны из-за большого количества измененных файлов