spoolman.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  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_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(
  142. f"{self.api_url}/vendor", json={"name": name}
  143. )
  144. response.raise_for_status()
  145. return response.json()
  146. except Exception as e:
  147. logger.error(f"Failed to create vendor in Spoolman: {e}")
  148. return None
  149. def _get_material_density(self, material: str | None) -> float:
  150. """Get typical density for a filament material type.
  151. Args:
  152. material: Material type (PLA, PETG, ABS, etc.)
  153. Returns:
  154. Density in g/cm³
  155. """
  156. # Typical densities for common filament materials
  157. densities = {
  158. "PLA": 1.24,
  159. "PLA-CF": 1.29,
  160. "PLA-S": 1.24,
  161. "PETG": 1.27,
  162. "ABS": 1.04,
  163. "ASA": 1.07,
  164. "TPU": 1.21,
  165. "PA": 1.14, # Nylon
  166. "PA-CF": 1.20,
  167. "PC": 1.20,
  168. "PVA": 1.23,
  169. "HIPS": 1.04,
  170. "PP": 0.90,
  171. "PET": 1.38,
  172. }
  173. if material:
  174. # Try exact match first, then uppercase
  175. mat_upper = material.upper()
  176. for key, density in densities.items():
  177. if key.upper() == mat_upper or mat_upper.startswith(key.upper()):
  178. return density
  179. return 1.24 # Default to PLA density
  180. async def create_filament(
  181. self,
  182. name: str,
  183. vendor_id: int | None = None,
  184. material: str | None = None,
  185. color_hex: str | None = None,
  186. weight: float | None = None,
  187. diameter: float = 1.75,
  188. density: float | None = None,
  189. ) -> dict | None:
  190. """Create a new filament in Spoolman.
  191. Args:
  192. name: Filament name
  193. vendor_id: Vendor ID
  194. material: Material type (PLA, PETG, etc.)
  195. color_hex: Color in hex format (without #)
  196. weight: Net weight in grams
  197. diameter: Filament diameter in mm (default 1.75)
  198. density: Filament density in g/cm³ (auto-calculated if not provided)
  199. Returns:
  200. Created filament dictionary or None on failure.
  201. """
  202. # Validate required fields
  203. if not name or not name.strip():
  204. logger.error("Cannot create filament: name is required")
  205. return None
  206. try:
  207. # Calculate density from material if not provided
  208. if density is None:
  209. density = self._get_material_density(material)
  210. data = {
  211. "name": name.strip(),
  212. "diameter": diameter,
  213. "density": density,
  214. }
  215. if vendor_id:
  216. data["vendor_id"] = vendor_id
  217. if material:
  218. data["material"] = material
  219. if color_hex:
  220. # Strip alpha channel if present (RRGGBBAA -> RRGGBB)
  221. color_hex = color_hex[:6] if len(color_hex) >= 6 else color_hex
  222. data["color_hex"] = color_hex
  223. if weight:
  224. data["weight"] = weight
  225. logger.debug(f"Creating filament in Spoolman: {data}")
  226. client = await self._get_client()
  227. response = await client.post(f"{self.api_url}/filament", json=data)
  228. response.raise_for_status()
  229. return response.json()
  230. except httpx.HTTPStatusError as e:
  231. logger.error(f"Failed to create filament in Spoolman: {e}, response: {e.response.text}")
  232. return None
  233. except Exception as e:
  234. logger.error(f"Failed to create filament in Spoolman: {e}")
  235. return None
  236. async def create_spool(
  237. self,
  238. filament_id: int,
  239. remaining_weight: float | None = None,
  240. location: str | None = None,
  241. lot_nr: str | None = None,
  242. comment: str | None = None,
  243. extra: dict | None = None,
  244. ) -> dict | None:
  245. """Create a new spool in Spoolman.
  246. Args:
  247. filament_id: ID of the filament type
  248. remaining_weight: Remaining weight in grams
  249. location: Physical location description
  250. lot_nr: Lot/batch number
  251. comment: Optional comment
  252. extra: Extra fields (e.g., {"tag": "RFID_TAG_UID"})
  253. Returns:
  254. Created spool dictionary or None on failure.
  255. """
  256. try:
  257. data = {"filament_id": filament_id}
  258. if remaining_weight is not None:
  259. data["remaining_weight"] = remaining_weight
  260. if location:
  261. data["location"] = location
  262. if lot_nr:
  263. data["lot_nr"] = lot_nr
  264. if comment:
  265. data["comment"] = comment
  266. if extra:
  267. data["extra"] = extra
  268. logger.debug(f"Creating spool in Spoolman: {data}")
  269. client = await self._get_client()
  270. response = await client.post(f"{self.api_url}/spool", json=data)
  271. response.raise_for_status()
  272. return response.json()
  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(timezone.utc).isoformat()
  305. client = await self._get_client()
  306. response = await client.patch(
  307. f"{self.api_url}/spool/{spool_id}", json=data
  308. )
  309. response.raise_for_status()
  310. return response.json()
  311. except Exception as e:
  312. logger.error(f"Failed to update spool in Spoolman: {e}")
  313. return None
  314. async def use_spool(self, spool_id: int, used_weight: float) -> dict | None:
  315. """Record filament usage for a spool.
  316. Args:
  317. spool_id: ID of the spool
  318. used_weight: Amount of filament used in grams
  319. Returns:
  320. Updated spool dictionary or None on failure.
  321. """
  322. try:
  323. client = await self._get_client()
  324. response = await client.put(
  325. f"{self.api_url}/spool/{spool_id}/use",
  326. json={"use_weight": used_weight},
  327. )
  328. response.raise_for_status()
  329. return response.json()
  330. except Exception as e:
  331. logger.error(f"Failed to record spool usage in Spoolman: {e}")
  332. return None
  333. async def find_spool_by_tag(self, tag_uid: str) -> dict | None:
  334. """Find a spool by its RFID tag UID.
  335. Args:
  336. tag_uid: The RFID tag UID to search for
  337. Returns:
  338. Spool dictionary or None if not found.
  339. """
  340. spools = await self.get_spools()
  341. # Normalize tag_uid for comparison (uppercase, strip quotes)
  342. search_tag = tag_uid.strip('"').upper()
  343. for spool in spools:
  344. extra = spool.get("extra", {})
  345. if extra:
  346. stored_tag = extra.get("tag", "")
  347. # Normalize stored tag (strip quotes, uppercase)
  348. if stored_tag:
  349. normalized_tag = stored_tag.strip('"').upper()
  350. if normalized_tag == search_tag:
  351. logger.debug(f"Found spool {spool['id']} matching tag {tag_uid}")
  352. return spool
  353. return None
  354. async def ensure_bambu_vendor(self) -> int | None:
  355. """Ensure Bambu Lab vendor exists and return its ID.
  356. Returns:
  357. Vendor ID or None on failure.
  358. """
  359. vendors = await self.get_vendors()
  360. for vendor in vendors:
  361. if vendor.get("name", "").lower() == "bambu lab":
  362. return vendor["id"]
  363. # Create Bambu Lab vendor if not exists
  364. vendor = await self.create_vendor("Bambu Lab")
  365. return vendor["id"] if vendor else None
  366. def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
  367. """Parse AMS tray data into AMSTray object.
  368. Args:
  369. ams_id: The AMS unit ID (0-3 for regular, 128-135 for external)
  370. tray_data: Raw tray data from MQTT
  371. Returns:
  372. AMSTray object or None if tray is empty or invalid.
  373. """
  374. # Skip empty trays - check for valid tray_type
  375. tray_type = tray_data.get("tray_type", "")
  376. if not tray_type or tray_type.strip() == "":
  377. return None
  378. # Need valid color to create filament
  379. tray_color = tray_data.get("tray_color", "")
  380. if not tray_color or tray_color in ("", "00000000"):
  381. logger.debug(f"Skipping tray with invalid color: {tray_color}")
  382. return None
  383. # Get sub_brands, falling back to tray_type
  384. tray_sub_brands = tray_data.get("tray_sub_brands", "")
  385. if not tray_sub_brands or tray_sub_brands.strip() == "":
  386. tray_sub_brands = tray_type
  387. # Get tag_uid and tray_uuid, filtering out empty/invalid values
  388. tag_uid = tray_data.get("tag_uid", "")
  389. if tag_uid in ("", "0000000000000000"):
  390. tag_uid = ""
  391. tray_uuid = tray_data.get("tray_uuid", "")
  392. if tray_uuid in ("", "00000000000000000000000000000000"):
  393. tray_uuid = ""
  394. # Get remaining percentage, ensure non-negative
  395. remain = max(0, int(tray_data.get("remain", 0)))
  396. return AMSTray(
  397. ams_id=ams_id,
  398. tray_id=int(tray_data.get("id", 0)),
  399. tray_type=tray_type.strip(),
  400. tray_sub_brands=tray_sub_brands.strip(),
  401. tray_color=tray_color,
  402. remain=remain,
  403. tag_uid=tag_uid,
  404. tray_uuid=tray_uuid,
  405. tray_weight=int(tray_data.get("tray_weight", 1000)),
  406. )
  407. def convert_ams_slot_to_location(self, ams_id: int, tray_id: int) -> str:
  408. """Convert AMS ID and tray ID to human-readable location.
  409. Args:
  410. ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for external)
  411. tray_id: Tray ID within the AMS (0-3)
  412. Returns:
  413. Location string like "AMS A1", "AMS B2", "External"
  414. """
  415. if ams_id >= 128:
  416. return "External Spool"
  417. ams_letter = chr(ord("A") + ams_id)
  418. return f"AMS {ams_letter}{tray_id + 1}"
  419. def is_bambu_lab_spool(self, tray_uuid: str) -> bool:
  420. """Check if a tray has a valid Bambu Lab spool UUID.
  421. Bambu Lab spools have a tray_uuid which is a 32-character hex string.
  422. This UUID is consistent across all printer models (unlike tag_uid which
  423. varies between X1C/H2D readers).
  424. Non-Bambu Lab spools (SpoolEase, third-party) won't have a valid tray_uuid.
  425. Args:
  426. tray_uuid: The tray UUID to check
  427. Returns:
  428. True if the spool has a valid Bambu Lab UUID, False otherwise.
  429. """
  430. if not tray_uuid:
  431. return False
  432. # Bambu Lab tray_uuid is always 32 hex characters
  433. uuid = tray_uuid.strip()
  434. if len(uuid) != 32:
  435. return False
  436. # Verify it's all hex characters and not empty/zero
  437. if uuid == "00000000000000000000000000000000":
  438. return False
  439. try:
  440. int(uuid, 16)
  441. return True
  442. except ValueError:
  443. return False
  444. def calculate_remaining_weight(
  445. self, remain_percent: int, spool_weight: int
  446. ) -> float:
  447. """Calculate remaining weight from percentage.
  448. Args:
  449. remain_percent: Remaining percentage (0-100)
  450. spool_weight: Total spool weight in grams
  451. Returns:
  452. Remaining weight in grams.
  453. """
  454. return (remain_percent / 100.0) * spool_weight
  455. async def sync_ams_tray(
  456. self, tray: AMSTray, printer_name: str
  457. ) -> dict | None:
  458. """Sync a single AMS tray to Spoolman.
  459. Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters).
  460. Non-Bambu Lab spools (SpoolEase/third-party) are skipped.
  461. Uses tray_uuid for matching, as it's consistent across all printer models
  462. (unlike tag_uid which varies between X1C/H2D readers).
  463. Args:
  464. tray: The AMSTray to sync
  465. printer_name: Name of the printer for location
  466. Returns:
  467. Synced spool dictionary or None if skipped or failed.
  468. """
  469. logger.debug(
  470. f"Processing {printer_name} AMS {tray.ams_id} tray {tray.tray_id}: "
  471. f"type={tray.tray_type}, uuid={tray.tray_uuid[:16] if tray.tray_uuid else 'none'}..."
  472. )
  473. # Only sync trays with valid Bambu Lab tray_uuid
  474. if not self.is_bambu_lab_spool(tray.tray_uuid):
  475. if tray.tray_uuid or tray.tag_uid:
  476. logger.info(
  477. f"Skipping non-Bambu Lab spool: {printer_name} AMS {tray.ams_id} tray {tray.tray_id} "
  478. f"(tray_uuid={tray.tray_uuid}, tag_uid={tray.tag_uid})"
  479. )
  480. else:
  481. logger.debug(
  482. f"Skipping tray without RFID tag: AMS {tray.ams_id} tray {tray.tray_id}"
  483. )
  484. return None
  485. # Calculate remaining weight
  486. remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
  487. location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
  488. # Find existing spool by tray_uuid (stored as "tag" in Spoolman)
  489. existing = await self.find_spool_by_tag(tray.tray_uuid)
  490. if existing:
  491. # Update existing spool
  492. logger.info(
  493. f"Updating existing spool {existing['id']} for tray_uuid {tray.tray_uuid}"
  494. )
  495. return await self.update_spool(
  496. spool_id=existing["id"],
  497. remaining_weight=remaining,
  498. location=location,
  499. )
  500. # Spool not found - auto-create it
  501. logger.info(
  502. f"Creating new spool in Spoolman for {tray.tray_sub_brands} "
  503. f"(tray_uuid: {tray.tray_uuid[:16]}...)"
  504. )
  505. # First find or create the filament type
  506. filament = await self._find_or_create_filament(tray)
  507. if not filament:
  508. logger.error(f"Failed to find or create filament for {tray.tray_sub_brands}")
  509. return None
  510. # Create the spool with tray_uuid stored as "tag" in extra field
  511. return await self.create_spool(
  512. filament_id=filament["id"],
  513. remaining_weight=remaining,
  514. location=location,
  515. comment=f"Auto-created from {printer_name} AMS",
  516. extra={"tag": tray.tray_uuid},
  517. )
  518. async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
  519. """Find existing filament or create new one.
  520. Args:
  521. tray: The AMSTray containing filament info
  522. Returns:
  523. Filament dictionary or None on failure.
  524. """
  525. # Search internal filaments first
  526. filaments = await self.get_filaments()
  527. color_hex = tray.tray_color[:6] # Strip alpha channel
  528. for filament in filaments:
  529. # Match by material and color (handle None values)
  530. fil_material = filament.get("material") or ""
  531. fil_color = filament.get("color_hex") or ""
  532. if (
  533. fil_material.upper() == tray.tray_type.upper()
  534. and fil_color.upper() == color_hex.upper()
  535. ):
  536. return filament
  537. # Search external filaments (Bambu library)
  538. external = await self.get_external_filaments()
  539. for filament in external:
  540. fil_material = filament.get("material") or ""
  541. fil_color = filament.get("color_hex") or ""
  542. if (
  543. fil_material.upper() == tray.tray_type.upper()
  544. and fil_color.upper() == color_hex.upper()
  545. ):
  546. # Found in external library - need to create internal copy
  547. return await self._create_filament_from_external(filament, tray)
  548. # Not found - create new filament
  549. vendor_id = await self.ensure_bambu_vendor()
  550. return await self.create_filament(
  551. name=tray.tray_sub_brands or tray.tray_type,
  552. vendor_id=vendor_id,
  553. material=tray.tray_type,
  554. color_hex=color_hex,
  555. weight=tray.tray_weight,
  556. )
  557. async def _create_filament_from_external(
  558. self, external: dict, tray: AMSTray
  559. ) -> dict | None:
  560. """Create internal filament from external library entry.
  561. Args:
  562. external: External filament dictionary
  563. tray: The AMSTray for additional info
  564. Returns:
  565. Created filament dictionary or None on failure.
  566. """
  567. vendor_id = await self.ensure_bambu_vendor()
  568. return await self.create_filament(
  569. name=external.get("name", tray.tray_sub_brands),
  570. vendor_id=vendor_id,
  571. material=external.get("material", tray.tray_type),
  572. color_hex=external.get("color_hex", tray.tray_color[:6]),
  573. weight=external.get("weight", tray.tray_weight),
  574. )
  575. # Global client instance (initialized when settings are loaded)
  576. _spoolman_client: SpoolmanClient | None = None
  577. async def get_spoolman_client() -> SpoolmanClient | None:
  578. """Get the global Spoolman client instance.
  579. Returns:
  580. SpoolmanClient instance or None if not configured.
  581. """
  582. return _spoolman_client
  583. async def init_spoolman_client(url: str) -> SpoolmanClient:
  584. """Initialize the global Spoolman client.
  585. Args:
  586. url: Spoolman server URL
  587. Returns:
  588. Initialized SpoolmanClient instance.
  589. """
  590. global _spoolman_client
  591. if _spoolman_client:
  592. await _spoolman_client.close()
  593. _spoolman_client = SpoolmanClient(url)
  594. return _spoolman_client
  595. async def close_spoolman_client():
  596. """Close the global Spoolman client."""
  597. global _spoolman_client
  598. if _spoolman_client:
  599. await _spoolman_client.close()
  600. _spoolman_client = None