spoolman.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854
  1. """Spoolman integration service for syncing AMS filament data."""
  2. import logging
  3. from dataclasses import dataclass
  4. from datetime import datetime, timezone
  5. import httpx
  6. logger = logging.getLogger(__name__)
  7. @dataclass
  8. class SpoolmanSpool:
  9. """Represents a spool in Spoolman."""
  10. id: int
  11. filament_id: int | None
  12. remaining_weight: float | None
  13. used_weight: float
  14. first_used: str | None
  15. last_used: str | None
  16. location: str | None
  17. lot_nr: str | None
  18. comment: str | None
  19. extra: dict | None # Contains tag_uid in extra.tag
  20. @dataclass
  21. class SpoolmanFilament:
  22. """Represents a filament type in Spoolman."""
  23. id: int
  24. name: str
  25. vendor_id: int | None
  26. material: str | None
  27. color_hex: str | None
  28. weight: float | None # Net weight in grams
  29. @dataclass
  30. class AMSTray:
  31. """Represents an AMS tray with filament data from Bambu printer."""
  32. ams_id: int # 0-3 for regular AMS, 128-135 for external spool
  33. tray_id: int # 0-3
  34. tray_type: str # PLA, PETG, ABS, etc.
  35. tray_sub_brands: str # Full name like "PLA Basic", "PETG HF"
  36. tray_color: str # Hex color like "FEC600FF"
  37. remain: int # Remaining percentage (0-100)
  38. tag_uid: str # RFID tag UID
  39. tray_uuid: str # Spool UUID
  40. tray_info_idx: str # Bambu filament preset ID like "GFA00"
  41. tray_weight: int # Spool weight in grams (usually 1000)
  42. class SpoolmanClient:
  43. """Client for interacting with Spoolman API."""
  44. def __init__(self, base_url: str):
  45. """Initialize the Spoolman client.
  46. Args:
  47. base_url: The base URL of the Spoolman server (e.g., http://localhost:7912)
  48. """
  49. self.base_url = base_url.rstrip("/")
  50. self.api_url = f"{self.base_url}/api/v1"
  51. self._client: httpx.AsyncClient | None = None
  52. self._connected = False
  53. async def _get_client(self) -> httpx.AsyncClient:
  54. """Get or create the HTTP client."""
  55. if self._client is None:
  56. self._client = httpx.AsyncClient(timeout=10.0)
  57. return self._client
  58. async def close(self):
  59. """Close the HTTP client."""
  60. if self._client:
  61. await self._client.aclose()
  62. self._client = None
  63. async def health_check(self) -> bool:
  64. """Check if Spoolman server is reachable.
  65. Returns:
  66. True if server is healthy, False otherwise.
  67. """
  68. try:
  69. client = await self._get_client()
  70. response = await client.get(f"{self.api_url}/health")
  71. self._connected = response.status_code == 200
  72. return self._connected
  73. except Exception as e:
  74. logger.warning(f"Spoolman health check failed: {e}")
  75. self._connected = False
  76. return False
  77. @property
  78. def is_connected(self) -> bool:
  79. """Check if client is connected to Spoolman."""
  80. return self._connected
  81. async def get_spools(self) -> list[dict]:
  82. """Get all spools from Spoolman.
  83. Returns:
  84. List of spool dictionaries.
  85. """
  86. try:
  87. client = await self._get_client()
  88. response = await client.get(f"{self.api_url}/spool")
  89. response.raise_for_status()
  90. return response.json()
  91. except Exception as e:
  92. logger.error(f"Failed to get spools from Spoolman: {e}")
  93. return []
  94. async def get_filaments(self) -> list[dict]:
  95. """Get all internal filaments from Spoolman.
  96. Returns:
  97. List of filament dictionaries.
  98. """
  99. try:
  100. client = await self._get_client()
  101. response = await client.get(f"{self.api_url}/filament")
  102. response.raise_for_status()
  103. return response.json()
  104. except Exception as e:
  105. logger.error(f"Failed to get filaments from Spoolman: {e}")
  106. return []
  107. async def get_external_filaments(self) -> list[dict]:
  108. """Get external/library filaments from Spoolman.
  109. Returns:
  110. List of external filament dictionaries.
  111. """
  112. try:
  113. client = await self._get_client()
  114. response = await client.get(f"{self.api_url}/external/filament")
  115. response.raise_for_status()
  116. return response.json()
  117. except Exception as e:
  118. logger.error(f"Failed to get external filaments from Spoolman: {e}")
  119. return []
  120. async def get_vendors(self) -> list[dict]:
  121. """Get all vendors from Spoolman.
  122. Returns:
  123. List of vendor dictionaries.
  124. """
  125. try:
  126. client = await self._get_client()
  127. response = await client.get(f"{self.api_url}/vendor")
  128. response.raise_for_status()
  129. return response.json()
  130. except Exception as e:
  131. logger.error(f"Failed to get vendors from Spoolman: {e}")
  132. return []
  133. async def create_vendor(self, name: str) -> dict | None:
  134. """Create a new vendor in Spoolman.
  135. Args:
  136. name: Vendor name (e.g., "Bambu Lab")
  137. Returns:
  138. Created vendor dictionary or None on failure.
  139. """
  140. try:
  141. client = await self._get_client()
  142. response = await client.post(f"{self.api_url}/vendor", json={"name": name})
  143. response.raise_for_status()
  144. return response.json()
  145. except Exception as e:
  146. logger.error(f"Failed to create vendor in Spoolman: {e}")
  147. return None
  148. def _get_material_density(self, material: str | None) -> float:
  149. """Get typical density for a filament material type.
  150. Args:
  151. material: Material type (PLA, PETG, ABS, etc.)
  152. Returns:
  153. Density in g/cm³
  154. """
  155. # Typical densities for common filament materials
  156. densities = {
  157. "PLA": 1.24,
  158. "PLA-CF": 1.29,
  159. "PLA-S": 1.24,
  160. "PETG": 1.27,
  161. "ABS": 1.04,
  162. "ASA": 1.07,
  163. "TPU": 1.21,
  164. "PA": 1.14, # Nylon
  165. "PA-CF": 1.20,
  166. "PC": 1.20,
  167. "PVA": 1.23,
  168. "HIPS": 1.04,
  169. "PP": 0.90,
  170. "PET": 1.38,
  171. }
  172. if material:
  173. # Try exact match first, then uppercase
  174. mat_upper = material.upper()
  175. for key, density in densities.items():
  176. if key.upper() == mat_upper or mat_upper.startswith(key.upper()):
  177. return density
  178. return 1.24 # Default to PLA density
  179. async def create_filament(
  180. self,
  181. name: str,
  182. vendor_id: int | None = None,
  183. material: str | None = None,
  184. color_hex: str | None = None,
  185. weight: float | None = None,
  186. diameter: float = 1.75,
  187. density: float | None = None,
  188. ) -> dict | None:
  189. """Create a new filament in Spoolman.
  190. Args:
  191. name: Filament name
  192. vendor_id: Vendor ID
  193. material: Material type (PLA, PETG, etc.)
  194. color_hex: Color in hex format (without #)
  195. weight: Net weight in grams
  196. diameter: Filament diameter in mm (default 1.75)
  197. density: Filament density in g/cm³ (auto-calculated if not provided)
  198. Returns:
  199. Created filament dictionary or None on failure.
  200. """
  201. # Validate required fields
  202. if not name or not name.strip():
  203. logger.error("Cannot create filament: name is required")
  204. return None
  205. try:
  206. # Calculate density from material if not provided
  207. if density is None:
  208. density = self._get_material_density(material)
  209. data = {
  210. "name": name.strip(),
  211. "diameter": diameter,
  212. "density": density,
  213. }
  214. if vendor_id:
  215. data["vendor_id"] = vendor_id
  216. if material:
  217. data["material"] = material
  218. if color_hex:
  219. # Strip alpha channel if present (RRGGBBAA -> RRGGBB)
  220. color_hex = color_hex[:6] if len(color_hex) >= 6 else color_hex
  221. data["color_hex"] = color_hex
  222. if weight:
  223. data["weight"] = weight
  224. logger.debug(f"Creating filament in Spoolman: {data}")
  225. client = await self._get_client()
  226. response = await client.post(f"{self.api_url}/filament", json=data)
  227. response.raise_for_status()
  228. return response.json()
  229. except httpx.HTTPStatusError as e:
  230. logger.error(f"Failed to create filament in Spoolman: {e}, response: {e.response.text}")
  231. return None
  232. except Exception as e:
  233. logger.error(f"Failed to create filament in Spoolman: {e}")
  234. return None
  235. async def create_spool(
  236. self,
  237. filament_id: int,
  238. remaining_weight: float | None = None,
  239. location: str | None = None,
  240. lot_nr: str | None = None,
  241. comment: str | None = None,
  242. extra: dict | None = None,
  243. ) -> dict | None:
  244. """Create a new spool in Spoolman.
  245. Args:
  246. filament_id: ID of the filament type
  247. remaining_weight: Remaining weight in grams
  248. location: Physical location description
  249. lot_nr: Lot/batch number
  250. comment: Optional comment
  251. extra: Extra fields (e.g., {"tag": "RFID_TAG_UID"})
  252. Returns:
  253. Created spool dictionary or None on failure.
  254. """
  255. try:
  256. data = {"filament_id": filament_id}
  257. if remaining_weight is not None:
  258. data["remaining_weight"] = remaining_weight
  259. if location:
  260. data["location"] = location
  261. if lot_nr:
  262. data["lot_nr"] = lot_nr
  263. if comment:
  264. data["comment"] = comment
  265. if extra:
  266. data["extra"] = extra
  267. logger.debug(f"Creating spool in Spoolman: {data}")
  268. client = await self._get_client()
  269. response = await client.post(f"{self.api_url}/spool", json=data)
  270. response.raise_for_status()
  271. result = response.json()
  272. logger.info(f"Created spool {result.get('id')} in Spoolman")
  273. return result
  274. except httpx.HTTPStatusError as e:
  275. logger.error(f"Failed to create spool in Spoolman: {e}, response: {e.response.text}")
  276. return None
  277. except Exception as e:
  278. logger.error(f"Failed to create spool in Spoolman: {e}")
  279. return None
  280. async def update_spool(
  281. self,
  282. spool_id: int,
  283. remaining_weight: float | None = None,
  284. location: str | None = None,
  285. clear_location: bool = False,
  286. extra: dict | None = None,
  287. ) -> dict | None:
  288. """Update an existing spool in Spoolman.
  289. Args:
  290. spool_id: ID of the spool to update
  291. remaining_weight: New remaining weight in grams
  292. location: New location (ignored if clear_location is True)
  293. clear_location: If True, clears the location field
  294. extra: Extra fields to update
  295. Returns:
  296. Updated spool dictionary or None on failure.
  297. """
  298. try:
  299. data = {}
  300. if remaining_weight is not None:
  301. data["remaining_weight"] = remaining_weight
  302. if clear_location:
  303. data["location"] = None
  304. elif location:
  305. data["location"] = location
  306. if extra:
  307. data["extra"] = extra
  308. # Always update last_used
  309. data["last_used"] = datetime.now(timezone.utc).isoformat()
  310. client = await self._get_client()
  311. response = await client.patch(f"{self.api_url}/spool/{spool_id}", json=data)
  312. response.raise_for_status()
  313. return response.json()
  314. except Exception as e:
  315. logger.error(f"Failed to update spool in Spoolman: {e}")
  316. return None
  317. async def use_spool(self, spool_id: int, used_weight: float) -> dict | None:
  318. """Record filament usage for a spool.
  319. Args:
  320. spool_id: ID of the spool
  321. used_weight: Amount of filament used in grams
  322. Returns:
  323. Updated spool dictionary or None on failure.
  324. """
  325. try:
  326. client = await self._get_client()
  327. response = await client.put(
  328. f"{self.api_url}/spool/{spool_id}/use",
  329. json={"use_weight": used_weight},
  330. )
  331. response.raise_for_status()
  332. return response.json()
  333. except Exception as e:
  334. logger.error(f"Failed to record spool usage in Spoolman: {e}")
  335. return None
  336. async def find_spool_by_tag(self, tag_uid: str) -> dict | None:
  337. """Find a spool by its RFID tag UID.
  338. Args:
  339. tag_uid: The RFID tag UID to search for
  340. Returns:
  341. Spool dictionary or None if not found.
  342. """
  343. spools = await self.get_spools()
  344. # Normalize tag_uid for comparison (uppercase, strip quotes)
  345. search_tag = tag_uid.strip('"').upper()
  346. for spool in spools:
  347. extra = spool.get("extra", {})
  348. if extra:
  349. stored_tag = extra.get("tag", "")
  350. # Normalize stored tag (strip quotes, uppercase)
  351. if stored_tag:
  352. normalized_tag = stored_tag.strip('"').upper()
  353. if normalized_tag == search_tag:
  354. logger.debug(f"Found spool {spool['id']} matching tag {tag_uid}")
  355. return spool
  356. return None
  357. async def find_spools_by_location_prefix(self, location_prefix: str) -> list[dict]:
  358. """Find all spools with locations starting with a given prefix.
  359. Args:
  360. location_prefix: The location prefix to search for (e.g., "PrinterName - ")
  361. Returns:
  362. List of spool dictionaries with matching locations.
  363. """
  364. spools = await self.get_spools()
  365. matching = []
  366. for spool in spools:
  367. location = spool.get("location", "")
  368. if location and location.startswith(location_prefix):
  369. matching.append(spool)
  370. return matching
  371. async def clear_location_for_removed_spools(
  372. self,
  373. printer_name: str,
  374. current_tray_uuids: set[str],
  375. ) -> int:
  376. """Clear location for spools that are no longer in the AMS.
  377. When a spool is removed from the AMS, its location should be cleared
  378. in Spoolman. This method finds all spools with locations for this printer
  379. and clears the location for any that are not in the current_tray_uuids set.
  380. Args:
  381. printer_name: The printer name used as location prefix
  382. current_tray_uuids: Set of tray_uuids currently in the AMS
  383. Returns:
  384. Number of spools whose location was cleared.
  385. """
  386. location_prefix = f"{printer_name} - "
  387. spools_at_printer = await self.find_spools_by_location_prefix(location_prefix)
  388. cleared_count = 0
  389. for spool in spools_at_printer:
  390. # Get the tray_uuid (stored as "tag" in extra field)
  391. extra = spool.get("extra", {}) or {}
  392. stored_tag = extra.get("tag", "")
  393. if stored_tag:
  394. # Normalize: strip quotes and uppercase
  395. spool_uuid = stored_tag.strip('"').upper()
  396. else:
  397. spool_uuid = ""
  398. # If this spool's UUID is not in the current AMS, clear its location
  399. if spool_uuid not in current_tray_uuids:
  400. logger.info(
  401. f"Clearing location for spool {spool['id']} "
  402. f"(was: {spool.get('location')}, uuid: {spool_uuid[:16] if spool_uuid else 'none'}...)"
  403. )
  404. result = await self.update_spool(spool_id=spool["id"], clear_location=True)
  405. if result:
  406. cleared_count += 1
  407. return cleared_count
  408. async def ensure_bambu_vendor(self) -> int | None:
  409. """Ensure Bambu Lab vendor exists and return its ID.
  410. Returns:
  411. Vendor ID or None on failure.
  412. """
  413. vendors = await self.get_vendors()
  414. for vendor in vendors:
  415. if vendor.get("name", "").lower() == "bambu lab":
  416. return vendor["id"]
  417. # Create Bambu Lab vendor if not exists
  418. vendor = await self.create_vendor("Bambu Lab")
  419. return vendor["id"] if vendor else None
  420. async def ensure_tag_extra_field(self) -> bool:
  421. """Ensure the 'tag' extra field exists for spools.
  422. Spoolman requires extra fields to be registered before use.
  423. This creates the 'tag' field used to store RFID/UUID identifiers.
  424. Returns:
  425. True if field exists or was created, False on failure.
  426. """
  427. try:
  428. client = await self._get_client()
  429. # Check if field already exists
  430. response = await client.get(f"{self.api_url}/field/spool/tag")
  431. if response.status_code == 200:
  432. logger.debug("Spoolman 'tag' extra field already exists")
  433. return True
  434. # Field doesn't exist - create it
  435. field_data = {
  436. "name": "tag",
  437. "field_type": "text",
  438. "default_value": None,
  439. }
  440. response = await client.post(f"{self.api_url}/field/spool/tag", json=field_data)
  441. if response.status_code in (200, 201):
  442. logger.info("Created 'tag' extra field in Spoolman")
  443. return True
  444. logger.warning(f"Failed to create 'tag' extra field: {response.status_code} - {response.text}")
  445. return False
  446. except Exception as e:
  447. logger.warning(f"Failed to ensure 'tag' extra field exists: {e}")
  448. return False
  449. def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
  450. """Parse AMS tray data into AMSTray object.
  451. Args:
  452. ams_id: The AMS unit ID (0-3 for regular, 128-135 for external)
  453. tray_data: Raw tray data from MQTT
  454. Returns:
  455. AMSTray object or None if tray is empty or invalid.
  456. """
  457. # Skip empty trays - check for valid tray_type
  458. tray_type = tray_data.get("tray_type", "")
  459. if not tray_type or tray_type.strip() == "":
  460. return None
  461. # Need valid color to create filament
  462. tray_color = tray_data.get("tray_color", "")
  463. if not tray_color or tray_color.strip() == "":
  464. logger.debug("Skipping tray with empty color")
  465. return None
  466. # Handle transparent/natural filament (RRGGBBAA with alpha=00)
  467. # Replace with cream color that represents how natural PLA actually looks
  468. if tray_color == "00000000":
  469. tray_color = "F5E6D3FF" # Light cream/natural color
  470. # Get sub_brands, falling back to tray_type
  471. tray_sub_brands = tray_data.get("tray_sub_brands", "")
  472. if not tray_sub_brands or tray_sub_brands.strip() == "":
  473. tray_sub_brands = tray_type
  474. # Get tag_uid and tray_uuid, filtering out empty/invalid values
  475. tag_uid = tray_data.get("tag_uid", "")
  476. if tag_uid in ("", "0000000000000000"):
  477. tag_uid = ""
  478. tray_uuid = tray_data.get("tray_uuid", "")
  479. if tray_uuid in ("", "00000000000000000000000000000000"):
  480. tray_uuid = ""
  481. # Get tray_info_idx (Bambu filament preset ID like "GFA00")
  482. tray_info_idx = tray_data.get("tray_info_idx", "") or ""
  483. # Get remaining percentage, ensure non-negative
  484. remain = max(0, int(tray_data.get("remain", 0)))
  485. return AMSTray(
  486. ams_id=ams_id,
  487. tray_id=int(tray_data.get("id", 0)),
  488. tray_type=tray_type.strip(),
  489. tray_sub_brands=tray_sub_brands.strip(),
  490. tray_color=tray_color,
  491. remain=remain,
  492. tag_uid=tag_uid,
  493. tray_uuid=tray_uuid,
  494. tray_info_idx=tray_info_idx.strip(),
  495. tray_weight=int(tray_data.get("tray_weight", 1000)),
  496. )
  497. def convert_ams_slot_to_location(self, ams_id: int, tray_id: int) -> str:
  498. """Convert AMS ID and tray ID to human-readable location.
  499. Args:
  500. ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for external)
  501. tray_id: Tray ID within the AMS (0-3)
  502. Returns:
  503. Location string like "AMS A1", "AMS B2", "External"
  504. """
  505. if ams_id >= 128:
  506. return "External Spool"
  507. ams_letter = chr(ord("A") + ams_id)
  508. return f"AMS {ams_letter}{tray_id + 1}"
  509. def is_bambu_lab_spool(self, tray_uuid: str, tag_uid: str = "", tray_info_idx: str = "") -> bool:
  510. """Check if a tray has a valid Bambu Lab spool.
  511. Bambu Lab spools can be identified by:
  512. 1. tray_uuid: 32-character hex string (preferred, consistent across printers)
  513. 2. tag_uid: 16-character hex string (RFID tag, varies between readers)
  514. 3. tray_info_idx: Bambu filament preset ID like "GFA00" (most reliable)
  515. Non-Bambu Lab spools (SpoolEase, third-party) won't have these identifiers.
  516. Args:
  517. tray_uuid: The tray UUID to check (32 hex chars)
  518. tag_uid: The RFID tag UID to check as fallback (16 hex chars)
  519. tray_info_idx: Bambu filament preset ID like "GFA00", "GFB00"
  520. Returns:
  521. True if the spool has valid Bambu Lab identifiers, False otherwise.
  522. """
  523. # Check tray_info_idx first - Bambu filament preset IDs like "GFA00", "GFB00", etc.
  524. # This is the most reliable indicator as it's set when the spool is recognized
  525. if tray_info_idx:
  526. idx = tray_info_idx.strip()
  527. # Bambu Lab preset IDs start with "GF" followed by letter and digits
  528. # e.g., GFA00, GFB00, GFL00, GFN00, GFG00, GFS00, GFU00
  529. if idx and len(idx) >= 3 and idx.startswith("GF"):
  530. logger.debug(f"Identified Bambu Lab spool via tray_info_idx: {idx}")
  531. return True
  532. # Check tray_uuid (preferred - consistent across printer models)
  533. if tray_uuid:
  534. uuid = tray_uuid.strip()
  535. if len(uuid) == 32 and uuid != "00000000000000000000000000000000":
  536. try:
  537. int(uuid, 16)
  538. return True
  539. except ValueError:
  540. pass
  541. # Fallback: check tag_uid (RFID tag - varies between printer readers)
  542. # Bambu Lab RFID tags are 16 hex characters (8 bytes)
  543. if tag_uid:
  544. tag = tag_uid.strip()
  545. if len(tag) == 16 and tag != "0000000000000000":
  546. try:
  547. int(tag, 16)
  548. logger.debug(f"Identified Bambu Lab spool via tag_uid fallback: {tag}")
  549. return True
  550. except ValueError:
  551. pass
  552. return False
  553. def calculate_remaining_weight(self, remain_percent: int, spool_weight: int) -> float:
  554. """Calculate remaining weight from percentage.
  555. Args:
  556. remain_percent: Remaining percentage (0-100)
  557. spool_weight: Total spool weight in grams
  558. Returns:
  559. Remaining weight in grams.
  560. """
  561. return (remain_percent / 100.0) * spool_weight
  562. async def sync_ams_tray(self, tray: AMSTray, printer_name: str, disable_weight_sync: bool = False) -> dict | None:
  563. """Sync a single AMS tray to Spoolman.
  564. Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters).
  565. Non-Bambu Lab spools (SpoolEase/third-party) are skipped.
  566. Uses tray_uuid for matching, as it's consistent across all printer models
  567. (unlike tag_uid which varies between X1C/H2D readers).
  568. Args:
  569. tray: The AMSTray to sync
  570. printer_name: Name of the printer for location
  571. disable_weight_sync: If True, skip updating remaining_weight for existing spools.
  572. This allows Spoolman's granular usage tracking to maintain accurate weights.
  573. Returns:
  574. Synced spool dictionary or None if skipped or failed.
  575. """
  576. logger.debug(
  577. f"Processing {printer_name} AMS {tray.ams_id} tray {tray.tray_id}: "
  578. f"type={tray.tray_type}, idx={tray.tray_info_idx or 'none'}, "
  579. f"uuid={tray.tray_uuid[:16] if tray.tray_uuid else 'none'}, "
  580. f"tag={tray.tag_uid[:8] if tray.tag_uid else 'none'}..."
  581. )
  582. # Only sync trays with valid Bambu Lab identifiers
  583. if not self.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
  584. if tray.tray_uuid or tray.tag_uid or tray.tray_info_idx:
  585. logger.info(
  586. f"Skipping non-Bambu Lab spool: {printer_name} AMS {tray.ams_id} tray {tray.tray_id} "
  587. f"(tray_info_idx={tray.tray_info_idx}, tray_uuid={tray.tray_uuid}, tag_uid={tray.tag_uid})"
  588. )
  589. else:
  590. logger.debug(f"Skipping tray without RFID tag: AMS {tray.ams_id} tray {tray.tray_id}")
  591. return None
  592. # Determine which identifier to use for Spoolman (prefer tray_uuid, fallback to tag_uid)
  593. spool_tag = (
  594. tray.tray_uuid if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000" else tray.tag_uid
  595. )
  596. # If no unique identifier available, we can't sync even if it's a Bambu Lab spool
  597. if not spool_tag:
  598. logger.warning(
  599. f"Bambu Lab spool detected but no unique identifier for Spoolman: "
  600. f"{printer_name} AMS {tray.ams_id} tray {tray.tray_id} (tray_info_idx={tray.tray_info_idx})"
  601. )
  602. return None
  603. # Calculate remaining weight
  604. remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
  605. location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
  606. # Find existing spool by tag (tray_uuid or tag_uid, stored as "tag" in Spoolman)
  607. existing = await self.find_spool_by_tag(spool_tag)
  608. if existing:
  609. # Update existing spool
  610. logger.info(f"Updating existing spool {existing['id']} for tag {spool_tag[:16]}...")
  611. return await self.update_spool(
  612. spool_id=existing["id"],
  613. remaining_weight=None if disable_weight_sync else remaining,
  614. location=location,
  615. )
  616. # Spool not found - auto-create it
  617. logger.info(f"Creating new spool in Spoolman for {tray.tray_sub_brands} (tag: {spool_tag[:16]}...)")
  618. # First find or create the filament type
  619. filament = await self._find_or_create_filament(tray)
  620. if not filament:
  621. logger.error(f"Failed to find or create filament for {tray.tray_sub_brands}")
  622. return None
  623. # Create the spool with identifier stored as "tag" in extra field
  624. # Note: Spoolman extra field values must be valid JSON, so we encode the string
  625. import json
  626. return await self.create_spool(
  627. filament_id=filament["id"],
  628. remaining_weight=remaining,
  629. location=location,
  630. comment="Created by Bambuddy",
  631. extra={"tag": json.dumps(spool_tag)},
  632. )
  633. async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
  634. """Find existing filament or create new one.
  635. Only matches Bambu Lab vendor filaments since this is called for
  636. Bambu Lab spools. Third-party filaments (like 3DJAKE) are ignored
  637. to prevent incorrect matching by color alone.
  638. Args:
  639. tray: The AMSTray containing filament info
  640. Returns:
  641. Filament dictionary or None on failure.
  642. """
  643. # Get Bambu Lab vendor ID for filtering
  644. bambu_vendor_id = await self.ensure_bambu_vendor()
  645. color_hex = tray.tray_color[:6] # Strip alpha channel
  646. # Search internal filaments - only match Bambu Lab vendor
  647. filaments = await self.get_filaments()
  648. for filament in filaments:
  649. # Only match filaments from Bambu Lab vendor
  650. fil_vendor_id = filament.get("vendor_id") or filament.get("vendor", {}).get("id")
  651. if fil_vendor_id != bambu_vendor_id:
  652. continue
  653. # Match by material and color (handle None values)
  654. fil_material = filament.get("material") or ""
  655. fil_color = filament.get("color_hex") or ""
  656. if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
  657. return filament
  658. # Search external filaments (Bambu library)
  659. external = await self.get_external_filaments()
  660. for filament in external:
  661. fil_material = filament.get("material") or ""
  662. fil_color = filament.get("color_hex") or ""
  663. if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
  664. # Found in external library - need to create internal copy
  665. return await self._create_filament_from_external(filament, tray)
  666. # Not found - create new Bambu Lab filament
  667. return await self.create_filament(
  668. name=tray.tray_sub_brands or tray.tray_type,
  669. vendor_id=bambu_vendor_id,
  670. material=tray.tray_type,
  671. color_hex=color_hex,
  672. weight=tray.tray_weight,
  673. )
  674. async def _create_filament_from_external(self, external: dict, tray: AMSTray) -> dict | None:
  675. """Create internal filament from external library entry.
  676. Args:
  677. external: External filament dictionary
  678. tray: The AMSTray for additional info
  679. Returns:
  680. Created filament dictionary or None on failure.
  681. """
  682. vendor_id = await self.ensure_bambu_vendor()
  683. return await self.create_filament(
  684. name=external.get("name", tray.tray_sub_brands),
  685. vendor_id=vendor_id,
  686. material=external.get("material", tray.tray_type),
  687. color_hex=external.get("color_hex", tray.tray_color[:6]),
  688. weight=external.get("weight", tray.tray_weight),
  689. )
  690. # Global client instance (initialized when settings are loaded)
  691. _spoolman_client: SpoolmanClient | None = None
  692. async def get_spoolman_client() -> SpoolmanClient | None:
  693. """Get the global Spoolman client instance.
  694. Returns:
  695. SpoolmanClient instance or None if not configured.
  696. """
  697. return _spoolman_client
  698. async def init_spoolman_client(url: str) -> SpoolmanClient:
  699. """Initialize the global Spoolman client.
  700. Args:
  701. url: Spoolman server URL
  702. Returns:
  703. Initialized SpoolmanClient instance.
  704. """
  705. global _spoolman_client
  706. if _spoolman_client:
  707. await _spoolman_client.close()
  708. _spoolman_client = SpoolmanClient(url)
  709. return _spoolman_client
  710. async def close_spoolman_client():
  711. """Close the global Spoolman client."""
  712. global _spoolman_client
  713. if _spoolman_client:
  714. await _spoolman_client.close()
  715. _spoolman_client = None