From e3f3c90c3d7f648d29295a816a59b67018c8cced Mon Sep 17 00:00:00 2001 From: maziggy Date: Fri, 13 Feb 2026 07:53:14 +0100 Subject: [PATCH] Sync --- backend/app/api/routes/spoolman.py | 36 ++++++++++++++++++ backend/app/main.py | 22 +++++++++++ backend/app/services/spoolman.py | 60 ++++++++++++++++-------------- 3 files changed, 91 insertions(+), 27 deletions(-) diff --git a/backend/app/api/routes/spoolman.py b/backend/app/api/routes/spoolman.py index 25948413b..0a59ea53b 100644 --- a/backend/app/api/routes/spoolman.py +++ b/backend/app/api/routes/spoolman.py @@ -6,12 +6,14 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from backend.app.core.auth import RequirePermissionIfAuthEnabled from backend.app.core.database import get_db from backend.app.core.permissions import Permission from backend.app.models.printer import Printer from backend.app.models.settings import Settings +from backend.app.models.spool_assignment import SpoolAssignment from backend.app.models.user import User from backend.app.services.printer_manager import printer_manager from backend.app.services.spoolman import ( @@ -230,6 +232,22 @@ async def sync_printer_ams( detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}", ) + # Load inventory weights as fallback (when AMS MQTT data lacks remain values) + inv_weights: dict[tuple[int, int], float] = {} + try: + assign_result = await db.execute( + select(SpoolAssignment) + .options(selectinload(SpoolAssignment.spool)) + .where(SpoolAssignment.printer_id == printer_id) + ) + for assignment in assign_result.scalars().all(): + spool = assignment.spool + if spool and spool.label_weight > 0: + remaining = max(0.0, spool.label_weight - (spool.weight_used or 0)) + inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining + except Exception as e: + logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e) + for ams_unit in ams_units: if not isinstance(ams_unit, dict): continue @@ -270,11 +288,13 @@ async def sync_printer_ams( current_tray_uuids.add(spool_tag.upper()) try: + inv_remaining = inv_weights.get((ams_id, tray.tray_id)) sync_result = await client.sync_ams_tray( tray, printer.name, disable_weight_sync=disable_weight_sync, cached_spools=cached_spools, + inventory_remaining=inv_remaining, ) if sync_result: synced += 1 @@ -361,6 +381,19 @@ async def sync_all_printers( detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}", ) + # Load inventory assignments for weight fallback (when AMS MQTT data lacks remain values) + # Key: (printer_id, ams_id, tray_id) → remaining_weight in grams + inventory_weights: dict[tuple[int, int, int], float] = {} + try: + assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool))) + for assignment in assign_result.scalars().all(): + spool = assignment.spool + if spool and spool.label_weight > 0: + remaining = max(0.0, spool.label_weight - (spool.weight_used or 0)) + inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining + except Exception as e: + logger.debug("Could not load inventory assignments for weight fallback: %s", e) + for printer in printers: state = printer_manager.get_status(printer.id) if not state or not state.raw_data: @@ -435,11 +468,14 @@ async def sync_all_printers( printer_tray_uuids[printer.name].add(spool_tag.upper()) try: + # Look up inventory weight as fallback when AMS data is invalid + inv_remaining = inventory_weights.get((printer.id, ams_id, tray.tray_id)) sync_result = await client.sync_ams_tray( tray, printer.name, disable_weight_sync=disable_weight_sync, cached_spools=cached_spools, + inventory_remaining=inv_remaining, ) if sync_result: total_synced += 1 diff --git a/backend/app/main.py b/backend/app/main.py index 276797b8f..7231cf34a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -771,6 +771,26 @@ async def on_ams_change(printer_id: int, ams_data: list): ) return + # Load inventory weights as fallback (when AMS MQTT data lacks remain values) + from sqlalchemy.orm import selectinload + + from backend.app.models.spool_assignment import SpoolAssignment + + inventory_weights: dict[tuple[int, int], float] = {} + try: + assign_result = await db.execute( + select(SpoolAssignment) + .options(selectinload(SpoolAssignment.spool)) + .where(SpoolAssignment.printer_id == printer_id) + ) + for assignment in assign_result.scalars().all(): + spool = assignment.spool + if spool and spool.label_weight > 0: + remaining = max(0.0, spool.label_weight - (spool.weight_used or 0)) + inventory_weights[(assignment.ams_id, assignment.tray_id)] = remaining + except Exception as e: + logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e) + # Sync each AMS tray synced = 0 for ams_unit in ams_data: @@ -783,11 +803,13 @@ async def on_ams_change(printer_id: int, ams_data: list): continue # Empty tray try: + inv_remaining = inventory_weights.get((ams_id, tray.tray_id)) result = await client.sync_ams_tray( tray, printer_name, disable_weight_sync=disable_weight_sync, cached_spools=cached_spools, + inventory_remaining=inv_remaining, ) if result: synced += 1 diff --git a/backend/app/services/spoolman.py b/backend/app/services/spoolman.py index 9ce78c6bf..b7f54a8bf 100644 --- a/backend/app/services/spoolman.py +++ b/backend/app/services/spoolman.py @@ -658,8 +658,8 @@ class SpoolmanClient: # Get tray_info_idx (Bambu filament preset ID like "GFA00") tray_info_idx = tray_data.get("tray_info_idx", "") or "" - # Get remaining percentage, ensure non-negative - remain = max(0, int(tray_data.get("remain", 0))) + # Get remaining percentage (-1 means unknown/not read by AMS) + remain = int(tray_data.get("remain", -1)) return AMSTray( ams_id=ams_id, @@ -760,6 +760,7 @@ class SpoolmanClient: printer_name: str, disable_weight_sync: bool = False, cached_spools: list[dict] | None = None, + inventory_remaining: float | None = None, ) -> dict | None: """Sync a single AMS tray to Spoolman. @@ -777,6 +778,8 @@ class SpoolmanClient: cached_spools: Optional pre-fetched list of spools to search (avoids API calls). When provided, this cache is passed to find_spool_by_tag to avoid redundant API calls during batch sync operations. + inventory_remaining: Optional fallback remaining weight (grams) from the built-in + inventory when AMS MQTT data has invalid remain/tray_weight values. Returns: Synced spool dictionary or None if skipped or failed. @@ -800,17 +803,31 @@ class SpoolmanClient: return None # Determine which identifier to use for Spoolman (prefer tray_uuid, fallback to tag_uid) - spool_tag = ( - tray.tray_uuid if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000" else tray.tag_uid - ) + # Zero-filled values mean the AMS hasn't read the RFID tag — treat as no tag + zero_uuid = "00000000000000000000000000000000" + zero_tag = "0000000000000000" + spool_tag = None + if tray.tray_uuid and tray.tray_uuid != zero_uuid: + spool_tag = tray.tray_uuid + elif tray.tag_uid and tray.tag_uid != zero_tag: + spool_tag = tray.tag_uid - # 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 - ) + # Calculate remaining weight + # Primary: AMS MQTT data (remain percentage + tray_weight) + # Fallback: Built-in inventory tracked weight (when firmware sends invalid remain/tray_weight) + if tray.remain >= 0 and tray.tray_weight > 0: + remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight) + elif inventory_remaining is not None: + remaining = inventory_remaining + logger.debug( + "Using inventory weight fallback for %s AMS %s tray %s: %.1fg", + printer_name, + tray.ams_id, + tray.tray_id, + remaining, + ) + else: + remaining = None location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}" if spool_tag: @@ -842,7 +859,8 @@ class SpoolmanClient: ) # Fallback path: no RFID tag available (newer firmware may not expose UUIDs) - # Match existing Spoolman spools by their location (AMS slot position) + # Only update existing spools matched by location — never create new ones without a tag + # to avoid duplicates when old spools exist from previous RFID-based syncs existing = self._find_spool_by_location(location, cached_spools) if existing: logger.info( @@ -856,23 +874,11 @@ class SpoolmanClient: location=location, ) - # 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, + "No existing spool found at '%s' — skipping (no RFID tag to create with)", location, ) - 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 - - return await self.create_spool( - filament_id=filament["id"], - remaining_weight=remaining, - location=location, - comment="Created by Bambuddy", - ) + return None async def _find_or_create_filament(self, tray: AMSTray) -> dict | None: """Find existing filament or create new one.