spoolman.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  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. extra: dict | None = None,
  285. ) -> dict | None:
  286. """Update an existing spool in Spoolman.
  287. Args:
  288. spool_id: ID of the spool to update
  289. remaining_weight: New remaining weight in grams
  290. location: New location
  291. extra: Extra fields to update
  292. Returns:
  293. Updated spool dictionary or None on failure.
  294. """
  295. try:
  296. data = {}
  297. if remaining_weight is not None:
  298. data["remaining_weight"] = remaining_weight
  299. if location:
  300. data["location"] = location
  301. if extra:
  302. data["extra"] = extra
  303. # Always update last_used
  304. data["last_used"] = datetime.now(UTC).isoformat()
  305. client = await self._get_client()
  306. response = await client.patch(f"{self.api_url}/spool/{spool_id}", json=data)
  307. response.raise_for_status()
  308. return response.json()
  309. except Exception as e:
  310. logger.error(f"Failed to update spool in Spoolman: {e}")
  311. return None
  312. async def use_spool(self, spool_id: int, used_weight: float) -> dict | None:
  313. """Record filament usage for a spool.
  314. Args:
  315. spool_id: ID of the spool
  316. used_weight: Amount of filament used in grams
  317. Returns:
  318. Updated spool dictionary or None on failure.
  319. """
  320. try:
  321. client = await self._get_client()
  322. response = await client.put(
  323. f"{self.api_url}/spool/{spool_id}/use",
  324. json={"use_weight": used_weight},
  325. )
  326. response.raise_for_status()
  327. return response.json()
  328. except Exception as e:
  329. logger.error(f"Failed to record spool usage in Spoolman: {e}")
  330. return None
  331. async def find_spool_by_tag(self, tag_uid: str) -> dict | None:
  332. """Find a spool by its RFID tag UID.
  333. Args:
  334. tag_uid: The RFID tag UID to search for
  335. Returns:
  336. Spool dictionary or None if not found.
  337. """
  338. spools = await self.get_spools()
  339. # Normalize tag_uid for comparison (uppercase, strip quotes)
  340. search_tag = tag_uid.strip('"').upper()
  341. for spool in spools:
  342. extra = spool.get("extra", {})
  343. if extra:
  344. stored_tag = extra.get("tag", "")
  345. # Normalize stored tag (strip quotes, uppercase)
  346. if stored_tag:
  347. normalized_tag = stored_tag.strip('"').upper()
  348. if normalized_tag == search_tag:
  349. logger.debug(f"Found spool {spool['id']} matching tag {tag_uid}")
  350. return spool
  351. return None
  352. async def ensure_bambu_vendor(self) -> int | None:
  353. """Ensure Bambu Lab vendor exists and return its ID.
  354. Returns:
  355. Vendor ID or None on failure.
  356. """
  357. vendors = await self.get_vendors()
  358. for vendor in vendors:
  359. if vendor.get("name", "").lower() == "bambu lab":
  360. return vendor["id"]
  361. # Create Bambu Lab vendor if not exists
  362. vendor = await self.create_vendor("Bambu Lab")
  363. return vendor["id"] if vendor else None
  364. def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
  365. """Parse AMS tray data into AMSTray object.
  366. Args:
  367. ams_id: The AMS unit ID (0-3 for regular, 128-135 for external)
  368. tray_data: Raw tray data from MQTT
  369. Returns:
  370. AMSTray object or None if tray is empty or invalid.
  371. """
  372. # Skip empty trays - check for valid tray_type
  373. tray_type = tray_data.get("tray_type", "")
  374. if not tray_type or tray_type.strip() == "":
  375. return None
  376. # Need valid color to create filament
  377. tray_color = tray_data.get("tray_color", "")
  378. if not tray_color or tray_color in ("", "00000000"):
  379. logger.debug(f"Skipping tray with invalid color: {tray_color}")
  380. return None
  381. # Get sub_brands, falling back to tray_type
  382. tray_sub_brands = tray_data.get("tray_sub_brands", "")
  383. if not tray_sub_brands or tray_sub_brands.strip() == "":
  384. tray_sub_brands = tray_type
  385. # Get tag_uid and tray_uuid, filtering out empty/invalid values
  386. tag_uid = tray_data.get("tag_uid", "")
  387. if tag_uid in ("", "0000000000000000"):
  388. tag_uid = ""
  389. tray_uuid = tray_data.get("tray_uuid", "")
  390. if tray_uuid in ("", "00000000000000000000000000000000"):
  391. tray_uuid = ""
  392. # Get remaining percentage, ensure non-negative
  393. remain = max(0, int(tray_data.get("remain", 0)))
  394. return AMSTray(
  395. ams_id=ams_id,
  396. tray_id=int(tray_data.get("id", 0)),
  397. tray_type=tray_type.strip(),
  398. tray_sub_brands=tray_sub_brands.strip(),
  399. tray_color=tray_color,
  400. remain=remain,
  401. tag_uid=tag_uid,
  402. tray_uuid=tray_uuid,
  403. tray_weight=int(tray_data.get("tray_weight", 1000)),
  404. )
  405. def convert_ams_slot_to_location(self, ams_id: int, tray_id: int) -> str:
  406. """Convert AMS ID and tray ID to human-readable location.
  407. Args:
  408. ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for external)
  409. tray_id: Tray ID within the AMS (0-3)
  410. Returns:
  411. Location string like "AMS A1", "AMS B2", "External"
  412. """
  413. if ams_id >= 128:
  414. return "External Spool"
  415. ams_letter = chr(ord("A") + ams_id)
  416. return f"AMS {ams_letter}{tray_id + 1}"
  417. def is_bambu_lab_spool(self, tray_uuid: str) -> bool:
  418. """Check if a tray has a valid Bambu Lab spool UUID.
  419. Bambu Lab spools have a tray_uuid which is a 32-character hex string.
  420. This UUID is consistent across all printer models (unlike tag_uid which
  421. varies between X1C/H2D readers).
  422. Non-Bambu Lab spools (SpoolEase, third-party) won't have a valid tray_uuid.
  423. Args:
  424. tray_uuid: The tray UUID to check
  425. Returns:
  426. True if the spool has a valid Bambu Lab UUID, False otherwise.
  427. """
  428. if not tray_uuid:
  429. return False
  430. # Bambu Lab tray_uuid is always 32 hex characters
  431. uuid = tray_uuid.strip()
  432. if len(uuid) != 32:
  433. return False
  434. # Verify it's all hex characters and not empty/zero
  435. if uuid == "00000000000000000000000000000000":
  436. return False
  437. try:
  438. int(uuid, 16)
  439. return True
  440. except ValueError:
  441. return False
  442. def calculate_remaining_weight(self, remain_percent: int, spool_weight: int) -> float:
  443. """Calculate remaining weight from percentage.
  444. Args:
  445. remain_percent: Remaining percentage (0-100)
  446. spool_weight: Total spool weight in grams
  447. Returns:
  448. Remaining weight in grams.
  449. """
  450. return (remain_percent / 100.0) * spool_weight
  451. async def sync_ams_tray(self, tray: AMSTray, printer_name: str) -> dict | None:
  452. """Sync a single AMS tray to Spoolman.
  453. Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters).
  454. Non-Bambu Lab spools (SpoolEase/third-party) are skipped.
  455. Uses tray_uuid for matching, as it's consistent across all printer models
  456. (unlike tag_uid which varies between X1C/H2D readers).
  457. Args:
  458. tray: The AMSTray to sync
  459. printer_name: Name of the printer for location
  460. Returns:
  461. Synced spool dictionary or None if skipped or failed.
  462. """
  463. logger.debug(
  464. f"Processing {printer_name} AMS {tray.ams_id} tray {tray.tray_id}: "
  465. f"type={tray.tray_type}, uuid={tray.tray_uuid[:16] if tray.tray_uuid else 'none'}..."
  466. )
  467. # Only sync trays with valid Bambu Lab tray_uuid
  468. if not self.is_bambu_lab_spool(tray.tray_uuid):
  469. if tray.tray_uuid or tray.tag_uid:
  470. logger.info(
  471. f"Skipping non-Bambu Lab spool: {printer_name} AMS {tray.ams_id} tray {tray.tray_id} "
  472. f"(tray_uuid={tray.tray_uuid}, tag_uid={tray.tag_uid})"
  473. )
  474. else:
  475. logger.debug(f"Skipping tray without RFID tag: AMS {tray.ams_id} tray {tray.tray_id}")
  476. return None
  477. # Calculate remaining weight
  478. remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
  479. location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
  480. # Find existing spool by tray_uuid (stored as "tag" in Spoolman)
  481. existing = await self.find_spool_by_tag(tray.tray_uuid)
  482. if existing:
  483. # Update existing spool
  484. logger.info(f"Updating existing spool {existing['id']} for tray_uuid {tray.tray_uuid}")
  485. return await self.update_spool(
  486. spool_id=existing["id"],
  487. remaining_weight=remaining,
  488. location=location,
  489. )
  490. # Spool not found - auto-create it
  491. logger.info(f"Creating new spool in Spoolman for {tray.tray_sub_brands} (tray_uuid: {tray.tray_uuid[:16]}...)")
  492. # First find or create the filament type
  493. filament = await self._find_or_create_filament(tray)
  494. if not filament:
  495. logger.error(f"Failed to find or create filament for {tray.tray_sub_brands}")
  496. return None
  497. # Create the spool with tray_uuid stored as "tag" in extra field
  498. # Note: Spoolman extra field values must be valid JSON, so we encode the string
  499. import json
  500. return await self.create_spool(
  501. filament_id=filament["id"],
  502. remaining_weight=remaining,
  503. location=location,
  504. comment="Created by Bambuddy",
  505. extra={"tag": json.dumps(tray.tray_uuid)},
  506. )
  507. async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
  508. """Find existing filament or create new one.
  509. Only matches Bambu Lab vendor filaments since this is called for
  510. Bambu Lab spools. Third-party filaments (like 3DJAKE) are ignored
  511. to prevent incorrect matching by color alone.
  512. Args:
  513. tray: The AMSTray containing filament info
  514. Returns:
  515. Filament dictionary or None on failure.
  516. """
  517. # Get Bambu Lab vendor ID for filtering
  518. bambu_vendor_id = await self.ensure_bambu_vendor()
  519. color_hex = tray.tray_color[:6] # Strip alpha channel
  520. # Search internal filaments - only match Bambu Lab vendor
  521. filaments = await self.get_filaments()
  522. for filament in filaments:
  523. # Only match filaments from Bambu Lab vendor
  524. fil_vendor_id = filament.get("vendor_id") or filament.get("vendor", {}).get("id")
  525. if fil_vendor_id != bambu_vendor_id:
  526. continue
  527. # Match by material and color (handle None values)
  528. fil_material = filament.get("material") or ""
  529. fil_color = filament.get("color_hex") or ""
  530. if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
  531. return filament
  532. # Search external filaments (Bambu library)
  533. external = await self.get_external_filaments()
  534. for filament in external:
  535. fil_material = filament.get("material") or ""
  536. fil_color = filament.get("color_hex") or ""
  537. if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
  538. # Found in external library - need to create internal copy
  539. return await self._create_filament_from_external(filament, tray)
  540. # Not found - create new Bambu Lab filament
  541. return await self.create_filament(
  542. name=tray.tray_sub_brands or tray.tray_type,
  543. vendor_id=bambu_vendor_id,
  544. material=tray.tray_type,
  545. color_hex=color_hex,
  546. weight=tray.tray_weight,
  547. )
  548. async def _create_filament_from_external(self, external: dict, tray: AMSTray) -> dict | None:
  549. """Create internal filament from external library entry.
  550. Args:
  551. external: External filament dictionary
  552. tray: The AMSTray for additional info
  553. Returns:
  554. Created filament dictionary or None on failure.
  555. """
  556. vendor_id = await self.ensure_bambu_vendor()
  557. return await self.create_filament(
  558. name=external.get("name", tray.tray_sub_brands),
  559. vendor_id=vendor_id,
  560. material=external.get("material", tray.tray_type),
  561. color_hex=external.get("color_hex", tray.tray_color[:6]),
  562. weight=external.get("weight", tray.tray_weight),
  563. )
  564. # Global client instance (initialized when settings are loaded)
  565. _spoolman_client: SpoolmanClient | None = None
  566. async def get_spoolman_client() -> SpoolmanClient | None:
  567. """Get the global Spoolman client instance.
  568. Returns:
  569. SpoolmanClient instance or None if not configured.
  570. """
  571. return _spoolman_client
  572. async def init_spoolman_client(url: str) -> SpoolmanClient:
  573. """Initialize the global Spoolman client.
  574. Args:
  575. url: Spoolman server URL
  576. Returns:
  577. Initialized SpoolmanClient instance.
  578. """
  579. global _spoolman_client
  580. if _spoolman_client:
  581. await _spoolman_client.close()
  582. _spoolman_client = SpoolmanClient(url)
  583. return _spoolman_client
  584. async def close_spoolman_client():
  585. """Close the global Spoolman client."""
  586. global _spoolman_client
  587. if _spoolman_client:
  588. await _spoolman_client.close()
  589. _spoolman_client = None