spoolman.py 28 KB

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