spoolman.py 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305
  1. """Spoolman integration service for syncing AMS filament data."""
  2. import asyncio
  3. import logging
  4. from dataclasses import dataclass
  5. from datetime import datetime, timezone
  6. from typing import Literal
  7. import httpx
  8. logger = logging.getLogger(__name__)
  9. BAMBU_RFID_TAG_LENGTH = 32
  10. @dataclass
  11. class SpoolmanSpool:
  12. """Represents a spool in Spoolman."""
  13. id: int
  14. filament_id: int | None
  15. remaining_weight: float | None
  16. used_weight: float
  17. first_used: str | None
  18. last_used: str | None
  19. location: str | None
  20. lot_nr: str | None
  21. comment: str | None
  22. extra: dict | None # Contains tag_uid in extra.tag
  23. @dataclass
  24. class SpoolmanFilament:
  25. """Represents a filament type in Spoolman."""
  26. id: int
  27. name: str
  28. vendor_id: int | None
  29. material: str | None
  30. color_hex: str | None
  31. weight: float | None # Net weight in grams
  32. @dataclass
  33. class AMSTray:
  34. """Represents an AMS tray with filament data from Bambu printer."""
  35. ams_id: int # 0-3 for regular AMS, 128-135 for AMS-HT, 254+ for external spool
  36. tray_id: int # 0-3
  37. tray_type: str # PLA, PETG, ABS, etc.
  38. tray_sub_brands: str # Full name like "PLA Basic", "PETG HF"
  39. tray_color: str # Hex color like "FEC600FF"
  40. remain: int # Remaining percentage (0-100)
  41. tag_uid: str # RFID tag UID
  42. tray_uuid: str # Spool UUID
  43. tray_info_idx: str # Bambu filament preset ID like "GFA00"
  44. tray_weight: int # Spool weight in grams (usually 1000)
  45. class SpoolmanNotFoundError(Exception):
  46. """Raised when a spool ID does not exist in Spoolman (HTTP 404)."""
  47. class SpoolmanUnavailableError(Exception):
  48. """Raised when Spoolman is unreachable or returns a server/network error."""
  49. class SpoolmanClientError(Exception):
  50. """Raised when Spoolman returns a 4xx client error (not 404).
  51. Indicates the request was malformed or rejected by Spoolman, not a connectivity failure.
  52. """
  53. def __init__(self, message: str, status_code: int):
  54. super().__init__(message)
  55. self.status_code = status_code
  56. class SpoolmanClient:
  57. """Client for interacting with Spoolman API."""
  58. def __init__(self, base_url: str):
  59. """Initialize the Spoolman client.
  60. Args:
  61. base_url: The base URL of the Spoolman server (e.g., http://localhost:7912)
  62. """
  63. self.base_url = base_url.rstrip("/")
  64. self.api_url = f"{self.base_url}/api/v1"
  65. self._client: httpx.AsyncClient | None = None
  66. self._connected = False
  67. # Per-spool locks for atomic read-modify-write in merge_spool_extra.
  68. self._extra_locks: dict[int, asyncio.Lock] = {}
  69. async def _get_client(self) -> httpx.AsyncClient:
  70. """Get or create the HTTP client with connection pooling limits.
  71. Configures the client to prevent idle connection issues:
  72. - max_keepalive_connections=5: Limit number of persistent connections
  73. - keepalive_expiry=30: Close idle connections after 30 seconds
  74. - max_connections=10: Limit total connections to prevent resource exhaustion
  75. """
  76. if self._client is None:
  77. self._client = httpx.AsyncClient(
  78. timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0),
  79. follow_redirects=False,
  80. verify=True,
  81. limits=httpx.Limits(
  82. max_keepalive_connections=5,
  83. max_connections=10,
  84. keepalive_expiry=30.0,
  85. ),
  86. )
  87. return self._client
  88. async def close(self):
  89. """Close the HTTP client."""
  90. if self._client:
  91. await self._client.aclose()
  92. self._client = None
  93. async def health_check(self) -> bool:
  94. """Check if Spoolman server is reachable.
  95. Returns:
  96. True if server is healthy, False otherwise.
  97. """
  98. try:
  99. client = await self._get_client()
  100. response = await client.get(f"{self.api_url}/health")
  101. self._connected = response.status_code == 200
  102. return self._connected
  103. except Exception as e:
  104. logger.warning("Spoolman health check failed: %s", e)
  105. self._connected = False
  106. return False
  107. @property
  108. def is_connected(self) -> bool:
  109. """Check if client is connected to Spoolman."""
  110. return self._connected
  111. async def get_spools(self) -> list[dict]:
  112. """Get all spools from Spoolman with retry logic.
  113. Attempts to fetch spools up to 3 times with 500ms delay between attempts.
  114. This handles transient network errors like closed connections.
  115. Returns:
  116. List of spool dictionaries.
  117. Raises:
  118. Exception: If all 3 retry attempts fail.
  119. """
  120. max_attempts = 3
  121. retry_delay = 0.5 # 500ms
  122. for attempt in range(1, max_attempts + 1):
  123. try:
  124. client = await self._get_client()
  125. response = await client.get(f"{self.api_url}/spool")
  126. response.raise_for_status()
  127. spools = response.json()
  128. if attempt > 1:
  129. logger.info("Successfully fetched %d spools on attempt %d", len(spools), attempt)
  130. return spools
  131. except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ConnectError) as e:
  132. # Connection-related errors - close and recreate client for next attempt
  133. if attempt < max_attempts:
  134. logger.warning(
  135. "Connection error getting spools (attempt %d/%d): %s. Recreating client and retrying in %dms...",
  136. attempt,
  137. max_attempts,
  138. e,
  139. int(retry_delay * 1000),
  140. )
  141. # Close the stale client and recreate it
  142. await self.close()
  143. await asyncio.sleep(retry_delay)
  144. else:
  145. logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
  146. raise
  147. except Exception as e:
  148. # Other errors (HTTP errors, JSON decode errors, etc.)
  149. if attempt < max_attempts:
  150. logger.warning(
  151. "Failed to get spools from Spoolman (attempt %d/%d): %s. Retrying in %dms...",
  152. attempt,
  153. max_attempts,
  154. e,
  155. int(retry_delay * 1000),
  156. )
  157. await asyncio.sleep(retry_delay)
  158. else:
  159. logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
  160. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  161. async def get_filaments(self) -> list[dict]:
  162. """Get all internal filaments from Spoolman.
  163. Returns:
  164. List of filament dictionaries.
  165. Raises:
  166. SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
  167. """
  168. try:
  169. client = await self._get_client()
  170. response = await client.get(f"{self.api_url}/filament")
  171. response.raise_for_status()
  172. return response.json()
  173. except Exception as e:
  174. logger.error("Failed to get filaments from Spoolman: %s", e)
  175. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  176. async def get_external_filaments(self) -> list[dict]:
  177. """Get external/library filaments from Spoolman.
  178. Returns:
  179. List of external filament dictionaries.
  180. Raises:
  181. SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
  182. """
  183. try:
  184. client = await self._get_client()
  185. response = await client.get(f"{self.api_url}/external/filament")
  186. response.raise_for_status()
  187. return response.json()
  188. except Exception as e:
  189. logger.error("Failed to get external filaments from Spoolman: %s", e)
  190. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  191. async def get_vendors(self) -> list[dict]:
  192. """Get all vendors from Spoolman.
  193. Returns:
  194. List of vendor dictionaries.
  195. Raises:
  196. SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
  197. """
  198. try:
  199. client = await self._get_client()
  200. response = await client.get(f"{self.api_url}/vendor")
  201. response.raise_for_status()
  202. return response.json()
  203. except Exception as e:
  204. logger.error("Failed to get vendors from Spoolman: %s", e)
  205. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  206. async def create_vendor(self, name: str) -> dict | None:
  207. """Create a new vendor in Spoolman.
  208. Args:
  209. name: Vendor name (e.g., "Bambu Lab")
  210. Returns:
  211. Created vendor dictionary or None on failure.
  212. """
  213. try:
  214. client = await self._get_client()
  215. response = await client.post(f"{self.api_url}/vendor", json={"name": name})
  216. response.raise_for_status()
  217. return response.json()
  218. except Exception as e:
  219. logger.error("Failed to create vendor in Spoolman: %s", e)
  220. return None
  221. def _get_material_density(self, material: str | None) -> float:
  222. """Get typical density for a filament material type.
  223. Args:
  224. material: Material type (PLA, PETG, ABS, etc.)
  225. Returns:
  226. Density in g/cm³
  227. """
  228. # Typical densities for common filament materials
  229. densities = {
  230. "PLA": 1.24,
  231. "PLA-CF": 1.29,
  232. "PLA-S": 1.24,
  233. "PETG": 1.27,
  234. "ABS": 1.04,
  235. "ASA": 1.07,
  236. "TPU": 1.21,
  237. "PA": 1.14, # Nylon
  238. "PA-CF": 1.20,
  239. "PC": 1.20,
  240. "PVA": 1.23,
  241. "HIPS": 1.04,
  242. "PP": 0.90,
  243. "PET": 1.38,
  244. }
  245. if material:
  246. # Try exact match first, then uppercase
  247. mat_upper = material.upper()
  248. for key, density in densities.items():
  249. if key.upper() == mat_upper or mat_upper.startswith(key.upper()):
  250. return density
  251. return 1.24 # Default to PLA density
  252. async def create_filament(
  253. self,
  254. name: str,
  255. vendor_id: int | None = None,
  256. material: str | None = None,
  257. color_hex: str | None = None,
  258. color_name: str | None = None,
  259. weight: float | None = None,
  260. diameter: float = 1.75,
  261. density: float | None = None,
  262. ) -> dict | None:
  263. """Create a new filament in Spoolman.
  264. Args:
  265. name: Filament name
  266. vendor_id: Vendor ID
  267. material: Material type (PLA, PETG, etc.)
  268. color_hex: Color in hex format (without #)
  269. color_name: Human-readable colour name (e.g. "Bambu Green")
  270. weight: Net weight in grams
  271. diameter: Filament diameter in mm (default 1.75)
  272. density: Filament density in g/cm³ (auto-calculated if not provided)
  273. Returns:
  274. Created filament dictionary or None on failure.
  275. """
  276. # Validate required fields
  277. if not name or not name.strip():
  278. logger.error("Cannot create filament: name is required")
  279. return None
  280. try:
  281. # Calculate density from material if not provided
  282. if density is None:
  283. density = self._get_material_density(material)
  284. data = {
  285. "name": name.strip(),
  286. "diameter": diameter,
  287. "density": density,
  288. }
  289. if vendor_id:
  290. data["vendor_id"] = vendor_id
  291. if material:
  292. data["material"] = material
  293. if color_hex:
  294. # Strip alpha channel if present (RRGGBBAA -> RRGGBB)
  295. color_hex = color_hex[:6] if len(color_hex) >= 6 else color_hex
  296. data["color_hex"] = color_hex
  297. if color_name:
  298. data["color_name"] = color_name
  299. if weight:
  300. data["weight"] = weight
  301. logger.debug("Creating filament in Spoolman: %s", data)
  302. client = await self._get_client()
  303. response = await client.post(f"{self.api_url}/filament", json=data)
  304. response.raise_for_status()
  305. return response.json()
  306. except httpx.HTTPStatusError as e:
  307. logger.error("Failed to create filament in Spoolman: %s, response: %s", e, e.response.text)
  308. return None
  309. except Exception as e:
  310. logger.error("Failed to create filament in Spoolman: %s", e)
  311. return None
  312. async def create_spool(
  313. self,
  314. filament_id: int,
  315. remaining_weight: float | None = None,
  316. location: str | None = None,
  317. lot_nr: str | None = None,
  318. comment: str | None = None,
  319. extra: dict | None = None,
  320. ) -> dict | None:
  321. """Create a new spool in Spoolman.
  322. Args:
  323. filament_id: ID of the filament type
  324. remaining_weight: Remaining weight in grams
  325. location: Physical location description
  326. lot_nr: Lot/batch number
  327. comment: Optional comment
  328. extra: Extra fields (e.g., {"tag": "RFID_TAG_UID"})
  329. Returns:
  330. Created spool dictionary or None on failure.
  331. """
  332. try:
  333. data = {"filament_id": filament_id}
  334. if remaining_weight is not None:
  335. data["remaining_weight"] = remaining_weight
  336. if location:
  337. data["location"] = location
  338. if lot_nr:
  339. data["lot_nr"] = lot_nr
  340. if comment:
  341. data["comment"] = comment
  342. if extra:
  343. data["extra"] = extra
  344. logger.debug("Creating spool in Spoolman: %s", data)
  345. client = await self._get_client()
  346. response = await client.post(f"{self.api_url}/spool", json=data)
  347. response.raise_for_status()
  348. result = response.json()
  349. logger.info("Created spool %s in Spoolman", result.get("id"))
  350. return result
  351. except httpx.HTTPStatusError as e:
  352. logger.error("Failed to create spool in Spoolman: %s, response: %s", e, e.response.text)
  353. return None
  354. except Exception as e:
  355. logger.error("Failed to create spool in Spoolman: %s", e)
  356. return None
  357. async def update_spool(
  358. self,
  359. spool_id: int,
  360. remaining_weight: float | None = None,
  361. location: str | None = None,
  362. clear_location: bool = False,
  363. extra: dict | None = None,
  364. ) -> dict | None:
  365. """Update an existing spool in Spoolman.
  366. Args:
  367. spool_id: ID of the spool to update
  368. remaining_weight: New remaining weight in grams
  369. location: New location (ignored if clear_location is True)
  370. clear_location: If True, clears the location field
  371. extra: Extra fields to update
  372. Returns:
  373. Updated spool dictionary or None on failure.
  374. """
  375. try:
  376. data = {}
  377. if remaining_weight is not None:
  378. data["remaining_weight"] = remaining_weight
  379. if clear_location:
  380. data["location"] = None
  381. elif location:
  382. data["location"] = location
  383. if extra:
  384. data["extra"] = extra
  385. # Always update last_used
  386. data["last_used"] = datetime.now(timezone.utc).isoformat()
  387. client = await self._get_client()
  388. response = await client.patch(f"{self.api_url}/spool/{spool_id}", json=data)
  389. response.raise_for_status()
  390. return response.json()
  391. except Exception as e:
  392. logger.error("Failed to update spool in Spoolman: %s", e)
  393. return None
  394. async def _request_spool(
  395. self,
  396. method: Literal["GET", "PATCH", "DELETE"],
  397. spool_id: int,
  398. *,
  399. json_body: dict | None = None,
  400. operation: str,
  401. ) -> httpx.Response:
  402. """Perform a spool-scoped HTTP request, translating 404 and errors to named exceptions."""
  403. try:
  404. client = await self._get_client()
  405. response = await client.request(
  406. method,
  407. f"{self.api_url}/spool/{spool_id}",
  408. json=json_body,
  409. )
  410. if response.status_code == 404:
  411. raise SpoolmanNotFoundError(f"Spool {spool_id} not found in Spoolman")
  412. response.raise_for_status()
  413. return response
  414. except SpoolmanNotFoundError:
  415. raise
  416. except httpx.HTTPStatusError as e:
  417. if 400 <= e.response.status_code < 500:
  418. logger.warning(
  419. "Spoolman returned %d for %s spool %s",
  420. e.response.status_code,
  421. operation,
  422. spool_id,
  423. )
  424. raise SpoolmanClientError(
  425. f"Spoolman rejected {operation} for spool {spool_id} (HTTP {e.response.status_code})",
  426. e.response.status_code,
  427. ) from e
  428. else:
  429. logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e)
  430. raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e
  431. except Exception as e:
  432. logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e)
  433. raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e
  434. async def get_spool(self, spool_id: int) -> dict:
  435. """Get a single spool by ID from Spoolman.
  436. Args:
  437. spool_id: Spoolman spool ID
  438. Returns:
  439. Spool dictionary.
  440. Raises:
  441. SpoolmanNotFoundError: If the spool does not exist (HTTP 404).
  442. SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
  443. """
  444. response = await self._request_spool("GET", spool_id, operation="get")
  445. return response.json()
  446. async def get_all_spools(self, allow_archived: bool = False) -> list[dict]:
  447. """Get all spools from Spoolman, optionally including archived ones.
  448. Args:
  449. allow_archived: If True, include archived spools in the result.
  450. Returns:
  451. List of spool dictionaries.
  452. Raises:
  453. SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
  454. """
  455. try:
  456. client = await self._get_client()
  457. params: dict = {}
  458. if allow_archived:
  459. params["allow_archived"] = "true"
  460. response = await client.get(f"{self.api_url}/spool", params=params or None)
  461. response.raise_for_status()
  462. return response.json()
  463. except Exception as e:
  464. logger.error("Failed to get all spools from Spoolman: %s", e)
  465. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  466. async def delete_spool(self, spool_id: int) -> None:
  467. """Delete a spool from Spoolman.
  468. Args:
  469. spool_id: Spoolman spool ID
  470. Raises:
  471. SpoolmanNotFoundError: If the spool does not exist (HTTP 404).
  472. SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
  473. """
  474. await self._request_spool("DELETE", spool_id, operation="delete")
  475. async def set_spool_archived(self, spool_id: int, archived: bool) -> dict:
  476. """Archive or restore a spool in Spoolman.
  477. Args:
  478. spool_id: Spoolman spool ID
  479. archived: True to archive, False to restore.
  480. Returns:
  481. Updated spool dictionary.
  482. Raises:
  483. SpoolmanNotFoundError: If the spool does not exist (HTTP 404).
  484. SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
  485. """
  486. response = await self._request_spool(
  487. "PATCH",
  488. spool_id,
  489. json_body={"archived": archived},
  490. operation="archive/restore",
  491. )
  492. return response.json()
  493. async def update_spool_full(
  494. self,
  495. spool_id: int,
  496. *,
  497. filament_id: int | None = None,
  498. remaining_weight: float | None = None,
  499. comment: str | None = None,
  500. price: float | None = None,
  501. location: str | None = None,
  502. clear_location: bool = False,
  503. extra: dict | None = None,
  504. ) -> dict:
  505. """Update a spool in Spoolman with comprehensive field support.
  506. Unlike update_spool, this method does not auto-set last_used and
  507. supports updating filament_id, comment, and price.
  508. Args:
  509. spool_id: Spoolman spool ID
  510. filament_id: New filament type ID
  511. remaining_weight: New remaining weight in grams
  512. comment: New comment/note (pass empty string to clear)
  513. price: Cost per unit (maps to cost_per_kg usage)
  514. location: New location string
  515. clear_location: If True, sets location to None
  516. extra: Extra fields dict to replace (overwrites the entire extra dict;
  517. use merge_spool_extra to preserve other fields)
  518. Returns:
  519. Updated spool dictionary.
  520. Raises:
  521. SpoolmanNotFoundError: If the spool does not exist (HTTP 404).
  522. SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error.
  523. """
  524. data: dict = {}
  525. if filament_id is not None:
  526. data["filament_id"] = filament_id
  527. if remaining_weight is not None:
  528. data["remaining_weight"] = remaining_weight
  529. if comment is not None:
  530. data["comment"] = comment if comment else None
  531. if price is not None:
  532. data["price"] = price
  533. if clear_location:
  534. data["location"] = None
  535. elif location is not None:
  536. data["location"] = location
  537. if extra is not None:
  538. data["extra"] = extra
  539. response = await self._request_spool("PATCH", spool_id, json_body=data, operation="update")
  540. return response.json()
  541. def extra_lock(self, spool_id: int) -> asyncio.Lock:
  542. """Return (creating if needed) the per-spool asyncio.Lock used by merge_spool_extra."""
  543. return self._extra_locks.setdefault(spool_id, asyncio.Lock())
  544. async def merge_spool_extra(self, spool_id: int, new_fields: dict) -> dict:
  545. """Fetch current extra dict, merge new_fields in, then PATCH back to Spoolman.
  546. Safe merge — never blindly overwrites other custom Spoolman extra fields.
  547. The operation is serialised per spool_id with an asyncio.Lock to prevent
  548. concurrent calls from clobbering each other's writes.
  549. Args:
  550. spool_id: Spoolman spool ID
  551. new_fields: Fields to add/update in the extra dict
  552. Returns:
  553. Updated spool dictionary.
  554. Raises:
  555. SpoolmanNotFoundError: If the spool does not exist.
  556. SpoolmanUnavailableError: If Spoolman is unreachable.
  557. """
  558. async with self.extra_lock(spool_id):
  559. current = await self.get_spool(spool_id) # raises on error
  560. current_extra: dict = current.get("extra") or {}
  561. merged = {**current_extra, **new_fields}
  562. return await self.update_spool_full(spool_id=spool_id, extra=merged)
  563. async def find_or_create_vendor(self, name: str) -> int | None:
  564. """Find an existing vendor by name or create a new one.
  565. Args:
  566. name: Vendor name (case-insensitive match)
  567. Returns:
  568. Vendor ID or None on failure.
  569. """
  570. vendors = await self.get_vendors()
  571. name_lower = name.strip().lower()
  572. for vendor in vendors:
  573. if vendor.get("name", "").strip().lower() == name_lower:
  574. return vendor["id"]
  575. created = await self.create_vendor(name.strip())
  576. return created["id"] if created else None
  577. async def find_or_create_filament(
  578. self,
  579. material: str,
  580. subtype: str,
  581. brand: str | None,
  582. color_hex: str,
  583. label_weight: int,
  584. color_name: str | None = None,
  585. ) -> int | None:
  586. """Find a matching filament in Spoolman or create a new one.
  587. Matching uses material + full name + vendor + color_hex (normalised).
  588. A new filament is created only when no exact match is found.
  589. Args:
  590. material: Filament material (e.g. "PLA")
  591. subtype: Filament subtype (e.g. "Basic"); combined with material as name
  592. brand: Vendor/brand name; None skips vendor matching
  593. color_hex: 6-char hex colour string (RRGGBB, no #)
  594. label_weight: Net spool weight in grams
  595. color_name: Human-readable colour name passed to create_filament when creating
  596. Returns:
  597. Filament ID or None on failure.
  598. """
  599. name = f"{material} {subtype}".strip() if subtype else material
  600. color = color_hex[:6].upper() if len(color_hex) >= 6 else color_hex.upper()
  601. vendor_id: int | None = None
  602. if brand:
  603. vendor_id = await self.find_or_create_vendor(brand)
  604. filaments = await self.get_filaments()
  605. for f in filaments:
  606. f_material = (f.get("material") or "").upper()
  607. f_name = (f.get("name") or "").strip()
  608. f_color = (f.get("color_hex") or "").upper()[:6]
  609. f_vendor = f.get("vendor") or {}
  610. f_vendor_name = (f_vendor.get("name") or "").strip().lower()
  611. material_match = f_material == material.upper()
  612. name_match = f_name.lower() == name.lower()
  613. color_match = f_color == color
  614. vendor_match = (not brand) or f_vendor_name == (brand or "").strip().lower()
  615. if material_match and name_match and color_match and vendor_match:
  616. return f["id"]
  617. filament = await self.create_filament(
  618. name=name,
  619. vendor_id=vendor_id,
  620. material=material,
  621. color_hex=color,
  622. color_name=color_name,
  623. weight=float(label_weight),
  624. )
  625. return filament["id"] if filament else None
  626. async def use_spool(self, spool_id: int, used_weight: float) -> dict | None:
  627. """Record filament usage for a spool.
  628. Args:
  629. spool_id: ID of the spool
  630. used_weight: Amount of filament used in grams
  631. Returns:
  632. Updated spool dictionary or None on failure.
  633. """
  634. try:
  635. client = await self._get_client()
  636. response = await client.put(
  637. f"{self.api_url}/spool/{spool_id}/use",
  638. json={"use_weight": used_weight},
  639. )
  640. response.raise_for_status()
  641. return response.json()
  642. except Exception as e:
  643. logger.error("Failed to record spool usage in Spoolman: %s", e)
  644. return None
  645. async def find_spool_by_tag(self, tag_uid: str, cached_spools: list[dict] | None = None) -> dict | None:
  646. """Find a spool by its RFID tag UID.
  647. Args:
  648. tag_uid: The RFID tag UID to search for
  649. cached_spools: Optional pre-fetched list of spools to search (avoids API call)
  650. Returns:
  651. Spool dictionary or None if not found.
  652. """
  653. # Use cached spools if provided, otherwise fetch from API
  654. spools = cached_spools if cached_spools is not None else await self.get_spools()
  655. # Normalize tag_uid for comparison (uppercase, strip quotes)
  656. search_tag = tag_uid.strip('"').upper()
  657. for spool in spools:
  658. extra = spool.get("extra", {})
  659. if extra:
  660. stored_tag = extra.get("tag", "")
  661. # Normalize stored tag (strip quotes, uppercase)
  662. if stored_tag:
  663. normalized_tag = stored_tag.strip('"').upper()
  664. if normalized_tag == search_tag:
  665. logger.debug("Found spool %s matching tag %s", spool["id"], tag_uid)
  666. return spool
  667. return None
  668. def _find_spool_by_location(self, location: str, cached_spools: list[dict] | None) -> dict | None:
  669. """Find a spool by exact location match.
  670. Used as fallback when RFID tag data is unavailable (e.g., newer firmware
  671. that doesn't expose tray_uuid/tag_uid via MQTT).
  672. Args:
  673. location: Exact location string (e.g., "H2D-1 - AMS A1")
  674. cached_spools: Pre-fetched list of spools to search
  675. Returns:
  676. Spool dictionary or None if not found.
  677. """
  678. if not cached_spools:
  679. return None
  680. for spool in cached_spools:
  681. if spool.get("location") == location:
  682. return spool
  683. return None
  684. async def find_spools_by_location_prefix(
  685. self, location_prefix: str, cached_spools: list[dict] | None = None
  686. ) -> list[dict]:
  687. """Find all spools with locations starting with a given prefix.
  688. Args:
  689. location_prefix: The location prefix to search for (e.g., "PrinterName - ")
  690. cached_spools: Optional pre-fetched list of spools to search (avoids API call)
  691. Returns:
  692. List of spool dictionaries with matching locations.
  693. """
  694. # Use cached spools if provided, otherwise fetch from API
  695. spools = cached_spools if cached_spools is not None else await self.get_spools()
  696. matching = []
  697. for spool in spools:
  698. location = spool.get("location", "")
  699. if location and location.startswith(location_prefix):
  700. matching.append(spool)
  701. return matching
  702. async def clear_location_for_removed_spools(
  703. self,
  704. printer_name: str,
  705. current_tray_uuids: set[str],
  706. cached_spools: list[dict] | None = None,
  707. synced_spool_ids: set[int] | None = None,
  708. ) -> int:
  709. """Clear location for spools that are no longer in the AMS.
  710. When a spool is removed from the AMS, its location should be cleared
  711. in Spoolman. This method finds all spools with locations for this printer
  712. and clears the location for any that are not in the current_tray_uuids set
  713. and were not synced in this cycle (synced_spool_ids).
  714. Args:
  715. printer_name: The printer name used as location prefix
  716. current_tray_uuids: Set of tray_uuids currently in the AMS
  717. cached_spools: Optional pre-fetched list of spools to search (avoids API call)
  718. synced_spool_ids: Set of spool IDs that were synced in this cycle
  719. (protects location-matched spools when RFID data is unavailable)
  720. Returns:
  721. Number of spools whose location was cleared.
  722. """
  723. location_prefix = f"{printer_name} - "
  724. spools_at_printer = await self.find_spools_by_location_prefix(location_prefix, cached_spools=cached_spools)
  725. cleared_count = 0
  726. for spool in spools_at_printer:
  727. spool_id = spool.get("id")
  728. # Skip spools that were just synced (matched by location or tag)
  729. if synced_spool_ids and spool_id in synced_spool_ids:
  730. continue
  731. # Get the tray_uuid (stored as "tag" in extra field)
  732. extra = spool.get("extra", {}) or {}
  733. stored_tag = extra.get("tag", "")
  734. if stored_tag:
  735. # Normalize: strip quotes and uppercase
  736. spool_uuid = stored_tag.strip('"').upper()
  737. else:
  738. spool_uuid = ""
  739. # Only clear location for Bambu Lab spools (those with a stored 32-character RFID tag).
  740. if len(spool_uuid) != BAMBU_RFID_TAG_LENGTH:
  741. continue
  742. # If this spool's UUID is not in the current AMS, clear its location
  743. if spool_uuid not in current_tray_uuids:
  744. logger.info(
  745. f"Clearing location for spool {spool_id} "
  746. f"(was: {spool.get('location')}, uuid: {spool_uuid[:16] if spool_uuid else 'none'}...)"
  747. )
  748. result = await self.update_spool(spool_id=spool_id, clear_location=True)
  749. if result:
  750. cleared_count += 1
  751. return cleared_count
  752. async def ensure_bambu_vendor(self) -> int | None:
  753. """Ensure Bambu Lab vendor exists and return its ID.
  754. Returns:
  755. Vendor ID or None on failure.
  756. """
  757. vendors = await self.get_vendors()
  758. for vendor in vendors:
  759. if vendor.get("name", "").lower() == "bambu lab":
  760. return vendor["id"]
  761. # Create Bambu Lab vendor if not exists
  762. vendor = await self.create_vendor("Bambu Lab")
  763. return vendor["id"] if vendor else None
  764. async def ensure_tag_extra_field(self) -> bool:
  765. """Ensure the 'tag' extra field exists for spools.
  766. Spoolman requires extra fields to be registered before use.
  767. This creates the 'tag' field used to store RFID/UUID identifiers.
  768. Returns:
  769. True if field exists or was created, False on failure.
  770. """
  771. try:
  772. client = await self._get_client()
  773. # Check if field already exists
  774. response = await client.get(f"{self.api_url}/field/spool/tag")
  775. if response.status_code == 200:
  776. logger.debug("Spoolman 'tag' extra field already exists")
  777. return True
  778. # Field doesn't exist - create it
  779. field_data = {
  780. "name": "tag",
  781. "field_type": "text",
  782. "default_value": None,
  783. }
  784. response = await client.post(f"{self.api_url}/field/spool/tag", json=field_data)
  785. if response.status_code in (200, 201):
  786. logger.info("Created 'tag' extra field in Spoolman")
  787. return True
  788. logger.warning("Failed to create 'tag' extra field: %s - %s", response.status_code, response.text)
  789. return False
  790. except Exception as e:
  791. logger.warning("Failed to ensure 'tag' extra field exists: %s", e)
  792. return False
  793. def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
  794. """Parse AMS tray data into AMSTray object.
  795. Args:
  796. ams_id: The AMS unit ID (0-3 for regular, 128-135 for AMS-HT, 254+ for external)
  797. tray_data: Raw tray data from MQTT
  798. Returns:
  799. AMSTray object or None if tray is empty or invalid.
  800. """
  801. # Skip empty trays - check for valid tray_type
  802. tray_type = tray_data.get("tray_type", "")
  803. if not tray_type or tray_type.strip() == "":
  804. return None
  805. # Need valid color to create filament
  806. tray_color = tray_data.get("tray_color", "")
  807. if not tray_color or tray_color.strip() == "":
  808. logger.debug("Skipping tray with empty color")
  809. return None
  810. # Handle transparent/natural filament (RRGGBBAA with alpha=00)
  811. # Replace with cream color that represents how natural PLA actually looks
  812. if tray_color == "00000000":
  813. tray_color = "F5E6D3FF" # Light cream/natural color
  814. # Get sub_brands, falling back to tray_type
  815. tray_sub_brands = tray_data.get("tray_sub_brands", "")
  816. if not tray_sub_brands or tray_sub_brands.strip() == "":
  817. tray_sub_brands = tray_type
  818. # Get tag_uid and tray_uuid, filtering out empty/invalid values
  819. tag_uid = tray_data.get("tag_uid", "")
  820. if tag_uid in ("", "0000000000000000"):
  821. tag_uid = ""
  822. tray_uuid = tray_data.get("tray_uuid", "")
  823. if tray_uuid in ("", "00000000000000000000000000000000"):
  824. tray_uuid = ""
  825. # Get tray_info_idx (Bambu filament preset ID like "GFA00")
  826. tray_info_idx = tray_data.get("tray_info_idx", "") or ""
  827. # Get remaining percentage (-1 means unknown/not read by AMS)
  828. remain = int(tray_data.get("remain", -1))
  829. return AMSTray(
  830. ams_id=ams_id,
  831. tray_id=int(tray_data.get("id", 0)),
  832. tray_type=tray_type.strip(),
  833. tray_sub_brands=tray_sub_brands.strip(),
  834. tray_color=tray_color,
  835. remain=remain,
  836. tag_uid=tag_uid,
  837. tray_uuid=tray_uuid,
  838. tray_info_idx=tray_info_idx.strip(),
  839. tray_weight=int(tray_data.get("tray_weight", 1000)),
  840. )
  841. def convert_ams_slot_to_location(self, ams_id: int, tray_id: int) -> str:
  842. """Convert AMS ID and tray ID to human-readable location.
  843. Args:
  844. ams_id: AMS unit ID (0-3 for regular AMS, 128-135 for AMS-HT, 254+ for external spool)
  845. tray_id: Tray ID within the AMS (0-3)
  846. Returns:
  847. Location string like "AMS A1", "AMS-HT A1", "External Spool", etc.
  848. """
  849. if ams_id >= 254:
  850. return "External Spool"
  851. if 128 <= ams_id <= 135:
  852. # AMS-HT units use IDs 128-135
  853. ht_letter = chr(ord("A") + (ams_id - 128))
  854. return f"AMS-HT {ht_letter}{tray_id + 1}"
  855. ams_letter = chr(ord("A") + ams_id)
  856. return f"AMS {ams_letter}{tray_id + 1}"
  857. def is_bambu_lab_spool(self, tray_uuid: str, tag_uid: str = "", tray_info_idx: str = "") -> bool:
  858. """Check if a tray has a valid Bambu Lab spool.
  859. Bambu Lab spools are identified by hardware RFID identifiers only:
  860. 1. tray_uuid: 32-character hex string (preferred, consistent across printers)
  861. 2. tag_uid: 16-character hex string (RFID tag, varies between readers)
  862. Note: tray_info_idx (e.g. "GFA00") is NOT a reliable indicator — third-party
  863. spools using Bambu generic presets also have GF-prefixed tray_info_idx values.
  864. The tray_info_idx parameter is kept for API compatibility but ignored.
  865. Args:
  866. tray_uuid: The tray UUID to check (32 hex chars)
  867. tag_uid: The RFID tag UID to check as fallback (16 hex chars)
  868. tray_info_idx: Ignored (kept for API compatibility)
  869. Returns:
  870. True if the spool has valid Bambu Lab RFID identifiers, False otherwise.
  871. """
  872. # Check tray_uuid (preferred - consistent across printer models)
  873. if tray_uuid:
  874. uuid = tray_uuid.strip()
  875. if len(uuid) == 32 and uuid != "00000000000000000000000000000000":
  876. try:
  877. int(uuid, 16)
  878. return True
  879. except ValueError:
  880. pass
  881. # Fallback: check tag_uid (RFID tag - varies between printer readers)
  882. # Bambu Lab RFID tags are 16 hex characters (8 bytes)
  883. if tag_uid:
  884. tag = tag_uid.strip()
  885. if len(tag) == 16 and tag != "0000000000000000":
  886. try:
  887. int(tag, 16)
  888. logger.debug("Identified Bambu Lab spool via tag_uid fallback: %s", tag)
  889. return True
  890. except ValueError:
  891. pass
  892. return False
  893. def calculate_remaining_weight(self, remain_percent: int, spool_weight: int) -> float:
  894. """Calculate remaining weight from percentage.
  895. Args:
  896. remain_percent: Remaining percentage (0-100)
  897. spool_weight: Total spool weight in grams
  898. Returns:
  899. Remaining weight in grams.
  900. """
  901. return (remain_percent / 100.0) * spool_weight
  902. async def sync_ams_tray(
  903. self,
  904. tray: AMSTray,
  905. printer_name: str,
  906. disable_weight_sync: bool = False,
  907. cached_spools: list[dict] | None = None,
  908. inventory_remaining: float | None = None,
  909. ) -> dict | None:
  910. """Sync a single AMS tray to Spoolman.
  911. Only syncs trays with valid Bambu Lab tray_uuid (32 hex characters).
  912. Non-Bambu Lab spools (SpoolEase/third-party) are skipped.
  913. Uses tray_uuid for matching, as it's consistent across all printer models
  914. (unlike tag_uid which varies between X1C/H2D readers).
  915. Args:
  916. tray: The AMSTray to sync
  917. printer_name: Name of the printer for location
  918. disable_weight_sync: If True, skip updating remaining_weight for existing spools.
  919. This allows Spoolman's granular usage tracking to maintain accurate weights.
  920. cached_spools: Optional pre-fetched list of spools to search (avoids API calls).
  921. When provided, this cache is passed to find_spool_by_tag to avoid redundant
  922. API calls during batch sync operations.
  923. inventory_remaining: Optional fallback remaining weight (grams) from the built-in
  924. inventory when AMS MQTT data has invalid remain/tray_weight values.
  925. Returns:
  926. Synced spool dictionary or None if skipped or failed.
  927. """
  928. logger.debug(
  929. f"Processing {printer_name} AMS {tray.ams_id} tray {tray.tray_id}: "
  930. f"type={tray.tray_type}, idx={tray.tray_info_idx or 'none'}, "
  931. f"uuid={tray.tray_uuid[:16] if tray.tray_uuid else 'none'}, "
  932. f"tag={tray.tag_uid[:8] if tray.tag_uid else 'none'}..."
  933. )
  934. # Only sync trays with valid Bambu Lab identifiers
  935. if not self.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
  936. if tray.tray_uuid or tray.tag_uid or tray.tray_info_idx:
  937. logger.info(
  938. f"Skipping non-Bambu Lab spool: {printer_name} AMS {tray.ams_id} tray {tray.tray_id} "
  939. f"(tray_info_idx={tray.tray_info_idx}, tray_uuid={tray.tray_uuid}, tag_uid={tray.tag_uid})"
  940. )
  941. else:
  942. logger.debug("Skipping tray without RFID tag: AMS %s tray %s", tray.ams_id, tray.tray_id)
  943. return None
  944. # Determine which identifier to use for Spoolman (prefer tray_uuid, fallback to tag_uid)
  945. # Zero-filled values mean the AMS hasn't read the RFID tag — treat as no tag
  946. zero_uuid = "00000000000000000000000000000000"
  947. zero_tag = "0000000000000000"
  948. spool_tag = None
  949. if tray.tray_uuid and tray.tray_uuid != zero_uuid:
  950. spool_tag = tray.tray_uuid
  951. elif tray.tag_uid and tray.tag_uid != zero_tag:
  952. spool_tag = tray.tag_uid
  953. # Calculate remaining weight
  954. # Primary: AMS MQTT data (remain percentage + tray_weight)
  955. # Fallback: Built-in inventory tracked weight (when firmware sends invalid remain/tray_weight)
  956. if tray.remain >= 0 and tray.tray_weight > 0:
  957. remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
  958. elif inventory_remaining is not None:
  959. remaining = inventory_remaining
  960. logger.debug(
  961. "Using inventory weight fallback for %s AMS %s tray %s: %.1fg",
  962. printer_name,
  963. tray.ams_id,
  964. tray.tray_id,
  965. remaining,
  966. )
  967. else:
  968. remaining = None
  969. location = f"{printer_name} - {self.convert_ams_slot_to_location(tray.ams_id, tray.tray_id)}"
  970. if spool_tag:
  971. # Primary path: match by RFID tag
  972. existing = await self.find_spool_by_tag(spool_tag, cached_spools=cached_spools)
  973. if existing:
  974. logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])
  975. return await self.update_spool(
  976. spool_id=existing["id"],
  977. remaining_weight=None if disable_weight_sync else remaining,
  978. location=location,
  979. )
  980. # Spool not found by tag - auto-create it
  981. logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
  982. filament = await self._find_or_create_filament(tray)
  983. if not filament:
  984. logger.error("Failed to find or create filament for %s", tray.tray_sub_brands)
  985. return None
  986. import json
  987. return await self.create_spool(
  988. filament_id=filament["id"],
  989. remaining_weight=remaining,
  990. location=location,
  991. comment="Created by Bambuddy",
  992. extra={"tag": json.dumps(spool_tag)},
  993. )
  994. # Fallback path: no RFID tag available (newer firmware may not expose UUIDs)
  995. # Only update existing spools matched by location — never create new ones without a tag
  996. # to avoid duplicates when old spools exist from previous RFID-based syncs
  997. existing = self._find_spool_by_location(location, cached_spools)
  998. if existing:
  999. logger.info(
  1000. "Updating spool %s by location match '%s' (no RFID tag available)",
  1001. existing["id"],
  1002. location,
  1003. )
  1004. return await self.update_spool(
  1005. spool_id=existing["id"],
  1006. remaining_weight=None if disable_weight_sync else remaining,
  1007. location=location,
  1008. )
  1009. logger.info(
  1010. "No existing spool found at '%s' — skipping (no RFID tag to create with)",
  1011. location,
  1012. )
  1013. return None
  1014. async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
  1015. """Find existing filament or create new one.
  1016. Only matches Bambu Lab vendor filaments since this is called for
  1017. Bambu Lab spools. Third-party filaments (like 3DJAKE) are ignored
  1018. to prevent incorrect matching by color alone.
  1019. Args:
  1020. tray: The AMSTray containing filament info
  1021. Returns:
  1022. Filament dictionary or None on failure.
  1023. """
  1024. # Get Bambu Lab vendor ID for filtering
  1025. bambu_vendor_id = await self.ensure_bambu_vendor()
  1026. color_hex = tray.tray_color[:6] # Strip alpha channel
  1027. # Search internal filaments - only match Bambu Lab vendor
  1028. filaments = await self.get_filaments()
  1029. for filament in filaments:
  1030. # Only match filaments from Bambu Lab vendor
  1031. fil_vendor_id = filament.get("vendor_id") or filament.get("vendor", {}).get("id")
  1032. if fil_vendor_id != bambu_vendor_id:
  1033. continue
  1034. # Match by material and color (handle None values)
  1035. fil_material = filament.get("material") or ""
  1036. fil_color = filament.get("color_hex") or ""
  1037. if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
  1038. return filament
  1039. # Search external filaments (Bambu library)
  1040. external = await self.get_external_filaments()
  1041. for filament in external:
  1042. fil_material = filament.get("material") or ""
  1043. fil_color = filament.get("color_hex") or ""
  1044. if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
  1045. # Found in external library - need to create internal copy
  1046. return await self._create_filament_from_external(filament, tray)
  1047. # Not found - create new Bambu Lab filament
  1048. return await self.create_filament(
  1049. name=tray.tray_sub_brands or tray.tray_type,
  1050. vendor_id=bambu_vendor_id,
  1051. material=tray.tray_type,
  1052. color_hex=color_hex,
  1053. weight=tray.tray_weight,
  1054. )
  1055. async def _create_filament_from_external(self, external: dict, tray: AMSTray) -> dict | None:
  1056. """Create internal filament from external library entry.
  1057. Args:
  1058. external: External filament dictionary
  1059. tray: The AMSTray for additional info
  1060. Returns:
  1061. Created filament dictionary or None on failure.
  1062. """
  1063. vendor_id = await self.ensure_bambu_vendor()
  1064. return await self.create_filament(
  1065. name=external.get("name", tray.tray_sub_brands),
  1066. vendor_id=vendor_id,
  1067. material=external.get("material", tray.tray_type),
  1068. color_hex=external.get("color_hex", tray.tray_color[:6]),
  1069. weight=external.get("weight", tray.tray_weight),
  1070. )
  1071. # Global client instance (initialized when settings are loaded)
  1072. _spoolman_client: SpoolmanClient | None = None
  1073. async def get_spoolman_client() -> SpoolmanClient | None:
  1074. """Get the global Spoolman client instance.
  1075. Returns:
  1076. SpoolmanClient instance or None if not configured.
  1077. """
  1078. return _spoolman_client
  1079. async def init_spoolman_client(url: str) -> SpoolmanClient:
  1080. """Initialize the global Spoolman client.
  1081. Args:
  1082. url: Spoolman server URL
  1083. Returns:
  1084. Initialized SpoolmanClient instance.
  1085. Raises:
  1086. ValueError: If *url* is rejected by the SSRF guard.
  1087. """
  1088. from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
  1089. assert_safe_spoolman_url(url)
  1090. global _spoolman_client
  1091. if _spoolman_client:
  1092. await _spoolman_client.close()
  1093. _spoolman_client = SpoolmanClient(url)
  1094. return _spoolman_client
  1095. async def close_spoolman_client():
  1096. """Close the global Spoolman client."""
  1097. global _spoolman_client
  1098. if _spoolman_client:
  1099. await _spoolman_client.close()
  1100. _spoolman_client = None