mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 05:01:37 +02:00
Spoolman rejects a spool whose extra dict carries a key it has not been told about, answering 400 "Unknown extra field tag.". Bambuddy keeps the tray UUID in extra.tag, so that key has to exist before the first spool is created. Registration ran from three hand-maintained lists that fire when the integration is set up -- the connect route, startup, and two inline blocks in the inventory routes. Enabling Spoolman from the Settings page reaches none of them, so the first AMS sync on a fresh Spoolman failed on every slot while vendor and filament creation succeeded. Neither fix suggested on the issue is quite the right shape. Adding the block to PUT /settings/spoolman fixes this path and makes a third copy of a list that has already drifted -- it would still omit bambu_color_name. Ensuring at the sync entry point leaves the other four tag writers alone: linking and unlinking a tag, and both inventory edit paths. So the registration moved to the write. create_spool, update_spool and update_spool_full each register the keys of the extra dict they are about to send, once per client, before sending. Every tag writer funnels through one of the three, merge_spool_extra included. This closes the class rather than the instance: a write that carries a key is a write that registers it, and bambu_color_name shows why that matters -- it never made it into the connect or startup lists at all, and works today only because two call sites remembered it by hand. Best-effort, deliberately. ensure_extra_field already logs and returns False rather than raising, so a registration that fails leaves the write to be attempted and to report exactly what it reported before. Failures are not memoised either, so a Spoolman that was merely restarting gets another try on the next write. The older blocks stay. They are redundant now, but the inventory routes' inline calls are pinned by tests that assert them against a mocked client, where the funnel cannot run. The status endpoint is the other half. The Connect button would have registered the fields, and the reason nobody reaches it is that GET /spoolman/status reported "connected" whenever an earlier request had left a client object behind. Roughly twenty call sites build one lazily, and saving the Settings page builds one as a side effect of syncing locations, so the flag turned on which page had been opened rather than on anything about Spoolman. The UI reads it twice -- Connect only while disconnected, the sync section only while connected -- so those two controls landed in states the user cannot explain. It now asks the Spoolman that is configured, including the stale-URL check every other route already does, and does not probe at all when the integration is switched off, which used to let a leftover client report a disabled Spoolman as connected. That leaves nothing for Disconnect to do, so it is gone. Spoolman is a stateless HTTP API with no session to close; the button dropped the client object, the next request rebuilt it lazily, and the status flipped back on its own within the 30s poll -- an action that looked like it worked and then quietly undid itself. The enable toggle owns turning the integration off. Connect stays as what it always was in practice, a way to re-check a Spoolman that is not answering, and is shown only then. Its translation keys are left in place; only the control is removed. Resolving the client there means the status poll can now fail in ways a read-only check could not, so it no longer reports failure by failing. Replacing a client closes the previous one and httpx's aclose() is not guaranteed not to raise; a poll that runs every 30 seconds answering 500 is worse than one answering what is true either way, which is that Spoolman could not be reached. The SSRF rejection keeps its own message rather than folding into the general one -- a URL the guard refuses is the admin's to correct, and that is only actionable if the log says so. Tests drive the real client against a fake Spoolman that enforces the unknown-extra-field rule rather than mocking it away, so each one fails against the old code for the reason the reporter's install did. The concurrency test needed the fake to be async: MockTransport answers without suspending, so the first version ran each request to completion in turn and passed just as happily with the lock removed.
1345 lines
57 KiB
Python
1345 lines
57 KiB
Python
"""Spoolman integration service for syncing AMS filament data."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import weakref
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Literal
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
BAMBU_RFID_TAG_LENGTH = 32
|
|
|
|
|
|
@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 AMS-HT, 254+ 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_info_idx: str # Bambu filament preset ID like "GFA00"
|
|
tray_weight: int # Spool weight in grams (usually 1000)
|
|
|
|
|
|
class SpoolmanNotFoundError(Exception):
|
|
"""Raised when a spool ID does not exist in Spoolman (HTTP 404)."""
|
|
|
|
|
|
class SpoolmanUnavailableError(Exception):
|
|
"""Raised when Spoolman is unreachable or returns a server/network error."""
|
|
|
|
|
|
class SpoolmanClientError(Exception):
|
|
"""Raised when Spoolman returns a 4xx client error (not 404)."""
|
|
|
|
def __init__(self, message: str, status_code: int, response_text: str = "") -> None:
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
self.response_text = response_text
|
|
|
|
|
|
def _filament_subtype_part(name: str, material: str) -> str:
|
|
"""Return the subtype portion of a filament name, lowercased.
|
|
|
|
Mirrors the read-side derivation in
|
|
``backend/app/api/routes/_spoolman_helpers.py::_map_spoolman_spool``:
|
|
if the filament name starts with the material prefix (e.g. ``"PLA Glow"``
|
|
when material is ``"PLA"``), strip it; otherwise return the name as-is.
|
|
|
|
Used by ``find_or_create_filament`` so that an existing filament saved by
|
|
the AMS-sync path with name ``"Glow"`` still matches a user-driven edit
|
|
that composes ``"PLA Glow"`` (#1357).
|
|
"""
|
|
s = (name or "").strip()
|
|
m = (material or "").strip()
|
|
if m and s.upper().startswith(m.upper() + " "):
|
|
return s[len(m) + 1 :].strip().lower()
|
|
return s.lower()
|
|
|
|
|
|
class SpoolmanClient:
|
|
"""Client for interacting with Spoolman API."""
|
|
|
|
def __init__(self, base_url: str):
|
|
"""Initialize the Spoolman client."""
|
|
self.base_url = base_url.rstrip("/")
|
|
self.api_url = f"{self.base_url}/api/v1"
|
|
self._client: httpx.AsyncClient | None = None
|
|
self._connected = False
|
|
# Per-spool locks for atomic read-modify-write in merge_spool_extra.
|
|
# WeakValueDictionary: locks are GC'd once no coroutine holds a reference.
|
|
self._extra_locks: weakref.WeakValueDictionary[int, asyncio.Lock] = weakref.WeakValueDictionary()
|
|
# Extra-field names this client has already registered with Spoolman.
|
|
# Bounded by the number of distinct keys Bambuddy writes, so it never
|
|
# grows with spool count; scoped to the instance so a client pointed at
|
|
# a different Spoolman starts over.
|
|
self._ensured_extra_fields: set[str] = set()
|
|
self._ensure_extra_lock = asyncio.Lock()
|
|
|
|
async def _get_client(self) -> httpx.AsyncClient:
|
|
"""Get or create the HTTP client with connection pooling limits."""
|
|
if self._client is None:
|
|
self._client = httpx.AsyncClient(
|
|
timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0),
|
|
follow_redirects=False,
|
|
verify=True,
|
|
limits=httpx.Limits(
|
|
max_keepalive_connections=5,
|
|
max_connections=10,
|
|
keepalive_expiry=30.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 healthy."""
|
|
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(
|
|
"Spoolman health check failed (url=%s, type=%s): %s",
|
|
self.api_url,
|
|
type(e).__name__,
|
|
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]:
|
|
"""Fetch all spools from Spoolman with up to 3 retries on connection errors."""
|
|
max_attempts = 3
|
|
retry_delay = 0.5 # 500ms
|
|
|
|
for attempt in range(1, max_attempts + 1):
|
|
try:
|
|
client = await self._get_client()
|
|
response = await client.get(f"{self.api_url}/spool")
|
|
response.raise_for_status()
|
|
spools = response.json()
|
|
if attempt > 1:
|
|
logger.info("Successfully fetched %d spools on attempt %d", len(spools), attempt)
|
|
return spools
|
|
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ConnectError) as e:
|
|
# Connection-related errors - close and recreate client for next attempt
|
|
if attempt < max_attempts:
|
|
logger.warning(
|
|
"Connection error getting spools (attempt %d/%d): %s. Recreating client and retrying in %dms...",
|
|
attempt,
|
|
max_attempts,
|
|
e,
|
|
int(retry_delay * 1000),
|
|
)
|
|
# Close the stale client and recreate it
|
|
await self.close()
|
|
await asyncio.sleep(retry_delay)
|
|
else:
|
|
logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
|
|
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
|
|
except Exception as e:
|
|
# Other errors (HTTP errors, JSON decode errors, etc.)
|
|
if attempt < max_attempts:
|
|
logger.warning(
|
|
"Failed to get spools from Spoolman (attempt %d/%d): %s. Retrying in %dms...",
|
|
attempt,
|
|
max_attempts,
|
|
e,
|
|
int(retry_delay * 1000),
|
|
)
|
|
await asyncio.sleep(retry_delay)
|
|
else:
|
|
logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
|
|
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
|
|
|
|
async def _get_with_retry(self, path: str, params: dict | None = None) -> list[dict]:
|
|
"""GET a Spoolman JSON list endpoint with up to 3 retries on connection errors."""
|
|
max_attempts = 3
|
|
retry_delay = 0.5
|
|
url = f"{self.api_url}/{path.lstrip('/')}"
|
|
|
|
for attempt in range(1, max_attempts + 1):
|
|
try:
|
|
client = await self._get_client()
|
|
response = await client.get(url, params=params or None)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ConnectError) as e:
|
|
if attempt < max_attempts:
|
|
logger.warning(
|
|
"Connection error fetching %s (attempt %d/%d): %s. Recreating client and retrying in %dms...",
|
|
path,
|
|
attempt,
|
|
max_attempts,
|
|
e,
|
|
int(retry_delay * 1000),
|
|
)
|
|
await self.close()
|
|
await asyncio.sleep(retry_delay)
|
|
else:
|
|
logger.error("Failed to fetch %s from Spoolman after %d attempts: %s", path, max_attempts, e)
|
|
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
|
|
except Exception as e:
|
|
if attempt < max_attempts:
|
|
logger.warning(
|
|
"Failed to fetch %s from Spoolman (attempt %d/%d): %s. Retrying in %dms...",
|
|
path,
|
|
attempt,
|
|
max_attempts,
|
|
e,
|
|
int(retry_delay * 1000),
|
|
)
|
|
await asyncio.sleep(retry_delay)
|
|
else:
|
|
logger.error("Failed to fetch %s from Spoolman after %d attempts: %s", path, max_attempts, e)
|
|
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
|
|
|
|
async def get_filaments(self) -> list[dict]:
|
|
"""Fetch all internal filaments from Spoolman."""
|
|
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("Failed to get filaments from Spoolman: %s", e)
|
|
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
|
|
|
|
async def get_filament(self, filament_id: int) -> dict:
|
|
"""Fetch a single filament by ID from Spoolman."""
|
|
if filament_id <= 0:
|
|
raise ValueError(f"Invalid filament_id: {filament_id}")
|
|
response = await self._request_filament("GET", filament_id, operation="get_filament")
|
|
return response.json()
|
|
|
|
async def get_external_filaments(self) -> list[dict]:
|
|
"""Fetch external/library filaments from Spoolman."""
|
|
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("Failed to get external filaments from Spoolman: %s", e)
|
|
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
|
|
|
|
async def get_vendors(self) -> list[dict]:
|
|
"""Fetch all vendors from Spoolman."""
|
|
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("Failed to get vendors from Spoolman: %s", e)
|
|
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
|
|
|
|
async def create_vendor(self, name: str) -> dict:
|
|
"""Create a new vendor in Spoolman."""
|
|
try:
|
|
client = await self._get_client()
|
|
response = await client.post(f"{self.api_url}/vendor", json={"name": name})
|
|
if 400 <= response.status_code < 500:
|
|
raise SpoolmanClientError(
|
|
f"Spoolman rejected vendor creation (HTTP {response.status_code})",
|
|
response.status_code,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except SpoolmanClientError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to create vendor in Spoolman: %s", e)
|
|
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
|
|
|
|
def _get_material_density(self, material: str | None) -> float:
|
|
"""Return typical density (g/cm³) for the given filament material; defaults to PLA (1.24)."""
|
|
# 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,
|
|
color_name: str | None = None,
|
|
weight: float | None = None,
|
|
diameter: float = 1.75,
|
|
density: float | None = None,
|
|
) -> dict:
|
|
"""Create a new filament in Spoolman."""
|
|
if not name or not name.strip():
|
|
raise ValueError("Filament name is required")
|
|
|
|
if density is None:
|
|
density = self._get_material_density(material)
|
|
|
|
data: dict = {
|
|
"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 color_name:
|
|
data["color_name"] = color_name
|
|
if weight:
|
|
data["weight"] = weight
|
|
|
|
logger.debug("Creating filament in Spoolman: %s", data)
|
|
try:
|
|
client = await self._get_client()
|
|
response = await client.post(f"{self.api_url}/filament", json=data)
|
|
if 400 <= response.status_code < 500:
|
|
raise SpoolmanClientError(
|
|
f"Spoolman rejected filament creation (HTTP {response.status_code})",
|
|
response.status_code,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except SpoolmanClientError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to create filament in Spoolman: %s", e)
|
|
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
|
|
|
|
async def patch_filament(self, filament_id: int, data: dict) -> dict:
|
|
"""PATCH a filament entry in Spoolman (e.g. update name or spool_weight)."""
|
|
if filament_id <= 0:
|
|
raise ValueError(f"Invalid filament_id: {filament_id}")
|
|
response = await self._request_filament("PATCH", filament_id, json_body=data, operation="patch_filament")
|
|
return response.json()
|
|
|
|
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:
|
|
"""Create a new spool in Spoolman."""
|
|
data: dict = {"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
|
|
await self._ensure_extra_fields(extra)
|
|
|
|
logger.debug("Creating spool in Spoolman: %s", data)
|
|
try:
|
|
client = await self._get_client()
|
|
response = await client.post(f"{self.api_url}/spool", json=data)
|
|
if response.status_code == 404:
|
|
raise SpoolmanNotFoundError(f"Filament {filament_id} not found in Spoolman")
|
|
if 400 <= response.status_code < 500:
|
|
raise SpoolmanClientError(
|
|
f"Spoolman rejected spool creation (HTTP {response.status_code})",
|
|
response.status_code,
|
|
)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
logger.info("Created spool %s in Spoolman", result.get("id"))
|
|
return result
|
|
except (SpoolmanNotFoundError, SpoolmanClientError):
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to create spool in Spoolman: %s", e)
|
|
raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
|
|
|
|
async def update_spool(
|
|
self,
|
|
spool_id: int,
|
|
remaining_weight: float | None = None,
|
|
location: str | None = None,
|
|
clear_location: bool = False,
|
|
extra: dict | None = None,
|
|
) -> dict:
|
|
"""Update an existing spool in Spoolman, always setting last_used."""
|
|
data: dict = {}
|
|
if remaining_weight is not None:
|
|
data["remaining_weight"] = remaining_weight
|
|
if clear_location:
|
|
data["location"] = None
|
|
elif location:
|
|
data["location"] = location
|
|
if extra:
|
|
data["extra"] = extra
|
|
await self._ensure_extra_fields(extra)
|
|
data["last_used"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
response = await self._request_spool("PATCH", spool_id, json_body=data, operation="update")
|
|
return response.json()
|
|
|
|
async def _request_spool(
|
|
self,
|
|
method: Literal["GET", "PATCH", "DELETE"],
|
|
spool_id: int,
|
|
*,
|
|
json_body: dict | None = None,
|
|
operation: str,
|
|
) -> httpx.Response:
|
|
"""Perform a spool-scoped HTTP request, translating 404 and errors to named exceptions."""
|
|
try:
|
|
client = await self._get_client()
|
|
response = await client.request(
|
|
method,
|
|
f"{self.api_url}/spool/{spool_id}",
|
|
json=json_body,
|
|
)
|
|
if response.status_code == 404:
|
|
raise SpoolmanNotFoundError(f"Spool {spool_id} not found in Spoolman")
|
|
response.raise_for_status()
|
|
return response
|
|
except SpoolmanNotFoundError:
|
|
raise
|
|
except httpx.HTTPStatusError as e:
|
|
if 400 <= e.response.status_code < 500:
|
|
logger.warning(
|
|
"Spoolman returned %d for %s spool %s",
|
|
e.response.status_code,
|
|
operation,
|
|
spool_id,
|
|
)
|
|
raise SpoolmanClientError(
|
|
f"Spoolman rejected {operation} for spool {spool_id} (HTTP {e.response.status_code})",
|
|
e.response.status_code,
|
|
e.response.text[:500],
|
|
) from e
|
|
else:
|
|
logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e)
|
|
raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e
|
|
except Exception as e:
|
|
logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e)
|
|
raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e
|
|
|
|
async def _request_filament(
|
|
self,
|
|
method: Literal["GET", "PATCH"],
|
|
filament_id: int,
|
|
*,
|
|
json_body: dict | None = None,
|
|
operation: str,
|
|
) -> httpx.Response:
|
|
"""Perform a filament-scoped HTTP request, translating 404 and errors to named exceptions."""
|
|
try:
|
|
client = await self._get_client()
|
|
response = await client.request(
|
|
method,
|
|
f"{self.api_url}/filament/{filament_id}",
|
|
json=json_body,
|
|
)
|
|
if response.status_code == 404:
|
|
raise SpoolmanNotFoundError(f"Filament {filament_id} not found in Spoolman")
|
|
response.raise_for_status()
|
|
return response
|
|
except SpoolmanNotFoundError:
|
|
raise
|
|
except httpx.HTTPStatusError as e:
|
|
if 400 <= e.response.status_code < 500:
|
|
logger.warning(
|
|
"Spoolman returned %d for %s filament %s",
|
|
e.response.status_code,
|
|
operation,
|
|
filament_id,
|
|
)
|
|
raise SpoolmanClientError(
|
|
f"Spoolman rejected {operation} for filament {filament_id} (HTTP {e.response.status_code})",
|
|
e.response.status_code,
|
|
e.response.text[:500],
|
|
) from e
|
|
else:
|
|
logger.error("Failed to %s filament %s in Spoolman: %s", operation, filament_id, e)
|
|
raise SpoolmanUnavailableError(f"Failed to {operation} filament {filament_id}") from e
|
|
except Exception as e:
|
|
logger.error("Failed to %s filament %s in Spoolman: %s", operation, filament_id, e)
|
|
raise SpoolmanUnavailableError(f"Failed to {operation} filament {filament_id}") from e
|
|
|
|
async def get_spool(self, spool_id: int) -> dict:
|
|
"""Fetch a single spool by ID from Spoolman."""
|
|
response = await self._request_spool("GET", spool_id, operation="get")
|
|
return response.json()
|
|
|
|
async def get_all_spools(self, allow_archived: bool = False) -> list[dict]:
|
|
"""Fetch all spools from Spoolman with retry, optionally including archived ones."""
|
|
params: dict = {}
|
|
if allow_archived:
|
|
params["allow_archived"] = "true"
|
|
return await self._get_with_retry("/spool", params=params or None)
|
|
|
|
async def get_distinct_locations(self) -> list[str]:
|
|
"""Return distinct location strings currently assigned to Spoolman spools.
|
|
|
|
Spoolman's `/location` endpoint shape varies across versions: older
|
|
releases return `list[str]`, newer ones return `list[dict]` with a
|
|
`name` field. Normalize to `list[str]` so callers can iterate without
|
|
runtime shape checks.
|
|
"""
|
|
raw = await self._get_with_retry("/location")
|
|
if not isinstance(raw, list):
|
|
return []
|
|
names: list[str] = []
|
|
for entry in raw:
|
|
if isinstance(entry, str):
|
|
names.append(entry)
|
|
elif isinstance(entry, dict):
|
|
name = entry.get("name")
|
|
if isinstance(name, str):
|
|
names.append(name)
|
|
return names
|
|
|
|
async def rename_location(self, current_name: str, new_name: str) -> int:
|
|
"""Bulk-rename a location string on all Spoolman spools.
|
|
|
|
Tries the bulk `PATCH /location/{name}` endpoint first. Spoolman
|
|
versions older than ~0.16 don't expose it and respond 404/405 — in
|
|
that case fall back to iterating every spool currently at
|
|
``current_name`` and PATCHing each one's ``location`` field directly.
|
|
Returns the number of spools renamed (or 0 if the bulk endpoint
|
|
succeeded without enumerating).
|
|
"""
|
|
from urllib.parse import quote
|
|
|
|
encoded = quote(current_name, safe="")
|
|
client = await self._get_client()
|
|
try:
|
|
response = await client.patch(
|
|
f"{self.api_url}/location/{encoded}",
|
|
json={"name": new_name},
|
|
)
|
|
response.raise_for_status()
|
|
return 0
|
|
except httpx.HTTPStatusError as exc:
|
|
if exc.response.status_code not in (404, 405):
|
|
raise
|
|
logger.info(
|
|
"Spoolman bulk-rename endpoint unavailable (status %d); falling back to per-spool PATCH",
|
|
exc.response.status_code,
|
|
)
|
|
|
|
# Per-spool fallback: enumerate every spool currently at the old name
|
|
# and PATCH each. Keep going on individual failures so a single
|
|
# already-deleted spool doesn't strand the rest at the old name —
|
|
# collect errors and re-raise as a single SpoolmanClientError if any
|
|
# leftover survives.
|
|
spools = await self.get_all_spools(allow_archived=True)
|
|
renamed = 0
|
|
failures: list[str] = []
|
|
for spool in spools:
|
|
if (spool.get("location") or "").strip() != current_name:
|
|
continue
|
|
try:
|
|
await self._request_spool(
|
|
"PATCH",
|
|
spool["id"],
|
|
json_body={"location": new_name},
|
|
operation="rename-location",
|
|
)
|
|
renamed += 1
|
|
except SpoolmanNotFoundError:
|
|
continue
|
|
except Exception as exc: # noqa: BLE001 — accumulate and re-raise below
|
|
failures.append(f"spool {spool.get('id')}: {exc}")
|
|
if failures:
|
|
raise SpoolmanClientError(
|
|
f"Spoolman rename fallback failed for {len(failures)} spool(s): {'; '.join(failures[:3])}",
|
|
status_code=502,
|
|
)
|
|
return renamed
|
|
|
|
async def delete_spool(self, spool_id: int) -> None:
|
|
"""Delete a spool from Spoolman."""
|
|
await self._request_spool("DELETE", spool_id, operation="delete")
|
|
|
|
async def is_filament_shared(self, filament_id: int, exclude_spool_id: int) -> bool:
|
|
"""True if any spool other than ``exclude_spool_id`` is linked to ``filament_id``.
|
|
|
|
Used by the spool-edit path to decide between PATCHing the existing
|
|
filament in place (singleton) and falling back to find_or_create
|
|
(shared — re-linking the spool is the only safe option). Includes
|
|
archived spools so a shared link doesn't suddenly look singleton just
|
|
because the sibling spool was archived.
|
|
"""
|
|
spools = await self.get_all_spools(allow_archived=True)
|
|
for s in spools:
|
|
if s.get("id") == exclude_spool_id:
|
|
continue
|
|
if ((s.get("filament") or {}).get("id")) == filament_id:
|
|
return True
|
|
return False
|
|
|
|
async def set_spool_archived(self, spool_id: int, archived: bool) -> dict:
|
|
"""Archive or restore a spool in Spoolman."""
|
|
response = await self._request_spool(
|
|
"PATCH",
|
|
spool_id,
|
|
json_body={"archived": archived},
|
|
operation="archive/restore",
|
|
)
|
|
return response.json()
|
|
|
|
async def reset_spool_usage(self, spool_id: int) -> dict:
|
|
"""Reset a spool's used_weight to 0 in Spoolman.
|
|
|
|
Used by the per-spool / bulk "Reset usage to 0" actions on the
|
|
Inventory page so the Total Consumed stat can be cleared without
|
|
touching the rest of the spool's data.
|
|
"""
|
|
response = await self._request_spool(
|
|
"PATCH",
|
|
spool_id,
|
|
json_body={"used_weight": 0},
|
|
operation="reset-usage",
|
|
)
|
|
return response.json()
|
|
|
|
async def update_spool_full(
|
|
self,
|
|
spool_id: int,
|
|
*,
|
|
filament_id: int | None = None,
|
|
remaining_weight: float | None = None,
|
|
comment: str | None = None,
|
|
price: float | None = None,
|
|
location: str | None = None,
|
|
clear_location: bool = False,
|
|
extra: dict | None = None,
|
|
spool_weight: float | None = None,
|
|
clear_spool_weight: bool = False,
|
|
) -> dict:
|
|
"""Update a spool with full field support; unlike update_spool, does not auto-set last_used."""
|
|
data: dict = {}
|
|
if filament_id is not None:
|
|
data["filament_id"] = filament_id
|
|
if remaining_weight is not None:
|
|
data["remaining_weight"] = remaining_weight
|
|
if comment is not None:
|
|
data["comment"] = comment if comment else None
|
|
if price is not None:
|
|
data["price"] = price
|
|
if clear_location:
|
|
data["location"] = None
|
|
elif location is not None:
|
|
data["location"] = location
|
|
if extra is not None:
|
|
data["extra"] = extra
|
|
await self._ensure_extra_fields(extra)
|
|
if clear_spool_weight:
|
|
data["spool_weight"] = None
|
|
elif spool_weight is not None:
|
|
data["spool_weight"] = spool_weight
|
|
|
|
response = await self._request_spool("PATCH", spool_id, json_body=data, operation="update")
|
|
return response.json()
|
|
|
|
def extra_lock(self, spool_id: int) -> asyncio.Lock:
|
|
"""Return (creating if needed) the per-spool asyncio.Lock used by merge_spool_extra."""
|
|
lock = self._extra_locks.get(spool_id)
|
|
if lock is None:
|
|
lock = asyncio.Lock()
|
|
self._extra_locks[spool_id] = lock
|
|
return lock
|
|
|
|
async def merge_spool_extra(self, spool_id: int, new_fields: dict) -> dict:
|
|
"""Fetch the spool's extra dict, merge new_fields into it, then PATCH back — serialised per spool."""
|
|
async with self.extra_lock(spool_id):
|
|
current = await self.get_spool(spool_id) # raises on error
|
|
current_extra: dict = current.get("extra") or {}
|
|
merged = {**current_extra, **new_fields}
|
|
return await self.update_spool_full(spool_id=spool_id, extra=merged)
|
|
|
|
async def find_or_create_vendor(self, name: str) -> int:
|
|
"""Return the Spoolman vendor ID for the given name, creating the vendor if absent."""
|
|
vendors = await self.get_vendors()
|
|
name_lower = name.strip().lower()
|
|
for vendor in vendors:
|
|
if vendor.get("name", "").strip().lower() == name_lower:
|
|
return vendor["id"]
|
|
created = await self.create_vendor(name.strip())
|
|
vendor_id = created.get("id")
|
|
if not vendor_id:
|
|
raise SpoolmanUnavailableError(f"Spoolman returned vendor without id field: {list(created.keys())}")
|
|
return vendor_id
|
|
|
|
async def find_or_create_filament(
|
|
self,
|
|
material: str,
|
|
subtype: str,
|
|
brand: str | None,
|
|
color_hex: str,
|
|
label_weight: int,
|
|
color_name: str | None = None,
|
|
) -> int:
|
|
"""Return the filament ID matching material/name/brand/color, creating it if absent."""
|
|
name = f"{material} {subtype}".strip() if subtype else material
|
|
color = color_hex[:6].upper() if len(color_hex) >= 6 else color_hex.upper()
|
|
|
|
vendor_id: int | None = None
|
|
if brand:
|
|
vendor_id = await self.find_or_create_vendor(brand)
|
|
|
|
# Normalised match keys (case-insensitive). Computed once outside the
|
|
# loop so the inner comparison stays simple.
|
|
composed_subtype = _filament_subtype_part(name, material)
|
|
material_norm = material.upper()
|
|
brand_norm = (brand or "").strip().lower()
|
|
|
|
filaments = await self.get_filaments()
|
|
for f in filaments:
|
|
f_material = (f.get("material") or "").upper()
|
|
f_color = (f.get("color_hex") or "").upper()[:6]
|
|
f_vendor = f.get("vendor") or {}
|
|
f_vendor_name = (f_vendor.get("name") or "").strip().lower()
|
|
|
|
material_match = f_material == material_norm
|
|
# Match on the subtype portion of the filament name. AMS-sync
|
|
# auto-create (the underscore-prefixed `_find_or_create_filament`
|
|
# used during MQTT tray import) stores the filament as just
|
|
# ``tray.tray_sub_brands`` — e.g. ``"Glow"`` — while the
|
|
# user-driven edit path here composes ``"<material> <subtype>"``
|
|
# — ``"PLA Glow"``. The old literal equality `f_name == name`
|
|
# failed to bridge the two shapes, so every edit fell through to
|
|
# `create_filament`, leaving a trail of duplicate filaments AND
|
|
# leaving the spool either still pointed at the old filament
|
|
# whose `color_name` never got patched, or pointed at a new
|
|
# filament with the colour while the inventory list kept
|
|
# showing the synth fallback from the old one (#1357).
|
|
f_subtype_part = _filament_subtype_part(f.get("name") or "", material)
|
|
name_match = f_subtype_part == composed_subtype
|
|
color_match = f_color == color
|
|
vendor_match = (not brand) or f_vendor_name == brand_norm
|
|
|
|
if material_match and name_match and color_match and vendor_match:
|
|
# color_name is intentionally not part of the match key and
|
|
# is no longer patched onto the filament here: Spoolman 0.23.1
|
|
# has no `color_name` field on Filament (#1357 — confirmed
|
|
# against the FilamentUpdateParameters schema). The earlier
|
|
# #1319 fix tried to patch it and Spoolman silently dropped
|
|
# the key, which is exactly why the user's edit looked "not
|
|
# saved". The route now persists color_name via
|
|
# spool.extra.bambu_color_name (see _map_spoolman_spool for
|
|
# the read side); find_or_create_filament's only job is to
|
|
# resolve the right filament_id for the spool link.
|
|
return f["id"]
|
|
|
|
# color_name omitted: Spoolman has no such field on Filament (#1357);
|
|
# the user's color_name lands in spool.extra.bambu_color_name via the
|
|
# route after find_or_create_filament returns the new id.
|
|
filament = await self.create_filament(
|
|
name=name,
|
|
vendor_id=vendor_id,
|
|
material=material,
|
|
color_hex=color,
|
|
weight=float(label_weight),
|
|
)
|
|
filament_id = filament.get("id")
|
|
if not filament_id:
|
|
raise SpoolmanUnavailableError(f"Spoolman returned filament without id field: {list(filament.keys())}")
|
|
return filament_id
|
|
|
|
async def use_spool(self, spool_id: int, used_weight: float) -> dict:
|
|
"""Record filament usage for a spool via the Spoolman /use endpoint."""
|
|
try:
|
|
client = await self._get_client()
|
|
response = await client.put(
|
|
f"{self.api_url}/spool/{spool_id}/use",
|
|
json={"use_weight": used_weight},
|
|
)
|
|
if response.status_code == 404:
|
|
raise SpoolmanNotFoundError(f"Spool {spool_id} not found in Spoolman")
|
|
if 400 <= response.status_code < 500:
|
|
raise SpoolmanClientError(
|
|
f"Spoolman rejected use_spool for spool {spool_id} (HTTP {response.status_code})",
|
|
response.status_code,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except (SpoolmanNotFoundError, SpoolmanClientError):
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to record spool usage in Spoolman: %s", e)
|
|
raise SpoolmanUnavailableError(f"Failed to record usage for spool {spool_id}") from e
|
|
|
|
async def find_spool_by_tag(self, tag_uid: str, cached_spools: list[dict] | None = None) -> dict | None:
|
|
"""Return the spool matching the given RFID tag UID, or None if not found."""
|
|
# Use cached spools if provided, otherwise fetch from API
|
|
spools = cached_spools if cached_spools is not None else 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("Found spool %s matching tag %s", spool["id"], tag_uid)
|
|
return spool
|
|
return None
|
|
|
|
def _find_spool_by_location(self, location: str, cached_spools: list[dict] | None) -> dict | None:
|
|
"""Return the spool at the exact location string, or None; fallback when RFID is unavailable."""
|
|
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(
|
|
self, location_prefix: str, cached_spools: list[dict] | None = None
|
|
) -> list[dict]:
|
|
"""Return all spools whose location starts with location_prefix."""
|
|
# Use cached spools if provided, otherwise fetch from API
|
|
spools = cached_spools if cached_spools is not None else 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],
|
|
cached_spools: list[dict] | None = None,
|
|
synced_spool_ids: set[int] | None = None,
|
|
) -> int:
|
|
"""Clear location for Bambu Lab spools at this printer whose tray_uuid is no longer in the AMS."""
|
|
location_prefix = f"{printer_name} - "
|
|
spools_at_printer = await self.find_spools_by_location_prefix(location_prefix, cached_spools=cached_spools)
|
|
cleared_count = 0
|
|
|
|
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)
|
|
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 = ""
|
|
|
|
# Only clear location for Bambu Lab spools (those with a stored 32-character RFID tag).
|
|
if len(spool_uuid) != BAMBU_RFID_TAG_LENGTH:
|
|
continue
|
|
|
|
# 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:
|
|
"""Return the Bambu Lab vendor ID in Spoolman, creating the vendor if absent."""
|
|
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
|
|
|
|
async def ensure_tag_extra_field(self) -> bool:
|
|
"""Register the 'tag' extra field in Spoolman if not present; returns True on success."""
|
|
return await self.ensure_extra_field("tag")
|
|
|
|
async def ensure_extra_field(self, name: str, field_type: str = "text") -> bool:
|
|
"""Register a custom extra field in Spoolman if not present.
|
|
|
|
Spoolman rejects PATCH requests that include unknown extra-dict keys
|
|
with HTTP 400 ('Unknown extra field <name>.'), so any custom field
|
|
Bambuddy persists alongside spools needs to be pre-registered.
|
|
Idempotent — returns True if the field already exists.
|
|
"""
|
|
try:
|
|
client = await self._get_client()
|
|
|
|
# Check if field already exists
|
|
response = await client.get(f"{self.api_url}/field/spool/{name}")
|
|
if response.status_code == 200:
|
|
logger.debug("Spoolman extra field %r already exists", name)
|
|
self._ensured_extra_fields.add(name)
|
|
return True
|
|
|
|
# Field doesn't exist - create it
|
|
field_data = {
|
|
"name": name,
|
|
"field_type": field_type,
|
|
"default_value": None,
|
|
}
|
|
response = await client.post(f"{self.api_url}/field/spool/{name}", json=field_data)
|
|
if response.status_code in (200, 201):
|
|
logger.info("Created Spoolman extra field %r", name)
|
|
self._ensured_extra_fields.add(name)
|
|
return True
|
|
|
|
logger.warning(
|
|
"Failed to create Spoolman extra field %r: %s - %s",
|
|
name,
|
|
response.status_code,
|
|
response.text,
|
|
)
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.warning("Failed to ensure Spoolman extra field %r exists: %s", name, e)
|
|
return False
|
|
|
|
async def _ensure_extra_fields(self, extra: dict | None) -> None:
|
|
"""Register every extra key an outgoing write declares, once per client.
|
|
|
|
Spoolman answers HTTP 400 "Unknown extra field <name>." for any extra
|
|
key that was not registered first, so registration has to happen before
|
|
the write, not before the feature. It used to happen before the feature:
|
|
three hand-maintained lists (the connect route, startup, and two inline
|
|
blocks in the inventory routes) each named the fields they expected to
|
|
be written later. Enabling Spoolman from Settings reaches none of them,
|
|
so the first AMS sync on a fresh Spoolman failed on every slot -- and
|
|
the Connect button that would have registered them is hidden by then,
|
|
because saving the settings initialises the client and the status
|
|
endpoint reads that as "connected" (issue #2903).
|
|
|
|
Keying off the payload instead removes the chance to forget: a write
|
|
that carries a key is a write that registers it. ``bambu_color_name``
|
|
is the cautionary case -- it never made it into the connect or startup
|
|
lists, and only works today because two call sites remembered to
|
|
register it by hand.
|
|
|
|
Best-effort by design. ``ensure_extra_field`` logs and returns False
|
|
rather than raising, and a failure here must not turn a write that
|
|
might still succeed into one that never happens -- the caller's own
|
|
error handling stays exactly as it was.
|
|
"""
|
|
names = [name for name in (extra or {}) if name not in self._ensured_extra_fields]
|
|
if not names:
|
|
return
|
|
|
|
async with self._ensure_extra_lock:
|
|
for name in names:
|
|
# Re-check under the lock: a concurrent write may have just
|
|
# registered this one, and two syncs racing to POST the same
|
|
# field is how one of them gets a needless warning logged.
|
|
if name not in self._ensured_extra_fields:
|
|
await self.ensure_extra_field(name)
|
|
|
|
def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
|
|
"""Parse raw MQTT tray data into an AMSTray; returns None for empty or invalid trays."""
|
|
# 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
|
|
|
|
# Need valid color to create filament
|
|
tray_color = tray_data.get("tray_color", "")
|
|
if not tray_color or tray_color.strip() == "":
|
|
logger.debug("Skipping tray with empty color")
|
|
return None
|
|
|
|
# Transparent filament (alpha=00) used to be rewritten to a cream
|
|
# "natural PLA" colour before being stored, because the swatch
|
|
# renderer couldn't show alpha. The swatch now paints a checkerboard
|
|
# underlay for translucent rgbas (see filamentSwatchHelpers.ts), so
|
|
# we pass `00000000` through verbatim — the inventory row keeps the
|
|
# AMS-reported colour and the frontend resolves the name to "Clear"
|
|
# via getColorName (#1545).
|
|
|
|
# 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 tray_info_idx (Bambu filament preset ID like "GFA00")
|
|
tray_info_idx = tray_data.get("tray_info_idx", "") or ""
|
|
|
|
# Get remaining percentage (-1 means unknown/not read by AMS)
|
|
remain = int(tray_data.get("remain", -1))
|
|
|
|
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_info_idx=tray_info_idx.strip(),
|
|
tray_weight=int(tray_data.get("tray_weight", 1000)),
|
|
)
|
|
|
|
def convert_ams_slot_to_location(self, ams_id: int, tray_id: int) -> str:
|
|
"""Return a human-readable location string (e.g. "AMS A1") for the given AMS slot."""
|
|
if ams_id >= 254:
|
|
return "External Spool"
|
|
|
|
if 128 <= ams_id <= 135:
|
|
# AMS-HT units use IDs 128-135
|
|
ht_letter = chr(ord("A") + (ams_id - 128))
|
|
return f"AMS-HT {ht_letter}{tray_id + 1}"
|
|
|
|
ams_letter = chr(ord("A") + ams_id)
|
|
return f"AMS {ams_letter}{tray_id + 1}"
|
|
|
|
def is_bambu_lab_spool(self, tray_uuid: str, tag_uid: str = "", tray_info_idx: str = "") -> bool:
|
|
"""Return True if tray_uuid or tag_uid identifies a Bambu Lab spool; tray_info_idx is ignored."""
|
|
# Check tray_uuid (preferred - consistent across printer models)
|
|
if tray_uuid:
|
|
uuid = tray_uuid.strip()
|
|
if len(uuid) == 32 and uuid != "00000000000000000000000000000000":
|
|
try:
|
|
int(uuid, 16)
|
|
return True
|
|
except ValueError:
|
|
pass
|
|
|
|
# Fallback: check tag_uid (RFID tag - varies between printer readers)
|
|
# Bambu Lab RFID tags are 16 hex characters (8 bytes)
|
|
if tag_uid:
|
|
tag = tag_uid.strip()
|
|
if len(tag) == 16 and tag != "0000000000000000":
|
|
try:
|
|
int(tag, 16)
|
|
logger.debug("Identified Bambu Lab spool via tag_uid fallback: %s", tag)
|
|
return True
|
|
except ValueError:
|
|
pass
|
|
|
|
return False
|
|
|
|
def calculate_remaining_weight(self, remain_percent: int, spool_weight: int) -> float:
|
|
"""Return remaining filament weight in grams given a percentage and total spool weight."""
|
|
return (remain_percent / 100.0) * spool_weight
|
|
|
|
async def sync_ams_tray(
|
|
self,
|
|
tray: AMSTray,
|
|
printer_name: str,
|
|
disable_weight_sync: bool = False,
|
|
cached_spools: list[dict] | None = None,
|
|
inventory_remaining: float | None = None,
|
|
spoolman_spool_id_hint: int | None = None,
|
|
auto_add_unknown_rfid: bool = True,
|
|
) -> dict | None:
|
|
"""Sync one AMS tray to Spoolman; creates the spool on first sight, updates weight otherwise."""
|
|
logger.debug(
|
|
f"Processing {printer_name} AMS {tray.ams_id} tray {tray.tray_id}: "
|
|
f"type={tray.tray_type}, idx={tray.tray_info_idx or 'none'}, "
|
|
f"uuid={tray.tray_uuid[:16] if tray.tray_uuid else 'none'}, "
|
|
f"tag={tray.tag_uid[:8] if tray.tag_uid else 'none'}..."
|
|
)
|
|
|
|
# Determine which identifier to use for Spoolman (prefer tray_uuid, fallback to 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
|
|
# 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
|
|
|
|
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,
|
|
)
|
|
|
|
# Spool not found by tag - auto-create it, unless the user has
|
|
# opted out of auto-adding unknown RFIDs (settings.auto_add_unknown_rfid).
|
|
# Caller broadcasts unknown_tag on the resulting None so the UI can
|
|
# surface a "+ Add to inventory" affordance on the slot.
|
|
if not auto_add_unknown_rfid:
|
|
logger.info(
|
|
"Auto-add disabled; skipping Spoolman spool create for %s (tag: %s...)",
|
|
tray.tray_sub_brands,
|
|
spool_tag[:16],
|
|
)
|
|
return None
|
|
|
|
logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
|
|
if self.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
|
|
filament = await self._find_or_create_filament(tray)
|
|
filament_id = filament["id"] if filament else None
|
|
else:
|
|
# Non-BL spool with custom RFID: use generic vendor lookup
|
|
brand = tray.tray_sub_brands if tray.tray_sub_brands != tray.tray_type else None
|
|
try:
|
|
filament_id = await self.find_or_create_filament(
|
|
material=tray.tray_type,
|
|
subtype="",
|
|
brand=brand,
|
|
color_hex=tray.tray_color[:6],
|
|
label_weight=tray.tray_weight,
|
|
)
|
|
except (SpoolmanNotFoundError, SpoolmanUnavailableError, SpoolmanClientError):
|
|
logger.warning("Could not find or create filament for non-BL spool %s", tray.tray_sub_brands)
|
|
return None
|
|
|
|
if not filament_id:
|
|
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,
|
|
comment="Created by Bambuddy",
|
|
extra={"tag": json.dumps(spool_tag)},
|
|
)
|
|
|
|
# No-RFID fallback: use the spool ID resolved from the local slot-assignment table.
|
|
# Never create new spools without a tag to avoid duplicates.
|
|
if spoolman_spool_id_hint is not None:
|
|
existing = next((s for s in (cached_spools or []) if s.get("id") == spoolman_spool_id_hint), None)
|
|
if existing is None:
|
|
try:
|
|
existing = await self.get_spool(spoolman_spool_id_hint)
|
|
except (SpoolmanNotFoundError, SpoolmanUnavailableError):
|
|
existing = None
|
|
if existing:
|
|
logger.info(
|
|
"Updating spool %s by slot-assignment hint (no RFID tag available)",
|
|
existing["id"],
|
|
)
|
|
return await self.update_spool(
|
|
spool_id=existing["id"],
|
|
remaining_weight=None if disable_weight_sync else remaining,
|
|
)
|
|
|
|
logger.info(
|
|
"%s AMS %s tray %s — skipping (no RFID tag and no slot-assignment hint)",
|
|
printer_name,
|
|
tray.ams_id,
|
|
tray.tray_id,
|
|
)
|
|
return None
|
|
|
|
async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
|
|
"""Return a Bambu Lab filament matching the tray's material/color, creating it if absent."""
|
|
bambu_vendor_id = await self.ensure_bambu_vendor()
|
|
color_hex = tray.tray_color[:6] # Strip alpha channel
|
|
material_upper = tray.tray_type.upper()
|
|
color_upper = color_hex.upper()
|
|
|
|
# Search internal filaments - only match Bambu Lab vendor
|
|
filaments = await self.get_filaments()
|
|
for filament in filaments:
|
|
fil_vendor_id = filament.get("vendor_id") or filament.get("vendor", {}).get("id")
|
|
if fil_vendor_id != bambu_vendor_id:
|
|
continue
|
|
fil_material = filament.get("material") or ""
|
|
fil_color = filament.get("color_hex") or ""
|
|
if fil_material.upper() == material_upper and fil_color.upper() == color_upper:
|
|
return filament
|
|
|
|
# Search external filaments (SpoolmanDB) — restrict to Bambu Lab only.
|
|
# The /api/v1/external/filament endpoint returns the full multi-vendor catalog
|
|
# with no server-side filter, so without a manufacturer check the first PLA/black
|
|
# hit is typically 3DJAKE or 3DXTECH, not Bambu Lab.
|
|
external = await self.get_external_filaments()
|
|
sub_brand = (tray.tray_sub_brands or "").strip().lower()
|
|
bambu_candidates = []
|
|
for filament in external:
|
|
manufacturer = (filament.get("manufacturer") or "").strip().lower()
|
|
ext_id = (filament.get("id") or "").strip().lower()
|
|
if manufacturer != "bambu lab" and not ext_id.startswith("bambulab_"):
|
|
continue
|
|
fil_material = filament.get("material") or ""
|
|
fil_color = filament.get("color_hex") or ""
|
|
if fil_material.upper() == material_upper and fil_color.upper() == color_upper:
|
|
bambu_candidates.append(filament)
|
|
|
|
if bambu_candidates:
|
|
# Prefer the entry whose `name` matches the AMS `tray_sub_brands`
|
|
# (e.g. "PLA Basic", "Support for PLA/PETG Black") so the more specific
|
|
# variant wins over a generic "Black" entry when both are present.
|
|
chosen = next(
|
|
(f for f in bambu_candidates if (f.get("name") or "").strip().lower() == sub_brand),
|
|
bambu_candidates[0],
|
|
)
|
|
return await self._create_filament_from_external(chosen, tray)
|
|
|
|
# Not found in either source - create a new Bambu Lab filament from scratch.
|
|
return await self.create_filament(
|
|
name=tray.tray_sub_brands or tray.tray_type,
|
|
vendor_id=bambu_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 an internal Spoolman filament from an external library entry."""
|
|
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),
|
|
density=external.get("density"),
|
|
)
|
|
|
|
|
|
# Global client instance (initialized when settings are loaded)
|
|
_spoolman_client: SpoolmanClient | None = None
|
|
|
|
|
|
async def get_spoolman_client() -> SpoolmanClient | None:
|
|
"""Return the global SpoolmanClient, or None if not configured."""
|
|
return _spoolman_client
|
|
|
|
|
|
async def init_spoolman_client(url: str) -> SpoolmanClient:
|
|
"""Initialise (or reinitialise) the global SpoolmanClient; raises ValueError if url fails SSRF guard."""
|
|
from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
|
|
|
|
assert_safe_spoolman_url(url)
|
|
|
|
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
|