spoolman.py 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103
  1. """Spoolman integration service for syncing AMS filament data."""
  2. import asyncio
  3. import logging
  4. import weakref
  5. from dataclasses import dataclass
  6. from datetime import datetime, timezone
  7. from typing import Literal
  8. import httpx
  9. logger = logging.getLogger(__name__)
  10. BAMBU_RFID_TAG_LENGTH = 32
  11. @dataclass
  12. class SpoolmanSpool:
  13. """Represents a spool in Spoolman."""
  14. id: int
  15. filament_id: int | None
  16. remaining_weight: float | None
  17. used_weight: float
  18. first_used: str | None
  19. last_used: str | None
  20. location: str | None
  21. lot_nr: str | None
  22. comment: str | None
  23. extra: dict | None # Contains tag_uid in extra.tag
  24. @dataclass
  25. class SpoolmanFilament:
  26. """Represents a filament type in Spoolman."""
  27. id: int
  28. name: str
  29. vendor_id: int | None
  30. material: str | None
  31. color_hex: str | None
  32. weight: float | None # Net weight in grams
  33. @dataclass
  34. class AMSTray:
  35. """Represents an AMS tray with filament data from Bambu printer."""
  36. ams_id: int # 0-3 for regular AMS, 128-135 for AMS-HT, 254+ for external spool
  37. tray_id: int # 0-3
  38. tray_type: str # PLA, PETG, ABS, etc.
  39. tray_sub_brands: str # Full name like "PLA Basic", "PETG HF"
  40. tray_color: str # Hex color like "FEC600FF"
  41. remain: int # Remaining percentage (0-100)
  42. tag_uid: str # RFID tag UID
  43. tray_uuid: str # Spool UUID
  44. tray_info_idx: str # Bambu filament preset ID like "GFA00"
  45. tray_weight: int # Spool weight in grams (usually 1000)
  46. class SpoolmanNotFoundError(Exception):
  47. """Raised when a spool ID does not exist in Spoolman (HTTP 404)."""
  48. class SpoolmanUnavailableError(Exception):
  49. """Raised when Spoolman is unreachable or returns a server/network error."""
  50. class SpoolmanClientError(Exception):
  51. """Raised when Spoolman returns a 4xx client error (not 404)."""
  52. def __init__(self, message: str, status_code: int, response_text: str = "") -> None:
  53. super().__init__(message)
  54. self.status_code = status_code
  55. self.response_text = response_text
  56. class SpoolmanClient:
  57. """Client for interacting with Spoolman API."""
  58. def __init__(self, base_url: str):
  59. """Initialize the Spoolman client."""
  60. self.base_url = base_url.rstrip("/")
  61. self.api_url = f"{self.base_url}/api/v1"
  62. self._client: httpx.AsyncClient | None = None
  63. self._connected = False
  64. # Per-spool locks for atomic read-modify-write in merge_spool_extra.
  65. # WeakValueDictionary: locks are GC'd once no coroutine holds a reference.
  66. self._extra_locks: weakref.WeakValueDictionary[int, asyncio.Lock] = weakref.WeakValueDictionary()
  67. async def _get_client(self) -> httpx.AsyncClient:
  68. """Get or create the HTTP client with connection pooling limits."""
  69. if self._client is None:
  70. self._client = httpx.AsyncClient(
  71. timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0),
  72. follow_redirects=False,
  73. verify=True,
  74. limits=httpx.Limits(
  75. max_keepalive_connections=5,
  76. max_connections=10,
  77. keepalive_expiry=30.0,
  78. ),
  79. )
  80. return self._client
  81. async def close(self):
  82. """Close the HTTP client."""
  83. if self._client:
  84. await self._client.aclose()
  85. self._client = None
  86. async def health_check(self) -> bool:
  87. """Check if Spoolman server is reachable; returns True if healthy."""
  88. try:
  89. client = await self._get_client()
  90. response = await client.get(f"{self.api_url}/health")
  91. self._connected = response.status_code == 200
  92. return self._connected
  93. except Exception as e:
  94. logger.warning(
  95. "Spoolman health check failed (url=%s, type=%s): %s",
  96. self.api_url,
  97. type(e).__name__,
  98. e,
  99. )
  100. self._connected = False
  101. return False
  102. @property
  103. def is_connected(self) -> bool:
  104. """Check if client is connected to Spoolman."""
  105. return self._connected
  106. async def get_spools(self) -> list[dict]:
  107. """Fetch all spools from Spoolman with up to 3 retries on connection errors."""
  108. max_attempts = 3
  109. retry_delay = 0.5 # 500ms
  110. for attempt in range(1, max_attempts + 1):
  111. try:
  112. client = await self._get_client()
  113. response = await client.get(f"{self.api_url}/spool")
  114. response.raise_for_status()
  115. spools = response.json()
  116. if attempt > 1:
  117. logger.info("Successfully fetched %d spools on attempt %d", len(spools), attempt)
  118. return spools
  119. except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ConnectError) as e:
  120. # Connection-related errors - close and recreate client for next attempt
  121. if attempt < max_attempts:
  122. logger.warning(
  123. "Connection error getting spools (attempt %d/%d): %s. Recreating client and retrying in %dms...",
  124. attempt,
  125. max_attempts,
  126. e,
  127. int(retry_delay * 1000),
  128. )
  129. # Close the stale client and recreate it
  130. await self.close()
  131. await asyncio.sleep(retry_delay)
  132. else:
  133. logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
  134. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  135. except Exception as e:
  136. # Other errors (HTTP errors, JSON decode errors, etc.)
  137. if attempt < max_attempts:
  138. logger.warning(
  139. "Failed to get spools from Spoolman (attempt %d/%d): %s. Retrying in %dms...",
  140. attempt,
  141. max_attempts,
  142. e,
  143. int(retry_delay * 1000),
  144. )
  145. await asyncio.sleep(retry_delay)
  146. else:
  147. logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
  148. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  149. async def _get_with_retry(self, path: str, params: dict | None = None) -> list[dict]:
  150. """GET a Spoolman JSON list endpoint with up to 3 retries on connection errors."""
  151. max_attempts = 3
  152. retry_delay = 0.5
  153. url = f"{self.api_url}/{path.lstrip('/')}"
  154. for attempt in range(1, max_attempts + 1):
  155. try:
  156. client = await self._get_client()
  157. response = await client.get(url, params=params or None)
  158. response.raise_for_status()
  159. return response.json()
  160. except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ConnectError) as e:
  161. if attempt < max_attempts:
  162. logger.warning(
  163. "Connection error fetching %s (attempt %d/%d): %s. Recreating client and retrying in %dms...",
  164. path,
  165. attempt,
  166. max_attempts,
  167. e,
  168. int(retry_delay * 1000),
  169. )
  170. await self.close()
  171. await asyncio.sleep(retry_delay)
  172. else:
  173. logger.error("Failed to fetch %s from Spoolman after %d attempts: %s", path, max_attempts, e)
  174. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  175. except Exception as e:
  176. if attempt < max_attempts:
  177. logger.warning(
  178. "Failed to fetch %s from Spoolman (attempt %d/%d): %s. Retrying in %dms...",
  179. path,
  180. attempt,
  181. max_attempts,
  182. e,
  183. int(retry_delay * 1000),
  184. )
  185. await asyncio.sleep(retry_delay)
  186. else:
  187. logger.error("Failed to fetch %s from Spoolman after %d attempts: %s", path, max_attempts, e)
  188. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  189. async def get_filaments(self) -> list[dict]:
  190. """Fetch all internal filaments from Spoolman."""
  191. try:
  192. client = await self._get_client()
  193. response = await client.get(f"{self.api_url}/filament")
  194. response.raise_for_status()
  195. return response.json()
  196. except Exception as e:
  197. logger.error("Failed to get filaments from Spoolman: %s", e)
  198. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  199. async def get_filament(self, filament_id: int) -> dict:
  200. """Fetch a single filament by ID from Spoolman."""
  201. if filament_id <= 0:
  202. raise ValueError(f"Invalid filament_id: {filament_id}")
  203. response = await self._request_filament("GET", filament_id, operation="get_filament")
  204. return response.json()
  205. async def get_external_filaments(self) -> list[dict]:
  206. """Fetch external/library filaments from Spoolman."""
  207. try:
  208. client = await self._get_client()
  209. response = await client.get(f"{self.api_url}/external/filament")
  210. response.raise_for_status()
  211. return response.json()
  212. except Exception as e:
  213. logger.error("Failed to get external filaments from Spoolman: %s", e)
  214. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  215. async def get_vendors(self) -> list[dict]:
  216. """Fetch all vendors from Spoolman."""
  217. try:
  218. client = await self._get_client()
  219. response = await client.get(f"{self.api_url}/vendor")
  220. response.raise_for_status()
  221. return response.json()
  222. except Exception as e:
  223. logger.error("Failed to get vendors from Spoolman: %s", e)
  224. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  225. async def create_vendor(self, name: str) -> dict:
  226. """Create a new vendor in Spoolman."""
  227. try:
  228. client = await self._get_client()
  229. response = await client.post(f"{self.api_url}/vendor", json={"name": name})
  230. if 400 <= response.status_code < 500:
  231. raise SpoolmanClientError(
  232. f"Spoolman rejected vendor creation (HTTP {response.status_code})",
  233. response.status_code,
  234. )
  235. response.raise_for_status()
  236. return response.json()
  237. except SpoolmanClientError:
  238. raise
  239. except Exception as e:
  240. logger.error("Failed to create vendor in Spoolman: %s", e)
  241. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  242. def _get_material_density(self, material: str | None) -> float:
  243. """Return typical density (g/cm³) for the given filament material; defaults to PLA (1.24)."""
  244. # Typical densities for common filament materials
  245. densities = {
  246. "PLA": 1.24,
  247. "PLA-CF": 1.29,
  248. "PLA-S": 1.24,
  249. "PETG": 1.27,
  250. "ABS": 1.04,
  251. "ASA": 1.07,
  252. "TPU": 1.21,
  253. "PA": 1.14, # Nylon
  254. "PA-CF": 1.20,
  255. "PC": 1.20,
  256. "PVA": 1.23,
  257. "HIPS": 1.04,
  258. "PP": 0.90,
  259. "PET": 1.38,
  260. }
  261. if material:
  262. # Try exact match first, then uppercase
  263. mat_upper = material.upper()
  264. for key, density in densities.items():
  265. if key.upper() == mat_upper or mat_upper.startswith(key.upper()):
  266. return density
  267. return 1.24 # Default to PLA density
  268. async def create_filament(
  269. self,
  270. name: str,
  271. vendor_id: int | None = None,
  272. material: str | None = None,
  273. color_hex: str | None = None,
  274. color_name: str | None = None,
  275. weight: float | None = None,
  276. diameter: float = 1.75,
  277. density: float | None = None,
  278. ) -> dict:
  279. """Create a new filament in Spoolman."""
  280. if not name or not name.strip():
  281. raise ValueError("Filament name is required")
  282. if density is None:
  283. density = self._get_material_density(material)
  284. data: dict = {
  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. try:
  303. client = await self._get_client()
  304. response = await client.post(f"{self.api_url}/filament", json=data)
  305. if 400 <= response.status_code < 500:
  306. raise SpoolmanClientError(
  307. f"Spoolman rejected filament creation (HTTP {response.status_code})",
  308. response.status_code,
  309. )
  310. response.raise_for_status()
  311. return response.json()
  312. except SpoolmanClientError:
  313. raise
  314. except Exception as e:
  315. logger.error("Failed to create filament in Spoolman: %s", e)
  316. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  317. async def patch_filament(self, filament_id: int, data: dict) -> dict:
  318. """PATCH a filament entry in Spoolman (e.g. update name or spool_weight)."""
  319. if filament_id <= 0:
  320. raise ValueError(f"Invalid filament_id: {filament_id}")
  321. response = await self._request_filament("PATCH", filament_id, json_body=data, operation="patch_filament")
  322. return response.json()
  323. async def create_spool(
  324. self,
  325. filament_id: int,
  326. remaining_weight: float | None = None,
  327. location: str | None = None,
  328. lot_nr: str | None = None,
  329. comment: str | None = None,
  330. extra: dict | None = None,
  331. ) -> dict:
  332. """Create a new spool in Spoolman."""
  333. data: dict = {"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. try:
  346. client = await self._get_client()
  347. response = await client.post(f"{self.api_url}/spool", json=data)
  348. if response.status_code == 404:
  349. raise SpoolmanNotFoundError(f"Filament {filament_id} not found in Spoolman")
  350. if 400 <= response.status_code < 500:
  351. raise SpoolmanClientError(
  352. f"Spoolman rejected spool creation (HTTP {response.status_code})",
  353. response.status_code,
  354. )
  355. response.raise_for_status()
  356. result = response.json()
  357. logger.info("Created spool %s in Spoolman", result.get("id"))
  358. return result
  359. except (SpoolmanNotFoundError, SpoolmanClientError):
  360. raise
  361. except Exception as e:
  362. logger.error("Failed to create spool in Spoolman: %s", e)
  363. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  364. async def update_spool(
  365. self,
  366. spool_id: int,
  367. remaining_weight: float | None = None,
  368. location: str | None = None,
  369. clear_location: bool = False,
  370. extra: dict | None = None,
  371. ) -> dict:
  372. """Update an existing spool in Spoolman, always setting last_used."""
  373. data: dict = {}
  374. if remaining_weight is not None:
  375. data["remaining_weight"] = remaining_weight
  376. if clear_location:
  377. data["location"] = None
  378. elif location:
  379. data["location"] = location
  380. if extra:
  381. data["extra"] = extra
  382. data["last_used"] = datetime.now(timezone.utc).isoformat()
  383. response = await self._request_spool("PATCH", spool_id, json_body=data, operation="update")
  384. return response.json()
  385. async def _request_spool(
  386. self,
  387. method: Literal["GET", "PATCH", "DELETE"],
  388. spool_id: int,
  389. *,
  390. json_body: dict | None = None,
  391. operation: str,
  392. ) -> httpx.Response:
  393. """Perform a spool-scoped HTTP request, translating 404 and errors to named exceptions."""
  394. try:
  395. client = await self._get_client()
  396. response = await client.request(
  397. method,
  398. f"{self.api_url}/spool/{spool_id}",
  399. json=json_body,
  400. )
  401. if response.status_code == 404:
  402. raise SpoolmanNotFoundError(f"Spool {spool_id} not found in Spoolman")
  403. response.raise_for_status()
  404. return response
  405. except SpoolmanNotFoundError:
  406. raise
  407. except httpx.HTTPStatusError as e:
  408. if 400 <= e.response.status_code < 500:
  409. logger.warning(
  410. "Spoolman returned %d for %s spool %s",
  411. e.response.status_code,
  412. operation,
  413. spool_id,
  414. )
  415. raise SpoolmanClientError(
  416. f"Spoolman rejected {operation} for spool {spool_id} (HTTP {e.response.status_code})",
  417. e.response.status_code,
  418. e.response.text[:500],
  419. ) from e
  420. else:
  421. logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e)
  422. raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e
  423. except Exception as e:
  424. logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e)
  425. raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e
  426. async def _request_filament(
  427. self,
  428. method: Literal["GET", "PATCH"],
  429. filament_id: int,
  430. *,
  431. json_body: dict | None = None,
  432. operation: str,
  433. ) -> httpx.Response:
  434. """Perform a filament-scoped HTTP request, translating 404 and errors to named exceptions."""
  435. try:
  436. client = await self._get_client()
  437. response = await client.request(
  438. method,
  439. f"{self.api_url}/filament/{filament_id}",
  440. json=json_body,
  441. )
  442. if response.status_code == 404:
  443. raise SpoolmanNotFoundError(f"Filament {filament_id} not found in Spoolman")
  444. response.raise_for_status()
  445. return response
  446. except SpoolmanNotFoundError:
  447. raise
  448. except httpx.HTTPStatusError as e:
  449. if 400 <= e.response.status_code < 500:
  450. logger.warning(
  451. "Spoolman returned %d for %s filament %s",
  452. e.response.status_code,
  453. operation,
  454. filament_id,
  455. )
  456. raise SpoolmanClientError(
  457. f"Spoolman rejected {operation} for filament {filament_id} (HTTP {e.response.status_code})",
  458. e.response.status_code,
  459. e.response.text[:500],
  460. ) from e
  461. else:
  462. logger.error("Failed to %s filament %s in Spoolman: %s", operation, filament_id, e)
  463. raise SpoolmanUnavailableError(f"Failed to {operation} filament {filament_id}") from e
  464. except Exception as e:
  465. logger.error("Failed to %s filament %s in Spoolman: %s", operation, filament_id, e)
  466. raise SpoolmanUnavailableError(f"Failed to {operation} filament {filament_id}") from e
  467. async def get_spool(self, spool_id: int) -> dict:
  468. """Fetch a single spool by ID from Spoolman."""
  469. response = await self._request_spool("GET", spool_id, operation="get")
  470. return response.json()
  471. async def get_all_spools(self, allow_archived: bool = False) -> list[dict]:
  472. """Fetch all spools from Spoolman with retry, optionally including archived ones."""
  473. params: dict = {}
  474. if allow_archived:
  475. params["allow_archived"] = "true"
  476. return await self._get_with_retry("/spool", params=params or None)
  477. async def delete_spool(self, spool_id: int) -> None:
  478. """Delete a spool from Spoolman."""
  479. await self._request_spool("DELETE", spool_id, operation="delete")
  480. async def set_spool_archived(self, spool_id: int, archived: bool) -> dict:
  481. """Archive or restore a spool in Spoolman."""
  482. response = await self._request_spool(
  483. "PATCH",
  484. spool_id,
  485. json_body={"archived": archived},
  486. operation="archive/restore",
  487. )
  488. return response.json()
  489. async def update_spool_full(
  490. self,
  491. spool_id: int,
  492. *,
  493. filament_id: int | None = None,
  494. remaining_weight: float | None = None,
  495. comment: str | None = None,
  496. price: float | None = None,
  497. location: str | None = None,
  498. clear_location: bool = False,
  499. extra: dict | None = None,
  500. spool_weight: float | None = None,
  501. clear_spool_weight: bool = False,
  502. ) -> dict:
  503. """Update a spool with full field support; unlike update_spool, does not auto-set last_used."""
  504. data: dict = {}
  505. if filament_id is not None:
  506. data["filament_id"] = filament_id
  507. if remaining_weight is not None:
  508. data["remaining_weight"] = remaining_weight
  509. if comment is not None:
  510. data["comment"] = comment if comment else None
  511. if price is not None:
  512. data["price"] = price
  513. if clear_location:
  514. data["location"] = None
  515. elif location is not None:
  516. data["location"] = location
  517. if extra is not None:
  518. data["extra"] = extra
  519. if clear_spool_weight:
  520. data["spool_weight"] = None
  521. elif spool_weight is not None:
  522. data["spool_weight"] = spool_weight
  523. response = await self._request_spool("PATCH", spool_id, json_body=data, operation="update")
  524. return response.json()
  525. def extra_lock(self, spool_id: int) -> asyncio.Lock:
  526. """Return (creating if needed) the per-spool asyncio.Lock used by merge_spool_extra."""
  527. lock = self._extra_locks.get(spool_id)
  528. if lock is None:
  529. lock = asyncio.Lock()
  530. self._extra_locks[spool_id] = lock
  531. return lock
  532. async def merge_spool_extra(self, spool_id: int, new_fields: dict) -> dict:
  533. """Fetch the spool's extra dict, merge new_fields into it, then PATCH back — serialised per spool."""
  534. async with self.extra_lock(spool_id):
  535. current = await self.get_spool(spool_id) # raises on error
  536. current_extra: dict = current.get("extra") or {}
  537. merged = {**current_extra, **new_fields}
  538. return await self.update_spool_full(spool_id=spool_id, extra=merged)
  539. async def find_or_create_vendor(self, name: str) -> int:
  540. """Return the Spoolman vendor ID for the given name, creating the vendor if absent."""
  541. vendors = await self.get_vendors()
  542. name_lower = name.strip().lower()
  543. for vendor in vendors:
  544. if vendor.get("name", "").strip().lower() == name_lower:
  545. return vendor["id"]
  546. created = await self.create_vendor(name.strip())
  547. vendor_id = created.get("id")
  548. if not vendor_id:
  549. raise SpoolmanUnavailableError(f"Spoolman returned vendor without id field: {list(created.keys())}")
  550. return vendor_id
  551. async def find_or_create_filament(
  552. self,
  553. material: str,
  554. subtype: str,
  555. brand: str | None,
  556. color_hex: str,
  557. label_weight: int,
  558. color_name: str | None = None,
  559. ) -> int:
  560. """Return the filament ID matching material/name/brand/color, creating it if absent."""
  561. name = f"{material} {subtype}".strip() if subtype else material
  562. color = color_hex[:6].upper() if len(color_hex) >= 6 else color_hex.upper()
  563. vendor_id: int | None = None
  564. if brand:
  565. vendor_id = await self.find_or_create_vendor(brand)
  566. filaments = await self.get_filaments()
  567. for f in filaments:
  568. f_material = (f.get("material") or "").upper()
  569. f_name = (f.get("name") or "").strip()
  570. f_color = (f.get("color_hex") or "").upper()[:6]
  571. f_vendor = f.get("vendor") or {}
  572. f_vendor_name = (f_vendor.get("name") or "").strip().lower()
  573. material_match = f_material == material.upper()
  574. name_match = f_name.lower() == name.lower()
  575. color_match = f_color == color
  576. vendor_match = (not brand) or f_vendor_name == (brand or "").strip().lower()
  577. if material_match and name_match and color_match and vendor_match:
  578. return f["id"]
  579. filament = await self.create_filament(
  580. name=name,
  581. vendor_id=vendor_id,
  582. material=material,
  583. color_hex=color,
  584. color_name=color_name,
  585. weight=float(label_weight),
  586. )
  587. filament_id = filament.get("id")
  588. if not filament_id:
  589. raise SpoolmanUnavailableError(f"Spoolman returned filament without id field: {list(filament.keys())}")
  590. return filament_id
  591. async def use_spool(self, spool_id: int, used_weight: float) -> dict:
  592. """Record filament usage for a spool via the Spoolman /use endpoint."""
  593. try:
  594. client = await self._get_client()
  595. response = await client.put(
  596. f"{self.api_url}/spool/{spool_id}/use",
  597. json={"use_weight": used_weight},
  598. )
  599. if response.status_code == 404:
  600. raise SpoolmanNotFoundError(f"Spool {spool_id} not found in Spoolman")
  601. if 400 <= response.status_code < 500:
  602. raise SpoolmanClientError(
  603. f"Spoolman rejected use_spool for spool {spool_id} (HTTP {response.status_code})",
  604. response.status_code,
  605. )
  606. response.raise_for_status()
  607. return response.json()
  608. except (SpoolmanNotFoundError, SpoolmanClientError):
  609. raise
  610. except Exception as e:
  611. logger.error("Failed to record spool usage in Spoolman: %s", e)
  612. raise SpoolmanUnavailableError(f"Failed to record usage for spool {spool_id}") from e
  613. async def find_spool_by_tag(self, tag_uid: str, cached_spools: list[dict] | None = None) -> dict | None:
  614. """Return the spool matching the given RFID tag UID, or None if not found."""
  615. # Use cached spools if provided, otherwise fetch from API
  616. spools = cached_spools if cached_spools is not None else await self.get_spools()
  617. # Normalize tag_uid for comparison (uppercase, strip quotes)
  618. search_tag = tag_uid.strip('"').upper()
  619. for spool in spools:
  620. extra = spool.get("extra", {})
  621. if extra:
  622. stored_tag = extra.get("tag", "")
  623. # Normalize stored tag (strip quotes, uppercase)
  624. if stored_tag:
  625. normalized_tag = stored_tag.strip('"').upper()
  626. if normalized_tag == search_tag:
  627. logger.debug("Found spool %s matching tag %s", spool["id"], tag_uid)
  628. return spool
  629. return None
  630. def _find_spool_by_location(self, location: str, cached_spools: list[dict] | None) -> dict | None:
  631. """Return the spool at the exact location string, or None; fallback when RFID is unavailable."""
  632. if not cached_spools:
  633. return None
  634. for spool in cached_spools:
  635. if spool.get("location") == location:
  636. return spool
  637. return None
  638. async def find_spools_by_location_prefix(
  639. self, location_prefix: str, cached_spools: list[dict] | None = None
  640. ) -> list[dict]:
  641. """Return all spools whose location starts with location_prefix."""
  642. # Use cached spools if provided, otherwise fetch from API
  643. spools = cached_spools if cached_spools is not None else await self.get_spools()
  644. matching = []
  645. for spool in spools:
  646. location = spool.get("location", "")
  647. if location and location.startswith(location_prefix):
  648. matching.append(spool)
  649. return matching
  650. async def clear_location_for_removed_spools(
  651. self,
  652. printer_name: str,
  653. current_tray_uuids: set[str],
  654. cached_spools: list[dict] | None = None,
  655. synced_spool_ids: set[int] | None = None,
  656. ) -> int:
  657. """Clear location for Bambu Lab spools at this printer whose tray_uuid is no longer in the AMS."""
  658. location_prefix = f"{printer_name} - "
  659. spools_at_printer = await self.find_spools_by_location_prefix(location_prefix, cached_spools=cached_spools)
  660. cleared_count = 0
  661. for spool in spools_at_printer:
  662. spool_id = spool.get("id")
  663. # Skip spools that were just synced (matched by location or tag)
  664. if synced_spool_ids and spool_id in synced_spool_ids:
  665. continue
  666. # Get the tray_uuid (stored as "tag" in extra field)
  667. extra = spool.get("extra", {}) or {}
  668. stored_tag = extra.get("tag", "")
  669. if stored_tag:
  670. # Normalize: strip quotes and uppercase
  671. spool_uuid = stored_tag.strip('"').upper()
  672. else:
  673. spool_uuid = ""
  674. # Only clear location for Bambu Lab spools (those with a stored 32-character RFID tag).
  675. if len(spool_uuid) != BAMBU_RFID_TAG_LENGTH:
  676. continue
  677. # If this spool's UUID is not in the current AMS, clear its location
  678. if spool_uuid not in current_tray_uuids:
  679. logger.info(
  680. f"Clearing location for spool {spool_id} "
  681. f"(was: {spool.get('location')}, uuid: {spool_uuid[:16] if spool_uuid else 'none'}...)"
  682. )
  683. result = await self.update_spool(spool_id=spool_id, clear_location=True)
  684. if result:
  685. cleared_count += 1
  686. return cleared_count
  687. async def ensure_bambu_vendor(self) -> int | None:
  688. """Return the Bambu Lab vendor ID in Spoolman, creating the vendor if absent."""
  689. vendors = await self.get_vendors()
  690. for vendor in vendors:
  691. if vendor.get("name", "").lower() == "bambu lab":
  692. return vendor["id"]
  693. # Create Bambu Lab vendor if not exists
  694. vendor = await self.create_vendor("Bambu Lab")
  695. return vendor["id"] if vendor else None
  696. async def ensure_tag_extra_field(self) -> bool:
  697. """Register the 'tag' extra field in Spoolman if not present; returns True on success."""
  698. return await self.ensure_extra_field("tag")
  699. async def ensure_extra_field(self, name: str, field_type: str = "text") -> bool:
  700. """Register a custom extra field in Spoolman if not present.
  701. Spoolman rejects PATCH requests that include unknown extra-dict keys
  702. with HTTP 400 ('Unknown extra field <name>.'), so any custom field
  703. Bambuddy persists alongside spools needs to be pre-registered.
  704. Idempotent — returns True if the field already exists.
  705. """
  706. try:
  707. client = await self._get_client()
  708. # Check if field already exists
  709. response = await client.get(f"{self.api_url}/field/spool/{name}")
  710. if response.status_code == 200:
  711. logger.debug("Spoolman extra field %r already exists", name)
  712. return True
  713. # Field doesn't exist - create it
  714. field_data = {
  715. "name": name,
  716. "field_type": field_type,
  717. "default_value": None,
  718. }
  719. response = await client.post(f"{self.api_url}/field/spool/{name}", json=field_data)
  720. if response.status_code in (200, 201):
  721. logger.info("Created Spoolman extra field %r", name)
  722. return True
  723. logger.warning(
  724. "Failed to create Spoolman extra field %r: %s - %s",
  725. name,
  726. response.status_code,
  727. response.text,
  728. )
  729. return False
  730. except Exception as e:
  731. logger.warning("Failed to ensure Spoolman extra field %r exists: %s", name, e)
  732. return False
  733. def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
  734. """Parse raw MQTT tray data into an AMSTray; returns None for empty or invalid trays."""
  735. # Skip empty trays - check for valid tray_type
  736. tray_type = tray_data.get("tray_type", "")
  737. if not tray_type or tray_type.strip() == "":
  738. return None
  739. # Need valid color to create filament
  740. tray_color = tray_data.get("tray_color", "")
  741. if not tray_color or tray_color.strip() == "":
  742. logger.debug("Skipping tray with empty color")
  743. return None
  744. # Handle transparent/natural filament (RRGGBBAA with alpha=00)
  745. # Replace with cream color that represents how natural PLA actually looks
  746. if tray_color == "00000000":
  747. tray_color = "F5E6D3FF" # Light cream/natural color
  748. # Get sub_brands, falling back to tray_type
  749. tray_sub_brands = tray_data.get("tray_sub_brands", "")
  750. if not tray_sub_brands or tray_sub_brands.strip() == "":
  751. tray_sub_brands = tray_type
  752. # Get tag_uid and tray_uuid, filtering out empty/invalid values
  753. tag_uid = tray_data.get("tag_uid", "")
  754. if tag_uid in ("", "0000000000000000"):
  755. tag_uid = ""
  756. tray_uuid = tray_data.get("tray_uuid", "")
  757. if tray_uuid in ("", "00000000000000000000000000000000"):
  758. tray_uuid = ""
  759. # Get tray_info_idx (Bambu filament preset ID like "GFA00")
  760. tray_info_idx = tray_data.get("tray_info_idx", "") or ""
  761. # Get remaining percentage (-1 means unknown/not read by AMS)
  762. remain = int(tray_data.get("remain", -1))
  763. return AMSTray(
  764. ams_id=ams_id,
  765. tray_id=int(tray_data.get("id", 0)),
  766. tray_type=tray_type.strip(),
  767. tray_sub_brands=tray_sub_brands.strip(),
  768. tray_color=tray_color,
  769. remain=remain,
  770. tag_uid=tag_uid,
  771. tray_uuid=tray_uuid,
  772. tray_info_idx=tray_info_idx.strip(),
  773. tray_weight=int(tray_data.get("tray_weight", 1000)),
  774. )
  775. def convert_ams_slot_to_location(self, ams_id: int, tray_id: int) -> str:
  776. """Return a human-readable location string (e.g. "AMS A1") for the given AMS slot."""
  777. if ams_id >= 254:
  778. return "External Spool"
  779. if 128 <= ams_id <= 135:
  780. # AMS-HT units use IDs 128-135
  781. ht_letter = chr(ord("A") + (ams_id - 128))
  782. return f"AMS-HT {ht_letter}{tray_id + 1}"
  783. ams_letter = chr(ord("A") + ams_id)
  784. return f"AMS {ams_letter}{tray_id + 1}"
  785. def is_bambu_lab_spool(self, tray_uuid: str, tag_uid: str = "", tray_info_idx: str = "") -> bool:
  786. """Return True if tray_uuid or tag_uid identifies a Bambu Lab spool; tray_info_idx is ignored."""
  787. # Check tray_uuid (preferred - consistent across printer models)
  788. if tray_uuid:
  789. uuid = tray_uuid.strip()
  790. if len(uuid) == 32 and uuid != "00000000000000000000000000000000":
  791. try:
  792. int(uuid, 16)
  793. return True
  794. except ValueError:
  795. pass
  796. # Fallback: check tag_uid (RFID tag - varies between printer readers)
  797. # Bambu Lab RFID tags are 16 hex characters (8 bytes)
  798. if tag_uid:
  799. tag = tag_uid.strip()
  800. if len(tag) == 16 and tag != "0000000000000000":
  801. try:
  802. int(tag, 16)
  803. logger.debug("Identified Bambu Lab spool via tag_uid fallback: %s", tag)
  804. return True
  805. except ValueError:
  806. pass
  807. return False
  808. def calculate_remaining_weight(self, remain_percent: int, spool_weight: int) -> float:
  809. """Return remaining filament weight in grams given a percentage and total spool weight."""
  810. return (remain_percent / 100.0) * spool_weight
  811. async def sync_ams_tray(
  812. self,
  813. tray: AMSTray,
  814. printer_name: str,
  815. disable_weight_sync: bool = False,
  816. cached_spools: list[dict] | None = None,
  817. inventory_remaining: float | None = None,
  818. spoolman_spool_id_hint: int | None = None,
  819. ) -> dict | None:
  820. """Sync one AMS tray to Spoolman; creates the spool on first sight, updates weight otherwise."""
  821. logger.debug(
  822. f"Processing {printer_name} AMS {tray.ams_id} tray {tray.tray_id}: "
  823. f"type={tray.tray_type}, idx={tray.tray_info_idx or 'none'}, "
  824. f"uuid={tray.tray_uuid[:16] if tray.tray_uuid else 'none'}, "
  825. f"tag={tray.tag_uid[:8] if tray.tag_uid else 'none'}..."
  826. )
  827. # Determine which identifier to use for Spoolman (prefer tray_uuid, fallback to tag_uid)
  828. # Zero-filled values mean the AMS hasn't read the RFID tag — treat as no tag
  829. zero_uuid = "00000000000000000000000000000000"
  830. zero_tag = "0000000000000000"
  831. spool_tag = None
  832. if tray.tray_uuid and tray.tray_uuid != zero_uuid:
  833. spool_tag = tray.tray_uuid
  834. elif tray.tag_uid and tray.tag_uid != zero_tag:
  835. spool_tag = tray.tag_uid
  836. # Calculate remaining weight
  837. # Primary: AMS MQTT data (remain percentage + tray_weight)
  838. # Fallback: Built-in inventory tracked weight (when firmware sends invalid remain/tray_weight)
  839. if tray.remain >= 0 and tray.tray_weight > 0:
  840. remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
  841. elif inventory_remaining is not None:
  842. remaining = inventory_remaining
  843. logger.debug(
  844. "Using inventory weight fallback for %s AMS %s tray %s: %.1fg",
  845. printer_name,
  846. tray.ams_id,
  847. tray.tray_id,
  848. remaining,
  849. )
  850. else:
  851. remaining = None
  852. if spool_tag:
  853. # Primary path: match by RFID tag
  854. existing = await self.find_spool_by_tag(spool_tag, cached_spools=cached_spools)
  855. if existing:
  856. logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])
  857. return await self.update_spool(
  858. spool_id=existing["id"],
  859. remaining_weight=None if disable_weight_sync else remaining,
  860. )
  861. # Spool not found by tag - auto-create it
  862. logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
  863. if self.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
  864. filament = await self._find_or_create_filament(tray)
  865. filament_id = filament["id"] if filament else None
  866. else:
  867. # Non-BL spool with custom RFID: use generic vendor lookup
  868. brand = tray.tray_sub_brands if tray.tray_sub_brands != tray.tray_type else None
  869. try:
  870. filament_id = await self.find_or_create_filament(
  871. material=tray.tray_type,
  872. subtype="",
  873. brand=brand,
  874. color_hex=tray.tray_color[:6],
  875. label_weight=tray.tray_weight,
  876. )
  877. except (SpoolmanNotFoundError, SpoolmanUnavailableError, SpoolmanClientError):
  878. logger.warning("Could not find or create filament for non-BL spool %s", tray.tray_sub_brands)
  879. return None
  880. if not filament_id:
  881. logger.error("Failed to find or create filament for %s", tray.tray_sub_brands)
  882. return None
  883. import json
  884. return await self.create_spool(
  885. filament_id=filament_id,
  886. remaining_weight=remaining,
  887. comment="Created by Bambuddy",
  888. extra={"tag": json.dumps(spool_tag)},
  889. )
  890. # No-RFID fallback: use the spool ID resolved from the local slot-assignment table.
  891. # Never create new spools without a tag to avoid duplicates.
  892. if spoolman_spool_id_hint is not None:
  893. existing = next((s for s in (cached_spools or []) if s.get("id") == spoolman_spool_id_hint), None)
  894. if existing is None:
  895. try:
  896. existing = await self.get_spool(spoolman_spool_id_hint)
  897. except (SpoolmanNotFoundError, SpoolmanUnavailableError):
  898. existing = None
  899. if existing:
  900. logger.info(
  901. "Updating spool %s by slot-assignment hint (no RFID tag available)",
  902. existing["id"],
  903. )
  904. return await self.update_spool(
  905. spool_id=existing["id"],
  906. remaining_weight=None if disable_weight_sync else remaining,
  907. )
  908. logger.info(
  909. "%s AMS %s tray %s — skipping (no RFID tag and no slot-assignment hint)",
  910. printer_name,
  911. tray.ams_id,
  912. tray.tray_id,
  913. )
  914. return None
  915. async def _find_or_create_filament(self, tray: AMSTray) -> dict | None:
  916. """Return a Bambu Lab filament matching the tray's material/color, creating it if absent."""
  917. # Get Bambu Lab vendor ID for filtering
  918. bambu_vendor_id = await self.ensure_bambu_vendor()
  919. color_hex = tray.tray_color[:6] # Strip alpha channel
  920. # Search internal filaments - only match Bambu Lab vendor
  921. filaments = await self.get_filaments()
  922. for filament in filaments:
  923. # Only match filaments from Bambu Lab vendor
  924. fil_vendor_id = filament.get("vendor_id") or filament.get("vendor", {}).get("id")
  925. if fil_vendor_id != bambu_vendor_id:
  926. continue
  927. # Match by material and color (handle None values)
  928. fil_material = filament.get("material") or ""
  929. fil_color = filament.get("color_hex") or ""
  930. if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
  931. return filament
  932. # Search external filaments (Bambu library)
  933. external = await self.get_external_filaments()
  934. for filament in external:
  935. fil_material = filament.get("material") or ""
  936. fil_color = filament.get("color_hex") or ""
  937. if fil_material.upper() == tray.tray_type.upper() and fil_color.upper() == color_hex.upper():
  938. # Found in external library - need to create internal copy
  939. return await self._create_filament_from_external(filament, tray)
  940. # Not found - create new Bambu Lab filament
  941. return await self.create_filament(
  942. name=tray.tray_sub_brands or tray.tray_type,
  943. vendor_id=bambu_vendor_id,
  944. material=tray.tray_type,
  945. color_hex=color_hex,
  946. weight=tray.tray_weight,
  947. )
  948. async def _create_filament_from_external(self, external: dict, tray: AMSTray) -> dict | None:
  949. """Create an internal Spoolman filament from an external library entry."""
  950. vendor_id = await self.ensure_bambu_vendor()
  951. return await self.create_filament(
  952. name=external.get("name", tray.tray_sub_brands),
  953. vendor_id=vendor_id,
  954. material=external.get("material", tray.tray_type),
  955. color_hex=external.get("color_hex", tray.tray_color[:6]),
  956. weight=external.get("weight", tray.tray_weight),
  957. )
  958. # Global client instance (initialized when settings are loaded)
  959. _spoolman_client: SpoolmanClient | None = None
  960. async def get_spoolman_client() -> SpoolmanClient | None:
  961. """Return the global SpoolmanClient, or None if not configured."""
  962. return _spoolman_client
  963. async def init_spoolman_client(url: str) -> SpoolmanClient:
  964. """Initialise (or reinitialise) the global SpoolmanClient; raises ValueError if url fails SSRF guard."""
  965. from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
  966. assert_safe_spoolman_url(url)
  967. global _spoolman_client
  968. if _spoolman_client:
  969. await _spoolman_client.close()
  970. _spoolman_client = SpoolmanClient(url)
  971. return _spoolman_client
  972. async def close_spoolman_client():
  973. """Close the global Spoolman client."""
  974. global _spoolman_client
  975. if _spoolman_client:
  976. await _spoolman_client.close()
  977. _spoolman_client = None