spoolman.py 78 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715
  1. """Spoolman integration service for syncing AMS filament data."""
  2. import asyncio
  3. import json
  4. import logging
  5. import math
  6. import time
  7. import weakref
  8. from dataclasses import dataclass
  9. from datetime import datetime, timezone
  10. from typing import Literal
  11. import httpx
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from backend.app.services.color_catalog_lookup import CatalogColor, best_product_line_match, resolve_bambu_color
  14. from backend.app.utils.color_utils import color_match_key, spoolman_color_hex
  15. logger = logging.getLogger(__name__)
  16. BAMBU_RFID_TAG_LENGTH = 32
  17. # How long a Spoolman without native tags is believed before it is asked again,
  18. # so upgrading Spoolman to 0.27 needs no Bambuddy restart.
  19. TAG_API_RECHECK_SECONDS = 600
  20. # How long a native tag Spoolman refused for a spool is left alone. The AMS sync
  21. # would otherwise ask again on every AMS update; this way a conflict settled in
  22. # Spoolman is still picked up without a restart.
  23. TAG_REFUSAL_RETRY_SECONDS = 600
  24. # Spool.extra key holding the consumed-counter baseline. Defined here rather
  25. # than imported from the routes package so the client does not depend on it;
  26. # _spoolman_helpers declares the same name for the read side (#2906), and
  27. # test_spoolman_reset_baseline_2906.py asserts the two are equal.
  28. BAMBU_WEIGHT_USED_BASELINE_KEY = "bambu_weight_used_baseline"
  29. @dataclass
  30. class SpoolmanSpool:
  31. """Represents a spool in Spoolman."""
  32. id: int
  33. filament_id: int | None
  34. remaining_weight: float | None
  35. used_weight: float
  36. first_used: str | None
  37. last_used: str | None
  38. location: str | None
  39. lot_nr: str | None
  40. comment: str | None
  41. extra: dict | None # Contains tag_uid in extra.tag
  42. @dataclass
  43. class SpoolmanFilament:
  44. """Represents a filament type in Spoolman."""
  45. id: int
  46. name: str
  47. vendor_id: int | None
  48. material: str | None
  49. color_hex: str | None
  50. weight: float | None # Net weight in grams
  51. @dataclass
  52. class AMSTray:
  53. """Represents an AMS tray with filament data from Bambu printer."""
  54. ams_id: int # 0-3 for regular AMS, 128-135 for AMS-HT, 254+ for external spool
  55. tray_id: int # 0-3
  56. tray_type: str # PLA, PETG, ABS, etc.
  57. tray_sub_brands: str # Full name like "PLA Basic", "PETG HF"
  58. tray_color: str # Hex color like "FEC600FF"
  59. remain: int # Remaining percentage (0-100)
  60. tag_uid: str # RFID tag UID
  61. tray_uuid: str # Spool UUID
  62. tray_info_idx: str # Bambu filament preset ID like "GFA00"
  63. tray_weight: int # Spool weight in grams (usually 1000)
  64. class SpoolmanNotFoundError(Exception):
  65. """Raised when a spool ID does not exist in Spoolman (HTTP 404)."""
  66. class SpoolmanUnavailableError(Exception):
  67. """Raised when Spoolman is unreachable or returns a server/network error."""
  68. class SpoolmanClientError(Exception):
  69. """Raised when Spoolman returns a 4xx client error (not 404)."""
  70. def __init__(self, message: str, status_code: int, response_text: str = "") -> None:
  71. super().__init__(message)
  72. self.status_code = status_code
  73. self.response_text = response_text
  74. def _filament_subtype_part(name: str, material: str) -> str:
  75. """Return the subtype portion of a filament name, lowercased.
  76. Mirrors the read-side derivation in
  77. ``backend/app/api/routes/_spoolman_helpers.py::_map_spoolman_spool``:
  78. if the filament name starts with the material prefix (e.g. ``"PLA Glow"``
  79. when material is ``"PLA"``), strip it; otherwise return the name as-is.
  80. Used by ``find_or_create_filament`` so that an existing filament saved by
  81. the AMS-sync path with name ``"Glow"`` still matches a user-driven edit
  82. that composes ``"PLA Glow"`` (#1357).
  83. """
  84. s = (name or "").strip()
  85. m = (material or "").strip()
  86. if m and s.upper().startswith(m.upper() + " "):
  87. return s[len(m) + 1 :].strip().lower()
  88. return s.lower()
  89. class SpoolmanClient:
  90. """Client for interacting with Spoolman API."""
  91. def __init__(self, base_url: str):
  92. """Initialize the Spoolman client."""
  93. self.base_url = base_url.rstrip("/")
  94. self.api_url = f"{self.base_url}/api/v1"
  95. self._client: httpx.AsyncClient | None = None
  96. self._connected = False
  97. # Per-spool locks for atomic read-modify-write in merge_spool_extra.
  98. # WeakValueDictionary: locks are GC'd once no coroutine holds a reference.
  99. self._extra_locks: weakref.WeakValueDictionary[int, asyncio.Lock] = weakref.WeakValueDictionary()
  100. # Extra-field names this client has already registered with Spoolman.
  101. # Bounded by the number of distinct keys Bambuddy writes, so it never
  102. # grows with spool count; scoped to the instance so a client pointed at
  103. # a different Spoolman starts over.
  104. self._ensured_extra_fields: set[str] = set()
  105. # Whether Spoolman's extra-field listing has been read once. Separate
  106. # from the set above because the two answer different questions: the
  107. # set is "which fields are known to exist", this is "have we asked".
  108. # Without it a client registering three brand-new fields re-read the
  109. # whole listing before each one.
  110. self._extra_fields_listed = False
  111. self._ensure_extra_lock = asyncio.Lock()
  112. # Whether this server links tags natively (Spoolman 0.27+), None until asked.
  113. # Per instance like the field cache above: a client for another URL asks again.
  114. self._tag_api: bool | None = None
  115. self._tag_api_asked_at = 0.0
  116. # (spool id, UID) -> when Spoolman last refused that native tag for that spool.
  117. self._refused_tags: dict[tuple[int, str], float] = {}
  118. async def _get_client(self) -> httpx.AsyncClient:
  119. """Get or create the HTTP client with connection pooling limits."""
  120. if self._client is None:
  121. self._client = httpx.AsyncClient(
  122. timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0),
  123. follow_redirects=False,
  124. verify=True,
  125. limits=httpx.Limits(
  126. max_keepalive_connections=5,
  127. max_connections=10,
  128. keepalive_expiry=30.0,
  129. ),
  130. )
  131. return self._client
  132. async def close(self):
  133. """Close the HTTP client."""
  134. if self._client:
  135. await self._client.aclose()
  136. self._client = None
  137. async def health_check(self) -> bool:
  138. """Check if Spoolman server is reachable; returns True if healthy."""
  139. try:
  140. client = await self._get_client()
  141. response = await client.get(f"{self.api_url}/health")
  142. self._connected = response.status_code == 200
  143. return self._connected
  144. except Exception as e:
  145. logger.warning(
  146. "Spoolman health check failed (url=%s, type=%s): %s",
  147. self.api_url,
  148. type(e).__name__,
  149. e,
  150. )
  151. self._connected = False
  152. return False
  153. @property
  154. def is_connected(self) -> bool:
  155. """Check if client is connected to Spoolman."""
  156. return self._connected
  157. async def get_spools(self) -> list[dict]:
  158. """Fetch all spools from Spoolman with up to 3 retries on connection errors."""
  159. max_attempts = 3
  160. retry_delay = 0.5 # 500ms
  161. for attempt in range(1, max_attempts + 1):
  162. try:
  163. client = await self._get_client()
  164. response = await client.get(f"{self.api_url}/spool")
  165. response.raise_for_status()
  166. spools = response.json()
  167. if attempt > 1:
  168. logger.info("Successfully fetched %d spools on attempt %d", len(spools), attempt)
  169. return spools
  170. except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ConnectError) as e:
  171. # Connection-related errors - close and recreate client for next attempt
  172. if attempt < max_attempts:
  173. logger.warning(
  174. "Connection error getting spools (attempt %d/%d): %s. Recreating client and retrying in %dms...",
  175. attempt,
  176. max_attempts,
  177. e,
  178. int(retry_delay * 1000),
  179. )
  180. # Close the stale client and recreate it
  181. await self.close()
  182. await asyncio.sleep(retry_delay)
  183. else:
  184. logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
  185. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  186. except Exception as e:
  187. # Other errors (HTTP errors, JSON decode errors, etc.)
  188. if attempt < max_attempts:
  189. logger.warning(
  190. "Failed to get spools from Spoolman (attempt %d/%d): %s. Retrying in %dms...",
  191. attempt,
  192. max_attempts,
  193. e,
  194. int(retry_delay * 1000),
  195. )
  196. await asyncio.sleep(retry_delay)
  197. else:
  198. logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e)
  199. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  200. async def _get_with_retry(self, path: str, params: dict | None = None) -> list[dict]:
  201. """GET a Spoolman JSON list endpoint with up to 3 retries on connection errors."""
  202. max_attempts = 3
  203. retry_delay = 0.5
  204. url = f"{self.api_url}/{path.lstrip('/')}"
  205. for attempt in range(1, max_attempts + 1):
  206. try:
  207. client = await self._get_client()
  208. response = await client.get(url, params=params or None)
  209. response.raise_for_status()
  210. return response.json()
  211. except (httpx.ReadError, httpx.RemoteProtocolError, httpx.ConnectError) as e:
  212. if attempt < max_attempts:
  213. logger.warning(
  214. "Connection error fetching %s (attempt %d/%d): %s. Recreating client and retrying in %dms...",
  215. path,
  216. attempt,
  217. max_attempts,
  218. e,
  219. int(retry_delay * 1000),
  220. )
  221. await self.close()
  222. await asyncio.sleep(retry_delay)
  223. else:
  224. logger.error("Failed to fetch %s from Spoolman after %d attempts: %s", path, max_attempts, e)
  225. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  226. except Exception as e:
  227. if attempt < max_attempts:
  228. logger.warning(
  229. "Failed to fetch %s from Spoolman (attempt %d/%d): %s. Retrying in %dms...",
  230. path,
  231. attempt,
  232. max_attempts,
  233. e,
  234. int(retry_delay * 1000),
  235. )
  236. await asyncio.sleep(retry_delay)
  237. else:
  238. logger.error("Failed to fetch %s from Spoolman after %d attempts: %s", path, max_attempts, e)
  239. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  240. async def get_filaments(self) -> list[dict]:
  241. """Fetch all internal filaments from Spoolman."""
  242. try:
  243. client = await self._get_client()
  244. response = await client.get(f"{self.api_url}/filament")
  245. response.raise_for_status()
  246. return response.json()
  247. except Exception as e:
  248. logger.error("Failed to get filaments from Spoolman: %s", e)
  249. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  250. async def get_filament(self, filament_id: int) -> dict:
  251. """Fetch a single filament by ID from Spoolman."""
  252. if filament_id <= 0:
  253. raise ValueError(f"Invalid filament_id: {filament_id}")
  254. response = await self._request_filament("GET", filament_id, operation="get_filament")
  255. return response.json()
  256. async def get_external_filaments(self) -> list[dict]:
  257. """Fetch external/library filaments from Spoolman."""
  258. try:
  259. client = await self._get_client()
  260. response = await client.get(f"{self.api_url}/external/filament")
  261. response.raise_for_status()
  262. return response.json()
  263. except Exception as e:
  264. logger.error("Failed to get external filaments from Spoolman: %s", e)
  265. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  266. async def get_vendors(self) -> list[dict]:
  267. """Fetch all vendors from Spoolman."""
  268. try:
  269. client = await self._get_client()
  270. response = await client.get(f"{self.api_url}/vendor")
  271. response.raise_for_status()
  272. return response.json()
  273. except Exception as e:
  274. logger.error("Failed to get vendors from Spoolman: %s", e)
  275. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  276. async def create_vendor(self, name: str) -> dict:
  277. """Create a new vendor in Spoolman."""
  278. try:
  279. client = await self._get_client()
  280. response = await client.post(f"{self.api_url}/vendor", json={"name": name})
  281. if 400 <= response.status_code < 500:
  282. raise SpoolmanClientError(
  283. f"Spoolman rejected vendor creation (HTTP {response.status_code})",
  284. response.status_code,
  285. )
  286. response.raise_for_status()
  287. return response.json()
  288. except SpoolmanClientError:
  289. raise
  290. except Exception as e:
  291. logger.error("Failed to create vendor in Spoolman: %s", e)
  292. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  293. def _get_material_density(self, material: str | None) -> float:
  294. """Return typical density (g/cm³) for the given filament material; defaults to PLA (1.24)."""
  295. # Typical densities for common filament materials
  296. densities = {
  297. "PLA": 1.24,
  298. "PLA-CF": 1.29,
  299. "PLA-S": 1.24,
  300. "PETG": 1.27,
  301. "ABS": 1.04,
  302. "ASA": 1.07,
  303. "TPU": 1.21,
  304. "PA": 1.14, # Nylon
  305. "PA-CF": 1.20,
  306. "PC": 1.20,
  307. "PVA": 1.23,
  308. "HIPS": 1.04,
  309. "PP": 0.90,
  310. "PET": 1.38,
  311. }
  312. if material:
  313. # Try exact match first, then uppercase
  314. mat_upper = material.upper()
  315. for key, density in densities.items():
  316. if key.upper() == mat_upper or mat_upper.startswith(key.upper()):
  317. return density
  318. return 1.24 # Default to PLA density
  319. async def create_filament(
  320. self,
  321. name: str,
  322. vendor_id: int | None = None,
  323. material: str | None = None,
  324. color_hex: str | None = None,
  325. color_name: str | None = None,
  326. weight: float | None = None,
  327. diameter: float = 1.75,
  328. density: float | None = None,
  329. ) -> dict:
  330. """Create a new filament in Spoolman."""
  331. if not name or not name.strip():
  332. raise ValueError("Filament name is required")
  333. if density is None:
  334. density = self._get_material_density(material)
  335. data: dict = {
  336. "name": name.strip(),
  337. "diameter": diameter,
  338. "density": density,
  339. }
  340. if vendor_id:
  341. data["vendor_id"] = vendor_id
  342. if material:
  343. data["material"] = material
  344. if color_hex:
  345. # Every create funnels through here, so this is where the stored shape
  346. # is decided: six characters for an opaque spool, eight only when the
  347. # alpha byte says the filament is translucent. See #2912.
  348. data["color_hex"] = spoolman_color_hex(color_hex) or color_hex
  349. if color_name:
  350. data["color_name"] = color_name
  351. if weight:
  352. data["weight"] = weight
  353. logger.debug("Creating filament in Spoolman: %s", data)
  354. try:
  355. client = await self._get_client()
  356. response = await client.post(f"{self.api_url}/filament", json=data)
  357. if 400 <= response.status_code < 500:
  358. raise SpoolmanClientError(
  359. f"Spoolman rejected filament creation (HTTP {response.status_code})",
  360. response.status_code,
  361. )
  362. response.raise_for_status()
  363. return response.json()
  364. except SpoolmanClientError:
  365. raise
  366. except Exception as e:
  367. logger.error("Failed to create filament in Spoolman: %s", e)
  368. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  369. async def patch_filament(self, filament_id: int, data: dict) -> dict:
  370. """PATCH a filament entry in Spoolman (e.g. update name or spool_weight)."""
  371. if filament_id <= 0:
  372. raise ValueError(f"Invalid filament_id: {filament_id}")
  373. response = await self._request_filament("PATCH", filament_id, json_body=data, operation="patch_filament")
  374. return response.json()
  375. async def create_spool(
  376. self,
  377. filament_id: int,
  378. remaining_weight: float | None = None,
  379. location: str | None = None,
  380. lot_nr: str | None = None,
  381. comment: str | None = None,
  382. extra: dict | None = None,
  383. spool_weight: float | None = None,
  384. initial_weight: float | None = None,
  385. ) -> dict:
  386. """Create a new spool in Spoolman."""
  387. data: dict = {"filament_id": filament_id}
  388. # The spool's own net weight. Left off, Spoolman copies the filament's
  389. # weight, which is wrong for a spool of another size (#3194).
  390. if initial_weight is not None:
  391. data["initial_weight"] = initial_weight
  392. if remaining_weight is not None:
  393. data["remaining_weight"] = remaining_weight
  394. if location:
  395. data["location"] = location
  396. if lot_nr:
  397. data["lot_nr"] = lot_nr
  398. if comment:
  399. data["comment"] = comment
  400. # `is not None`, not truthiness: 0 g is a legitimate tare (a spool-less
  401. # coil), and it is not the same answer as "inherit from the filament",
  402. # which is what leaving the field off means to Spoolman.
  403. if spool_weight is not None:
  404. data["spool_weight"] = spool_weight
  405. if extra:
  406. data["extra"] = extra
  407. await self._ensure_extra_fields(extra)
  408. logger.debug("Creating spool in Spoolman: %s", data)
  409. try:
  410. client = await self._get_client()
  411. response = await client.post(f"{self.api_url}/spool", json=data)
  412. if response.status_code == 404:
  413. raise SpoolmanNotFoundError(f"Filament {filament_id} not found in Spoolman")
  414. if 400 <= response.status_code < 500:
  415. raise SpoolmanClientError(
  416. f"Spoolman rejected spool creation (HTTP {response.status_code})",
  417. response.status_code,
  418. )
  419. response.raise_for_status()
  420. result = response.json()
  421. logger.info("Created spool %s in Spoolman", result.get("id"))
  422. return result
  423. except (SpoolmanNotFoundError, SpoolmanClientError):
  424. raise
  425. except Exception as e:
  426. logger.error("Failed to create spool in Spoolman: %s", e)
  427. raise SpoolmanUnavailableError("Cannot reach Spoolman") from e
  428. async def update_spool(
  429. self,
  430. spool_id: int,
  431. remaining_weight: float | None = None,
  432. location: str | None = None,
  433. clear_location: bool = False,
  434. extra: dict | None = None,
  435. ) -> dict:
  436. """Update an existing spool in Spoolman, always setting last_used."""
  437. data: dict = {}
  438. if remaining_weight is not None:
  439. data["remaining_weight"] = remaining_weight
  440. if clear_location:
  441. data["location"] = None
  442. elif location:
  443. data["location"] = location
  444. if extra:
  445. data["extra"] = extra
  446. await self._ensure_extra_fields(extra)
  447. data["last_used"] = datetime.now(timezone.utc).isoformat()
  448. response = await self._request_spool("PATCH", spool_id, json_body=data, operation="update")
  449. return response.json()
  450. async def _request_spool(
  451. self,
  452. method: Literal["GET", "PATCH", "DELETE"],
  453. spool_id: int,
  454. *,
  455. json_body: dict | None = None,
  456. operation: str,
  457. ) -> httpx.Response:
  458. """Perform a spool-scoped HTTP request, translating 404 and errors to named exceptions."""
  459. try:
  460. client = await self._get_client()
  461. response = await client.request(
  462. method,
  463. f"{self.api_url}/spool/{spool_id}",
  464. json=json_body,
  465. )
  466. if response.status_code == 404:
  467. raise SpoolmanNotFoundError(f"Spool {spool_id} not found in Spoolman")
  468. response.raise_for_status()
  469. return response
  470. except SpoolmanNotFoundError:
  471. raise
  472. except httpx.HTTPStatusError as e:
  473. if 400 <= e.response.status_code < 500:
  474. logger.warning(
  475. "Spoolman returned %d for %s spool %s",
  476. e.response.status_code,
  477. operation,
  478. spool_id,
  479. )
  480. raise SpoolmanClientError(
  481. f"Spoolman rejected {operation} for spool {spool_id} (HTTP {e.response.status_code})",
  482. e.response.status_code,
  483. e.response.text[:500],
  484. ) from e
  485. else:
  486. logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e)
  487. raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e
  488. except Exception as e:
  489. logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e)
  490. raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e
  491. async def _request_filament(
  492. self,
  493. method: Literal["GET", "PATCH"],
  494. filament_id: int,
  495. *,
  496. json_body: dict | None = None,
  497. operation: str,
  498. ) -> httpx.Response:
  499. """Perform a filament-scoped HTTP request, translating 404 and errors to named exceptions."""
  500. try:
  501. client = await self._get_client()
  502. response = await client.request(
  503. method,
  504. f"{self.api_url}/filament/{filament_id}",
  505. json=json_body,
  506. )
  507. if response.status_code == 404:
  508. raise SpoolmanNotFoundError(f"Filament {filament_id} not found in Spoolman")
  509. response.raise_for_status()
  510. return response
  511. except SpoolmanNotFoundError:
  512. raise
  513. except httpx.HTTPStatusError as e:
  514. if 400 <= e.response.status_code < 500:
  515. logger.warning(
  516. "Spoolman returned %d for %s filament %s",
  517. e.response.status_code,
  518. operation,
  519. filament_id,
  520. )
  521. raise SpoolmanClientError(
  522. f"Spoolman rejected {operation} for filament {filament_id} (HTTP {e.response.status_code})",
  523. e.response.status_code,
  524. e.response.text[:500],
  525. ) from e
  526. else:
  527. logger.error("Failed to %s filament %s in Spoolman: %s", operation, filament_id, e)
  528. raise SpoolmanUnavailableError(f"Failed to {operation} filament {filament_id}") from e
  529. except Exception as e:
  530. logger.error("Failed to %s filament %s in Spoolman: %s", operation, filament_id, e)
  531. raise SpoolmanUnavailableError(f"Failed to {operation} filament {filament_id}") from e
  532. async def get_spool(self, spool_id: int) -> dict:
  533. """Fetch a single spool by ID from Spoolman."""
  534. response = await self._request_spool("GET", spool_id, operation="get")
  535. return response.json()
  536. async def get_all_spools(self, allow_archived: bool = False) -> list[dict]:
  537. """Fetch all spools from Spoolman with retry, optionally including archived ones."""
  538. params: dict = {}
  539. if allow_archived:
  540. params["allow_archived"] = "true"
  541. return await self._get_with_retry("/spool", params=params or None)
  542. async def get_distinct_locations(self) -> list[str]:
  543. """Return distinct location strings currently assigned to Spoolman spools.
  544. Spoolman's `/location` endpoint shape varies across versions: older
  545. releases return `list[str]`, newer ones return `list[dict]` with a
  546. `name` field. Normalize to `list[str]` so callers can iterate without
  547. runtime shape checks.
  548. """
  549. raw = await self._get_with_retry("/location")
  550. if not isinstance(raw, list):
  551. return []
  552. names: list[str] = []
  553. for entry in raw:
  554. if isinstance(entry, str):
  555. names.append(entry)
  556. elif isinstance(entry, dict):
  557. name = entry.get("name")
  558. if isinstance(name, str):
  559. names.append(name)
  560. return names
  561. async def rename_location(self, current_name: str, new_name: str) -> int:
  562. """Bulk-rename a location string on all Spoolman spools.
  563. Tries the bulk `PATCH /location/{name}` endpoint first. Spoolman
  564. versions older than ~0.16 don't expose it and respond 404/405 — in
  565. that case fall back to iterating every spool currently at
  566. ``current_name`` and PATCHing each one's ``location`` field directly.
  567. Returns the number of spools renamed (or 0 if the bulk endpoint
  568. succeeded without enumerating).
  569. """
  570. from urllib.parse import quote
  571. encoded = quote(current_name, safe="")
  572. client = await self._get_client()
  573. try:
  574. response = await client.patch(
  575. f"{self.api_url}/location/{encoded}",
  576. json={"name": new_name},
  577. )
  578. response.raise_for_status()
  579. return 0
  580. except httpx.HTTPStatusError as exc:
  581. if exc.response.status_code not in (404, 405):
  582. raise
  583. logger.info(
  584. "Spoolman bulk-rename endpoint unavailable (status %d); falling back to per-spool PATCH",
  585. exc.response.status_code,
  586. )
  587. # Per-spool fallback: enumerate every spool currently at the old name
  588. # and PATCH each. Keep going on individual failures so a single
  589. # already-deleted spool doesn't strand the rest at the old name —
  590. # collect errors and re-raise as a single SpoolmanClientError if any
  591. # leftover survives.
  592. spools = await self.get_all_spools(allow_archived=True)
  593. renamed = 0
  594. failures: list[str] = []
  595. for spool in spools:
  596. if (spool.get("location") or "").strip() != current_name:
  597. continue
  598. try:
  599. await self._request_spool(
  600. "PATCH",
  601. spool["id"],
  602. json_body={"location": new_name},
  603. operation="rename-location",
  604. )
  605. renamed += 1
  606. except SpoolmanNotFoundError:
  607. continue
  608. except Exception as exc: # noqa: BLE001 — accumulate and re-raise below
  609. failures.append(f"spool {spool.get('id')}: {exc}")
  610. if failures:
  611. raise SpoolmanClientError(
  612. f"Spoolman rename fallback failed for {len(failures)} spool(s): {'; '.join(failures[:3])}",
  613. status_code=502,
  614. )
  615. return renamed
  616. async def delete_spool(self, spool_id: int) -> None:
  617. """Delete a spool from Spoolman."""
  618. await self._request_spool("DELETE", spool_id, operation="delete")
  619. async def is_filament_shared(self, filament_id: int, exclude_spool_id: int) -> bool:
  620. """True if any spool other than ``exclude_spool_id`` is linked to ``filament_id``.
  621. Used by the spool-edit path to decide between PATCHing the existing
  622. filament in place (singleton) and falling back to find_or_create
  623. (shared — re-linking the spool is the only safe option). Includes
  624. archived spools so a shared link doesn't suddenly look singleton just
  625. because the sibling spool was archived.
  626. """
  627. spools = await self.get_all_spools(allow_archived=True)
  628. for s in spools:
  629. if s.get("id") == exclude_spool_id:
  630. continue
  631. if ((s.get("filament") or {}).get("id")) == filament_id:
  632. return True
  633. return False
  634. async def set_spool_archived(self, spool_id: int, archived: bool) -> dict:
  635. """Archive or restore a spool in Spoolman."""
  636. response = await self._request_spool(
  637. "PATCH",
  638. spool_id,
  639. json_body={"archived": archived},
  640. operation="archive/restore",
  641. )
  642. return response.json()
  643. async def reset_spool_consumed_counter(self, spool_id: int) -> dict:
  644. """Zero the displayed consumed counter by recording a baseline in spool.extra.
  645. This used to PATCH ``used_weight = 0``, which is not a baseline: Spoolman
  646. recomputes ``remaining_weight`` as initial minus used, so zeroing used
  647. weight put the spool back to full and threw away the measured remaining
  648. filament. The confirmation copy promised the opposite in all thirteen
  649. locales (#2906).
  650. Writing the baseline into ``extra`` instead touches no native Spoolman
  651. field -- initial, remaining and used all survive -- and is the same
  652. mechanism internal mode has always used for its ``weight_used_baseline``
  653. column, which is what #1644 asked the two modes to share rather than
  654. approximate. ``_map_spoolman_spool`` folds the stored value back into the
  655. baseline it reports.
  656. Serialised on the same per-spool lock as ``merge_spool_extra`` so a
  657. concurrent tag or colour write cannot read the extra dict between this
  658. method's fetch and its PATCH and put the old one back.
  659. """
  660. async with self.extra_lock(spool_id):
  661. current = await self.get_spool(spool_id) # raises on error
  662. used_weight = current.get("used_weight")
  663. try:
  664. baseline = float(used_weight) if used_weight is not None else 0.0
  665. except (TypeError, ValueError):
  666. baseline = 0.0
  667. if not math.isfinite(baseline) or baseline < 0:
  668. baseline = 0.0
  669. merged = {
  670. **(current.get("extra") or {}),
  671. # Stored as a JSON *string*, not a JSON number. Spoolman
  672. # registers an unseen extra key as field_type "text" on first
  673. # write, and its validate_extra_field_value then requires the
  674. # value to decode to a str -- a bare 263.0 is rejected with
  675. # "Value is not a string." and the PATCH 400s, so the reset
  676. # would never land. It has to be right the first time:
  677. # add_or_update_extra_field refuses to change a field's type
  678. # afterwards, so one numeric write would pin the key to text on
  679. # that install permanently. tag, bambu_color_name and both
  680. # slicer keys already store the string form for this reason.
  681. BAMBU_WEIGHT_USED_BASELINE_KEY: json.dumps(str(baseline)),
  682. }
  683. return await self.update_spool_full(spool_id=spool_id, extra=merged)
  684. async def update_spool_full(
  685. self,
  686. spool_id: int,
  687. *,
  688. filament_id: int | None = None,
  689. remaining_weight: float | None = None,
  690. comment: str | None = None,
  691. price: float | None = None,
  692. location: str | None = None,
  693. clear_location: bool = False,
  694. extra: dict | None = None,
  695. spool_weight: float | None = None,
  696. clear_spool_weight: bool = False,
  697. initial_weight: float | None = None,
  698. ) -> dict:
  699. """Update a spool with full field support; unlike update_spool, does not auto-set last_used."""
  700. data: dict = {}
  701. if filament_id is not None:
  702. data["filament_id"] = filament_id
  703. # Spoolman applies the fields in its own schema order, which puts
  704. # initial_weight before remaining_weight, so a remaining_weight in the
  705. # same PATCH is measured against the new net weight.
  706. if initial_weight is not None:
  707. data["initial_weight"] = initial_weight
  708. if remaining_weight is not None:
  709. data["remaining_weight"] = remaining_weight
  710. if comment is not None:
  711. data["comment"] = comment if comment else None
  712. if price is not None:
  713. data["price"] = price
  714. if clear_location:
  715. data["location"] = None
  716. elif location is not None:
  717. data["location"] = location
  718. if extra is not None:
  719. data["extra"] = extra
  720. await self._ensure_extra_fields(extra)
  721. if clear_spool_weight:
  722. data["spool_weight"] = None
  723. elif spool_weight is not None:
  724. data["spool_weight"] = spool_weight
  725. response = await self._request_spool("PATCH", spool_id, json_body=data, operation="update")
  726. return response.json()
  727. def extra_lock(self, spool_id: int) -> asyncio.Lock:
  728. """Return (creating if needed) the per-spool asyncio.Lock used by merge_spool_extra."""
  729. lock = self._extra_locks.get(spool_id)
  730. if lock is None:
  731. lock = asyncio.Lock()
  732. self._extra_locks[spool_id] = lock
  733. return lock
  734. async def merge_spool_extra(self, spool_id: int, new_fields: dict) -> dict:
  735. """Fetch the spool's extra dict, merge new_fields into it, then PATCH back — serialised per spool."""
  736. async with self.extra_lock(spool_id):
  737. current = await self.get_spool(spool_id) # raises on error
  738. current_extra: dict = current.get("extra") or {}
  739. merged = {**current_extra, **new_fields}
  740. return await self.update_spool_full(spool_id=spool_id, extra=merged)
  741. async def find_or_create_vendor(self, name: str) -> int:
  742. """Return the Spoolman vendor ID for the given name, creating the vendor if absent."""
  743. vendors = await self.get_vendors()
  744. name_lower = name.strip().lower()
  745. for vendor in vendors:
  746. if vendor.get("name", "").strip().lower() == name_lower:
  747. return vendor["id"]
  748. created = await self.create_vendor(name.strip())
  749. vendor_id = created.get("id")
  750. if not vendor_id:
  751. raise SpoolmanUnavailableError(f"Spoolman returned vendor without id field: {list(created.keys())}")
  752. return vendor_id
  753. async def find_or_create_filament(
  754. self,
  755. material: str,
  756. subtype: str,
  757. brand: str | None,
  758. color_hex: str,
  759. label_weight: int,
  760. color_name: str | None = None,
  761. ) -> int:
  762. """Return the filament ID matching material/name/brand/color, creating it if absent."""
  763. name = f"{material} {subtype}".strip() if subtype else material
  764. # One value in both roles. `color_match_key` returns the shape the colour
  765. # would be stored as, so the key the loop below compares on and the value
  766. # a new filament is created with are the same string by construction: an
  767. # opaque spool keys and stores as six characters, a translucent one as
  768. # eight, and neither can be conflated with the other (#2912).
  769. color = color_match_key(color_hex)
  770. vendor_id: int | None = None
  771. if brand:
  772. vendor_id = await self.find_or_create_vendor(brand)
  773. # Normalised match keys (case-insensitive). Computed once outside the
  774. # loop so the inner comparison stays simple.
  775. composed_subtype = _filament_subtype_part(name, material)
  776. material_norm = material.upper()
  777. brand_norm = (brand or "").strip().lower()
  778. filaments = await self.get_filaments()
  779. for f in filaments:
  780. f_material = (f.get("material") or "").upper()
  781. f_color = color_match_key(f.get("color_hex"))
  782. f_vendor = f.get("vendor") or {}
  783. f_vendor_name = (f_vendor.get("name") or "").strip().lower()
  784. material_match = f_material == material_norm
  785. # Match on the subtype portion of the filament name. AMS-sync
  786. # auto-create (the underscore-prefixed `_find_or_create_filament`
  787. # used during MQTT tray import) stores the filament as just
  788. # ``tray.tray_sub_brands`` — e.g. ``"Glow"`` — while the
  789. # user-driven edit path here composes ``"<material> <subtype>"``
  790. # — ``"PLA Glow"``. The old literal equality `f_name == name`
  791. # failed to bridge the two shapes, so every edit fell through to
  792. # `create_filament`, leaving a trail of duplicate filaments AND
  793. # leaving the spool either still pointed at the old filament
  794. # whose `color_name` never got patched, or pointed at a new
  795. # filament with the colour while the inventory list kept
  796. # showing the synth fallback from the old one (#1357).
  797. f_subtype_part = _filament_subtype_part(f.get("name") or "", material)
  798. name_match = f_subtype_part == composed_subtype
  799. color_match = f_color == color
  800. vendor_match = (not brand) or f_vendor_name == brand_norm
  801. if material_match and name_match and color_match and vendor_match:
  802. # color_name is intentionally not part of the match key and
  803. # is no longer patched onto the filament here: Spoolman 0.23.1
  804. # has no `color_name` field on Filament (#1357 — confirmed
  805. # against the FilamentUpdateParameters schema). The earlier
  806. # #1319 fix tried to patch it and Spoolman silently dropped
  807. # the key, which is exactly why the user's edit looked "not
  808. # saved". The route now persists color_name via
  809. # spool.extra.bambu_color_name (see _map_spoolman_spool for
  810. # the read side); find_or_create_filament's only job is to
  811. # resolve the right filament_id for the spool link.
  812. return f["id"]
  813. # color_name omitted: Spoolman has no such field on Filament (#1357);
  814. # the user's color_name lands in spool.extra.bambu_color_name via the
  815. # route after find_or_create_filament returns the new id.
  816. filament = await self.create_filament(
  817. name=name,
  818. vendor_id=vendor_id,
  819. material=material,
  820. color_hex=color,
  821. weight=float(label_weight),
  822. )
  823. filament_id = filament.get("id")
  824. if not filament_id:
  825. raise SpoolmanUnavailableError(f"Spoolman returned filament without id field: {list(filament.keys())}")
  826. return filament_id
  827. async def use_spool(self, spool_id: int, used_weight: float) -> dict:
  828. """Record filament usage for a spool via the Spoolman /use endpoint."""
  829. try:
  830. client = await self._get_client()
  831. response = await client.put(
  832. f"{self.api_url}/spool/{spool_id}/use",
  833. json={"use_weight": used_weight},
  834. )
  835. if response.status_code == 404:
  836. raise SpoolmanNotFoundError(f"Spool {spool_id} not found in Spoolman")
  837. if 400 <= response.status_code < 500:
  838. raise SpoolmanClientError(
  839. f"Spoolman rejected use_spool for spool {spool_id} (HTTP {response.status_code})",
  840. response.status_code,
  841. )
  842. response.raise_for_status()
  843. return response.json()
  844. except (SpoolmanNotFoundError, SpoolmanClientError):
  845. raise
  846. except Exception as e:
  847. logger.error("Failed to record spool usage in Spoolman: %s", e)
  848. raise SpoolmanUnavailableError(f"Failed to record usage for spool {spool_id}") from e
  849. async def has_tag_api(self) -> bool:
  850. """Whether this Spoolman links tags natively (0.27+); older servers answer 404.
  851. A yes is kept for the client's lifetime. A no is asked again after
  852. TAG_API_RECHECK_SECONDS, so a Spoolman upgraded underneath is picked up.
  853. """
  854. stale = self._tag_api is False and time.monotonic() - self._tag_api_asked_at > TAG_API_RECHECK_SECONDS
  855. if self._tag_api is None or stale:
  856. try:
  857. client = await self._get_client()
  858. response = await client.get(f"{self.api_url}/tag/reader")
  859. except httpx.HTTPError as e:
  860. # Not cached: a Spoolman that is briefly down is asked again next time.
  861. logger.debug("Spoolman tag API probe failed: %s", e)
  862. return False
  863. self._tag_api = response.status_code == 200
  864. self._tag_api_asked_at = time.monotonic()
  865. logger.info("Spoolman native tags: %s", "available" if self._tag_api else "not available")
  866. return self._tag_api
  867. async def _tag_request(self, method: str, path: str, **kwargs) -> httpx.Response:
  868. """A request to Spoolman's tag endpoints, with errors as the typed exceptions the routes translate.
  869. 409 and 400 come back as responses: they are answers the callers act on.
  870. """
  871. try:
  872. client = await self._get_client()
  873. response = await client.request(method, f"{self.api_url}{path}", **kwargs)
  874. except httpx.HTTPError as e:
  875. raise SpoolmanUnavailableError(f"Cannot reach Spoolman for {method} {path}") from e
  876. if response.status_code in (400, 409) or response.status_code < 400:
  877. return response
  878. if response.status_code == 404:
  879. raise SpoolmanNotFoundError(f"Spoolman answered 404 for {method} {path}")
  880. if response.status_code < 500:
  881. raise SpoolmanClientError(
  882. f"Spoolman rejected {method} {path} (HTTP {response.status_code})",
  883. response.status_code,
  884. response.text[:500],
  885. )
  886. raise SpoolmanUnavailableError(f"Spoolman failed {method} {path} (HTTP {response.status_code})")
  887. async def find_spool_by_native_tag(self, uid: str) -> dict | None:
  888. """Return the spool a native tag is linked to, asked on the server side, or None."""
  889. response = await self._tag_request("GET", "/spool", params={"tag": uid})
  890. if response.status_code == 400:
  891. return None # not a hex UID, so no spool can hold it
  892. # Only a spool that really carries the tag counts: a server that does not
  893. # know the parameter ignores it and answers every spool.
  894. return next(
  895. (s for s in response.json() if any(t.get("uid") == uid for t in s.get("tags") or [])),
  896. None,
  897. )
  898. async def link_native_tag(self, spool_id: int, uid: str, tag_format: str | None = None) -> int | None:
  899. """Link a native tag to a spool; None on success, the holder's spool id on a conflict.
  900. A conflict whose holder is not a spool (a filament or a location) answers -1.
  901. """
  902. body: dict = {"uid": uid}
  903. if tag_format:
  904. body["format"] = tag_format
  905. response = await self._tag_request("POST", f"/spool/{spool_id}/tag", json=body)
  906. if response.status_code == 409:
  907. holder = response.json().get("spool_id")
  908. return holder if isinstance(holder, int) else -1
  909. if response.status_code == 400:
  910. raise SpoolmanClientError(f"Spoolman refused tag {uid!r}", 400, response.text[:500])
  911. return None
  912. async def claim_native_tag(self, spool_id: int, uid: str, tag_format: str | None = None) -> int | None:
  913. """Link a native tag to a spool, taking it from an archived spool that still holds it.
  914. Spoolman keeps a tag unique across archived spools too, while Bambuddy never
  915. looks at those: a spool is archived once it is used up or replaced, so the tag
  916. it still holds belongs to the active spool now. A tag held by an active spool,
  917. a filament or a location is refused as with link_native_tag.
  918. """
  919. holder = await self.link_native_tag(spool_id, uid, tag_format)
  920. if holder is None or holder < 0:
  921. return holder
  922. try:
  923. held_by = await self.get_spool(holder)
  924. except SpoolmanNotFoundError:
  925. return holder
  926. if not held_by.get("archived"):
  927. return holder
  928. await self.unlink_native_tag(holder, uid)
  929. logger.info("Native tag %s moved from archived spool %s to spool %s", uid, holder, spool_id)
  930. return await self.link_native_tag(spool_id, uid, tag_format)
  931. async def unlink_native_tag(self, spool_id: int, uid: str) -> None:
  932. """Remove a native tag from a spool; a tag the spool does not hold is not an error."""
  933. try:
  934. await self._tag_request("DELETE", f"/spool/{spool_id}/tag/{uid}")
  935. except SpoolmanNotFoundError:
  936. pass
  937. async def unlink_all_native_tags(self, spool: dict) -> None:
  938. """Remove every native tag a spool carries. No-op on a server without the tag API."""
  939. if not await self.has_tag_api():
  940. return
  941. for tag in spool.get("tags") or []:
  942. uid = tag.get("uid")
  943. if uid:
  944. await self.unlink_native_tag(int(spool["id"]), uid)
  945. async def add_native_tags(
  946. self,
  947. spool: dict,
  948. uids: list[str | None],
  949. tag_format: str | None = None,
  950. *,
  951. retry_refused: bool = False,
  952. ) -> int:
  953. """Link the given UIDs to the spool as native tags, skipping those it already has.
  954. Best-effort: a UID another spool holds, or a failed request, is logged and
  955. skipped, so a caller never loses its main result over this. A refused UID is
  956. not asked for again for TAG_REFUSAL_RETRY_SECONDS, and warned about once:
  957. the AMS sync calls this on every AMS update. A scan passes retry_refused,
  958. since someone is standing at the spool and may just have settled the
  959. conflict in Spoolman. Returns how many tags were added.
  960. """
  961. if not await self.has_tag_api():
  962. return 0
  963. spool_id = int(spool["id"])
  964. have = {t.get("uid") for t in spool.get("tags") or []}
  965. wanted = {u.strip('"').upper() for u in uids if u and u.strip('"').strip("0")}
  966. now = time.monotonic()
  967. added = 0
  968. for uid in sorted(wanted - have):
  969. refused_at = self._refused_tags.get((spool_id, uid))
  970. if not retry_refused and refused_at is not None and now - refused_at < TAG_REFUSAL_RETRY_SECONDS:
  971. continue
  972. try:
  973. holder = await self.claim_native_tag(spool_id, uid, tag_format)
  974. except (SpoolmanNotFoundError, SpoolmanClientError, SpoolmanUnavailableError) as e:
  975. logger.warning("Could not add native tag %s to spool %s: %s", uid, spool_id, e)
  976. continue
  977. if holder is None:
  978. added += 1
  979. self._refused_tags.pop((spool_id, uid), None)
  980. logger.info("Added native tag %s to Spoolman spool %s", uid, spool_id)
  981. continue
  982. log = logger.debug if refused_at is not None else logger.warning
  983. where = f"spool {holder}" if holder > 0 else "a filament or location"
  984. log("Native tag %s belongs to %s, not added to spool %s", uid, where, spool_id)
  985. self._refused_tags[(spool_id, uid)] = now
  986. return added
  987. async def find_spool_by_tag(self, tag_uid: str, cached_spools: list[dict] | None = None) -> dict | None:
  988. """Return the spool matching the given RFID tag UID, or None if not found.
  989. Native tags (Spoolman 0.27+) are asked first: out of the cached list when there
  990. is one, otherwise with one server-side query instead of loading every spool.
  991. extra.tag stays the fallback for spools that were linked before.
  992. """
  993. search_tag = tag_uid.strip('"').upper()
  994. if cached_spools is not None:
  995. for spool in cached_spools:
  996. if any(t.get("uid") == search_tag for t in spool.get("tags") or []):
  997. return spool
  998. elif await self.has_tag_api():
  999. try:
  1000. spool = await self.find_spool_by_native_tag(search_tag)
  1001. except (SpoolmanClientError, SpoolmanUnavailableError) as e:
  1002. logger.warning("Native tag lookup failed, falling back to extra.tag: %s", e)
  1003. spool = None
  1004. if spool is not None:
  1005. return spool
  1006. # Use cached spools if provided, otherwise fetch from API
  1007. spools = cached_spools if cached_spools is not None else await self.get_spools()
  1008. for spool in spools:
  1009. extra = spool.get("extra", {})
  1010. if extra:
  1011. stored_tag = extra.get("tag", "")
  1012. # Normalize stored tag (strip quotes, uppercase)
  1013. if stored_tag:
  1014. normalized_tag = stored_tag.strip('"').upper()
  1015. if normalized_tag == search_tag:
  1016. logger.debug("Found spool %s matching tag %s", spool["id"], tag_uid)
  1017. return spool
  1018. return None
  1019. def _find_spool_by_location(self, location: str, cached_spools: list[dict] | None) -> dict | None:
  1020. """Return the spool at the exact location string, or None; fallback when RFID is unavailable."""
  1021. if not cached_spools:
  1022. return None
  1023. for spool in cached_spools:
  1024. if spool.get("location") == location:
  1025. return spool
  1026. return None
  1027. async def find_spools_by_location_prefix(
  1028. self, location_prefix: str, cached_spools: list[dict] | None = None
  1029. ) -> list[dict]:
  1030. """Return all spools whose location starts with location_prefix."""
  1031. # Use cached spools if provided, otherwise fetch from API
  1032. spools = cached_spools if cached_spools is not None else await self.get_spools()
  1033. matching = []
  1034. for spool in spools:
  1035. location = spool.get("location", "")
  1036. if location and location.startswith(location_prefix):
  1037. matching.append(spool)
  1038. return matching
  1039. async def clear_location_for_removed_spools(
  1040. self,
  1041. printer_name: str,
  1042. current_tray_uuids: set[str],
  1043. cached_spools: list[dict] | None = None,
  1044. synced_spool_ids: set[int] | None = None,
  1045. ) -> int:
  1046. """Clear location for Bambu Lab spools at this printer whose tray_uuid is no longer in the AMS."""
  1047. location_prefix = f"{printer_name} - "
  1048. spools_at_printer = await self.find_spools_by_location_prefix(location_prefix, cached_spools=cached_spools)
  1049. cleared_count = 0
  1050. for spool in spools_at_printer:
  1051. spool_id = spool.get("id")
  1052. # Skip spools that were just synced (matched by location or tag)
  1053. if synced_spool_ids and spool_id in synced_spool_ids:
  1054. continue
  1055. # Get the tray_uuid (stored as "tag" in extra field)
  1056. extra = spool.get("extra", {}) or {}
  1057. stored_tag = extra.get("tag", "")
  1058. if stored_tag:
  1059. # Normalize: strip quotes and uppercase
  1060. spool_uuid = stored_tag.strip('"').upper()
  1061. else:
  1062. spool_uuid = ""
  1063. # Only clear location for Bambu Lab spools (those with a stored 32-character RFID tag).
  1064. if len(spool_uuid) != BAMBU_RFID_TAG_LENGTH:
  1065. continue
  1066. # If this spool's UUID is not in the current AMS, clear its location
  1067. if spool_uuid not in current_tray_uuids:
  1068. logger.info(
  1069. f"Clearing location for spool {spool_id} "
  1070. f"(was: {spool.get('location')}, uuid: {spool_uuid[:16] if spool_uuid else 'none'}...)"
  1071. )
  1072. result = await self.update_spool(spool_id=spool_id, clear_location=True)
  1073. if result:
  1074. cleared_count += 1
  1075. return cleared_count
  1076. async def ensure_bambu_vendor(self) -> int | None:
  1077. """Return the Bambu Lab vendor ID in Spoolman, creating the vendor if absent."""
  1078. vendors = await self.get_vendors()
  1079. for vendor in vendors:
  1080. if vendor.get("name", "").lower() == "bambu lab":
  1081. return vendor["id"]
  1082. # Create Bambu Lab vendor if not exists
  1083. vendor = await self.create_vendor("Bambu Lab")
  1084. return vendor["id"] if vendor else None
  1085. async def ensure_tag_extra_field(self) -> bool:
  1086. """Register the 'tag' extra field in Spoolman if not present; returns True on success."""
  1087. return await self.ensure_extra_field("tag")
  1088. async def _load_existing_extra_field_keys(self) -> set[str] | None:
  1089. """Keys of the spool extra fields Spoolman already has, or ``None`` when
  1090. the listing could not be read.
  1091. ``None`` and ``set()`` mean different things and the caller acts on the
  1092. difference: an empty set is "Spoolman has no extra fields", which means
  1093. every field Bambuddy needs must be created; ``None`` is "we could not
  1094. find out", where the only safe move is to fall back to attempting the
  1095. write blind.
  1096. """
  1097. try:
  1098. client = await self._get_client()
  1099. response = await client.get(f"{self.api_url}/field/spool")
  1100. if response.status_code != 200:
  1101. logger.debug(
  1102. "Spoolman extra-field listing returned %s; falling back to blind registration",
  1103. response.status_code,
  1104. )
  1105. return None
  1106. fields = response.json()
  1107. except Exception as e: # noqa: BLE001 — registration is best-effort, see _ensure_extra_fields
  1108. logger.debug("Could not read Spoolman extra-field listing: %s", e)
  1109. return None
  1110. if not isinstance(fields, list):
  1111. return None
  1112. # Match on `key`, not `name`: `key` is the identifier the extra dict is
  1113. # written under and the one Bambuddy cares about, while `name` is the
  1114. # free-text label a user is free to change in Spoolman's UI.
  1115. return {f["key"] for f in fields if isinstance(f, dict) and isinstance(f.get("key"), str)}
  1116. async def ensure_extra_field(self, name: str, field_type: str = "text") -> bool:
  1117. """Register a custom extra field in Spoolman if not present.
  1118. Spoolman rejects PATCH requests that include unknown extra-dict keys
  1119. with HTTP 400 ('Unknown extra field <name>.'), so any custom field
  1120. Bambuddy persists alongside spools needs to be pre-registered.
  1121. Idempotent — returns True if the field already exists.
  1122. Existence is read from ``GET /field/spool``, the whole-listing endpoint.
  1123. This used to probe ``GET /field/spool/{name}`` for one field at a time,
  1124. which Spoolman has never served: it declares only POST and DELETE at
  1125. that path, so the probe answered 405 every time and the check could
  1126. never succeed (issue #2983, reported by @ngreatorex).
  1127. Falling through to the POST on every call was worse than a wasted
  1128. request, because that endpoint is an upsert rather than a create. It
  1129. answered 200 whether or not the field was already there, so a field a
  1130. user had renamed, retyped or given a default in Spoolman's own UI was
  1131. silently reset to Bambuddy's version of it on every restart. Reading
  1132. the listing first is what lets an existing field be left alone.
  1133. """
  1134. try:
  1135. if name in self._ensured_extra_fields:
  1136. return True
  1137. if not self._extra_fields_listed:
  1138. existing = await self._load_existing_extra_field_keys()
  1139. if existing is not None:
  1140. # Bank the whole listing: the caller registers several
  1141. # fields in a row, and each one it already has is a request
  1142. # not sent and a user customisation not overwritten. Read
  1143. # once per client — every field created after this point is
  1144. # added to the set as it is created, so re-reading would
  1145. # only ever confirm what we already know.
  1146. self._ensured_extra_fields |= existing
  1147. self._extra_fields_listed = True
  1148. if name in existing:
  1149. logger.debug("Spoolman extra field %r already exists", name)
  1150. return True
  1151. client = await self._get_client()
  1152. field_data = {
  1153. "name": name,
  1154. "field_type": field_type,
  1155. "default_value": None,
  1156. }
  1157. response = await client.post(f"{self.api_url}/field/spool/{name}", json=field_data)
  1158. if response.status_code in (200, 201):
  1159. logger.info("Created Spoolman extra field %r", name)
  1160. self._ensured_extra_fields.add(name)
  1161. return True
  1162. logger.warning(
  1163. "Failed to create Spoolman extra field %r: %s - %s",
  1164. name,
  1165. response.status_code,
  1166. response.text,
  1167. )
  1168. return False
  1169. except Exception as e:
  1170. logger.warning("Failed to ensure Spoolman extra field %r exists: %s", name, e)
  1171. return False
  1172. async def _ensure_extra_fields(self, extra: dict | None) -> None:
  1173. """Register every extra key an outgoing write declares, once per client.
  1174. Spoolman answers HTTP 400 "Unknown extra field <name>." for any extra
  1175. key that was not registered first, so registration has to happen before
  1176. the write, not before the feature. It used to happen before the feature:
  1177. three hand-maintained lists (the connect route, startup, and two inline
  1178. blocks in the inventory routes) each named the fields they expected to
  1179. be written later. Enabling Spoolman from Settings reaches none of them,
  1180. so the first AMS sync on a fresh Spoolman failed on every slot -- and
  1181. the Connect button that would have registered them is hidden by then,
  1182. because saving the settings initialises the client and the status
  1183. endpoint reads that as "connected" (issue #2903).
  1184. Keying off the payload instead removes the chance to forget: a write
  1185. that carries a key is a write that registers it. ``bambu_color_name``
  1186. is the cautionary case -- it never made it into the connect or startup
  1187. lists, and only works today because two call sites remembered to
  1188. register it by hand.
  1189. Best-effort by design. ``ensure_extra_field`` logs and returns False
  1190. rather than raising, and a failure here must not turn a write that
  1191. might still succeed into one that never happens -- the caller's own
  1192. error handling stays exactly as it was.
  1193. """
  1194. names = [name for name in (extra or {}) if name not in self._ensured_extra_fields]
  1195. if not names:
  1196. return
  1197. async with self._ensure_extra_lock:
  1198. for name in names:
  1199. # Re-check under the lock: a concurrent write may have just
  1200. # registered this one, and two syncs racing to POST the same
  1201. # field is how one of them gets a needless warning logged.
  1202. if name not in self._ensured_extra_fields:
  1203. await self.ensure_extra_field(name)
  1204. def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
  1205. """Parse raw MQTT tray data into an AMSTray; returns None for empty or invalid trays."""
  1206. # Skip empty trays - check for valid tray_type
  1207. tray_type = tray_data.get("tray_type", "")
  1208. if not tray_type or tray_type.strip() == "":
  1209. return None
  1210. # Need valid color to create filament
  1211. tray_color = tray_data.get("tray_color", "")
  1212. if not tray_color or tray_color.strip() == "":
  1213. logger.debug("Skipping tray with empty color")
  1214. return None
  1215. # Transparent filament (alpha=00) used to be rewritten to a cream
  1216. # "natural PLA" colour before being stored, because the swatch
  1217. # renderer couldn't show alpha. The swatch now paints a checkerboard
  1218. # underlay for translucent rgbas (see filamentSwatchHelpers.ts), so
  1219. # we pass `00000000` through verbatim — the inventory row keeps the
  1220. # AMS-reported colour and the frontend resolves the name to "Clear"
  1221. # via getColorName (#1545).
  1222. # Get sub_brands, falling back to tray_type
  1223. tray_sub_brands = tray_data.get("tray_sub_brands", "")
  1224. if not tray_sub_brands or tray_sub_brands.strip() == "":
  1225. tray_sub_brands = tray_type
  1226. # Get tag_uid and tray_uuid, filtering out empty/invalid values
  1227. tag_uid = tray_data.get("tag_uid", "")
  1228. if tag_uid in ("", "0000000000000000"):
  1229. tag_uid = ""
  1230. tray_uuid = tray_data.get("tray_uuid", "")
  1231. if tray_uuid in ("", "00000000000000000000000000000000"):
  1232. tray_uuid = ""
  1233. # Get tray_info_idx (Bambu filament preset ID like "GFA00")
  1234. tray_info_idx = tray_data.get("tray_info_idx", "") or ""
  1235. # Get remaining percentage (-1 means unknown/not read by AMS)
  1236. remain = int(tray_data.get("remain", -1))
  1237. return AMSTray(
  1238. ams_id=ams_id,
  1239. tray_id=int(tray_data.get("id", 0)),
  1240. tray_type=tray_type.strip(),
  1241. tray_sub_brands=tray_sub_brands.strip(),
  1242. tray_color=tray_color,
  1243. remain=remain,
  1244. tag_uid=tag_uid,
  1245. tray_uuid=tray_uuid,
  1246. tray_info_idx=tray_info_idx.strip(),
  1247. tray_weight=int(tray_data.get("tray_weight", 1000)),
  1248. )
  1249. def convert_ams_slot_to_location(self, ams_id: int, tray_id: int) -> str:
  1250. """Return a human-readable location string (e.g. "AMS A1") for the given AMS slot."""
  1251. if ams_id >= 254:
  1252. return "External Spool"
  1253. if 128 <= ams_id <= 135:
  1254. # AMS-HT units use IDs 128-135
  1255. ht_letter = chr(ord("A") + (ams_id - 128))
  1256. return f"AMS-HT {ht_letter}{tray_id + 1}"
  1257. ams_letter = chr(ord("A") + ams_id)
  1258. return f"AMS {ams_letter}{tray_id + 1}"
  1259. def is_bambu_lab_spool(self, tray_uuid: str, tag_uid: str = "", tray_info_idx: str = "") -> bool:
  1260. """Return True if tray_uuid or tag_uid identifies a Bambu Lab spool; tray_info_idx is ignored."""
  1261. # Check tray_uuid (preferred - consistent across printer models)
  1262. if tray_uuid:
  1263. uuid = tray_uuid.strip()
  1264. if len(uuid) == 32 and uuid != "00000000000000000000000000000000":
  1265. try:
  1266. int(uuid, 16)
  1267. return True
  1268. except ValueError:
  1269. pass
  1270. # Fallback: check tag_uid (RFID tag - varies between printer readers)
  1271. # Bambu Lab RFID tags are 16 hex characters (8 bytes)
  1272. if tag_uid:
  1273. tag = tag_uid.strip()
  1274. if len(tag) == 16 and tag != "0000000000000000":
  1275. try:
  1276. int(tag, 16)
  1277. logger.debug("Identified Bambu Lab spool via tag_uid fallback: %s", tag)
  1278. return True
  1279. except ValueError:
  1280. pass
  1281. return False
  1282. def calculate_remaining_weight(self, remain_percent: int, spool_weight: int) -> float:
  1283. """Return remaining filament weight in grams given a percentage and total spool weight."""
  1284. return (remain_percent / 100.0) * spool_weight
  1285. async def sync_ams_tray(
  1286. self,
  1287. tray: AMSTray,
  1288. printer_name: str,
  1289. db: AsyncSession,
  1290. disable_weight_sync: bool = False,
  1291. cached_spools: list[dict] | None = None,
  1292. inventory_remaining: float | None = None,
  1293. spoolman_spool_id_hint: int | None = None,
  1294. auto_add_unknown_rfid: bool = True,
  1295. ) -> dict | None:
  1296. """Sync one AMS tray to Spoolman; creates the spool on first sight, updates weight otherwise."""
  1297. logger.debug(
  1298. f"Processing {printer_name} AMS {tray.ams_id} tray {tray.tray_id}: "
  1299. f"type={tray.tray_type}, idx={tray.tray_info_idx or 'none'}, "
  1300. f"uuid={tray.tray_uuid[:16] if tray.tray_uuid else 'none'}, "
  1301. f"tag={tray.tag_uid[:8] if tray.tag_uid else 'none'}..."
  1302. )
  1303. # Determine which identifier to use for Spoolman (prefer tray_uuid, fallback to tag_uid)
  1304. # Zero-filled values mean the AMS hasn't read the RFID tag — treat as no tag
  1305. zero_uuid = "00000000000000000000000000000000"
  1306. zero_tag = "0000000000000000"
  1307. spool_tag = None
  1308. if tray.tray_uuid and tray.tray_uuid != zero_uuid:
  1309. spool_tag = tray.tray_uuid
  1310. elif tray.tag_uid and tray.tag_uid != zero_tag:
  1311. spool_tag = tray.tag_uid
  1312. # Calculate remaining weight
  1313. # Primary: AMS MQTT data (remain percentage + tray_weight)
  1314. # Fallback: Built-in inventory tracked weight (when firmware sends invalid remain/tray_weight)
  1315. if tray.remain >= 0 and tray.tray_weight > 0:
  1316. remaining = self.calculate_remaining_weight(tray.remain, tray.tray_weight)
  1317. elif inventory_remaining is not None:
  1318. remaining = inventory_remaining
  1319. logger.debug(
  1320. "Using inventory weight fallback for %s AMS %s tray %s: %.1fg",
  1321. printer_name,
  1322. tray.ams_id,
  1323. tray.tray_id,
  1324. remaining,
  1325. )
  1326. else:
  1327. remaining = None
  1328. if spool_tag:
  1329. # Primary path: match by RFID tag
  1330. existing = await self.find_spool_by_tag(spool_tag, cached_spools=cached_spools)
  1331. is_bambu = self.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx)
  1332. tag_format = "bambu" if is_bambu else None
  1333. # The AMS reports a Bambu chip's 4-byte UID padded to 8 bytes
  1334. # ("D3E68F32" arrives as "D3E68F3200000100"). A native tag is the UID any
  1335. # reader sees, so only the chip's own 4 bytes go there.
  1336. chip_uid = tray.tag_uid[:8] if is_bambu and tray.tag_uid and len(tray.tag_uid) == 16 else tray.tag_uid
  1337. # A spool linked by its chip UID alone - one side of a Bambu spool read by
  1338. # SpoolBuddy, say - is found by that too, not only by the tray UUID. A
  1339. # reader stores the chip's own UID, a link from the AMS the padded one.
  1340. if tray.tag_uid and tray.tag_uid != zero_tag:
  1341. for uid in dict.fromkeys((chip_uid, tray.tag_uid)):
  1342. if existing is None and uid != spool_tag:
  1343. existing = await self.find_spool_by_tag(uid, cached_spools=cached_spools)
  1344. if existing:
  1345. logger.info("Updating existing spool %s for tag %s...", existing["id"], spool_tag[:16])
  1346. # Native tags collect what the AMS reads: the tray UUID, and the chip
  1347. # UID of whichever side faces the reader, so both sides end up linked.
  1348. await self.add_native_tags(existing, [tray.tray_uuid, chip_uid], tag_format)
  1349. return await self.update_spool(
  1350. spool_id=existing["id"],
  1351. remaining_weight=None if disable_weight_sync else remaining,
  1352. )
  1353. # Spool not found by tag - auto-create it, unless the user has
  1354. # opted out of auto-adding unknown RFIDs (settings.auto_add_unknown_rfid).
  1355. # Caller broadcasts unknown_tag on the resulting None so the UI can
  1356. # surface a "+ Add to inventory" affordance on the slot.
  1357. if not auto_add_unknown_rfid:
  1358. logger.info(
  1359. "Auto-add disabled; skipping Spoolman spool create for %s (tag: %s...)",
  1360. tray.tray_sub_brands,
  1361. spool_tag[:16],
  1362. )
  1363. return None
  1364. logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
  1365. catalog_color = None
  1366. if self.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
  1367. catalog_color = await resolve_bambu_color(db, tray.tray_color, (tray.tray_sub_brands or "").strip())
  1368. filament = await self._find_or_create_filament(tray, catalog_color)
  1369. filament_id = filament["id"] if filament else None
  1370. else:
  1371. # Non-BL spool with custom RFID: use generic vendor lookup
  1372. brand = tray.tray_sub_brands if tray.tray_sub_brands != tray.tray_type else None
  1373. try:
  1374. filament_id = await self.find_or_create_filament(
  1375. material=tray.tray_type,
  1376. subtype="",
  1377. brand=brand,
  1378. color_hex=tray.tray_color,
  1379. label_weight=tray.tray_weight,
  1380. )
  1381. except (SpoolmanNotFoundError, SpoolmanUnavailableError, SpoolmanClientError):
  1382. logger.warning("Could not find or create filament for non-BL spool %s", tray.tray_sub_brands)
  1383. return None
  1384. if not filament_id:
  1385. logger.error("Failed to find or create filament for %s", tray.tray_sub_brands)
  1386. return None
  1387. extra = {"tag": json.dumps(spool_tag)}
  1388. # The colour name goes where #1357 put it, not into the filament. A
  1389. # Spoolman Filament has no color_name, so the route stores the user's
  1390. # choice under spool.extra.bambu_color_name and _map_spoolman_spool
  1391. # reads that first. This path never wrote it, which is why the colour
  1392. # ended up in filament.name and the product line fell out of subtype.
  1393. # _ensure_extra_fields registers the key from the payload (#2903).
  1394. if catalog_color is not None:
  1395. extra["bambu_color_name"] = json.dumps(catalog_color.name)
  1396. created = await self.create_spool(
  1397. filament_id=filament_id,
  1398. remaining_weight=remaining,
  1399. comment="Created by Bambuddy",
  1400. extra=extra,
  1401. )
  1402. await self.add_native_tags(created, [tray.tray_uuid, chip_uid], tag_format)
  1403. return created
  1404. # No-RFID fallback: use the spool ID resolved from the local slot-assignment table.
  1405. # Never create new spools without a tag to avoid duplicates.
  1406. if spoolman_spool_id_hint is not None:
  1407. existing = next((s for s in (cached_spools or []) if s.get("id") == spoolman_spool_id_hint), None)
  1408. if existing is None:
  1409. try:
  1410. existing = await self.get_spool(spoolman_spool_id_hint)
  1411. except (SpoolmanNotFoundError, SpoolmanUnavailableError):
  1412. existing = None
  1413. if existing:
  1414. logger.info(
  1415. "Updating spool %s by slot-assignment hint (no RFID tag available)",
  1416. existing["id"],
  1417. )
  1418. return await self.update_spool(
  1419. spool_id=existing["id"],
  1420. remaining_weight=None if disable_weight_sync else remaining,
  1421. )
  1422. logger.info(
  1423. "%s AMS %s tray %s — skipping (no RFID tag and no slot-assignment hint)",
  1424. printer_name,
  1425. tray.ams_id,
  1426. tray.tray_id,
  1427. )
  1428. return None
  1429. async def _find_or_create_filament(self, tray: AMSTray, catalog_color: CatalogColor | None) -> dict | None:
  1430. """Return the Bambu Lab filament for this tray's product line, creating it if absent.
  1431. Material plus colour is not an identity. PLA Basic Black and PLA Matte
  1432. Charcoal are both PLA at ``#000000``, so a Matte roll was linked to the
  1433. Basic filament and inherited its name (#2907). The product line lives in
  1434. ``tray_sub_brands``, so the filament's name carries it -- the same place
  1435. internal mode keeps it, as the subtype _map_spoolman_spool derives from
  1436. the name. The colour name belongs to the spool (``bambu_color_name``),
  1437. which the caller writes from ``catalog_color``.
  1438. ``catalog_color`` is what the colour catalogue says about this roll, or
  1439. None when it has no row. It is resolved by the caller rather than here so
  1440. this client stays pure HTTP and the caller can store the name too.
  1441. """
  1442. bambu_vendor_id = await self.ensure_bambu_vendor()
  1443. material_upper = tray.tray_type.upper()
  1444. # Same single value as the user-driven path: the match key is the stored
  1445. # shape. That is what lets an opaque tray still find the six-character
  1446. # filaments every existing instance is full of, while a clear tray keys
  1447. # to eight and gets its own record (#2912).
  1448. color = color_match_key(tray.tray_color)
  1449. sub_brand = (tray.tray_sub_brands or "").strip()
  1450. color_name = catalog_color.name if catalog_color else None
  1451. # Search internal filaments - only match Bambu Lab vendor
  1452. same_colour = []
  1453. for filament in await self.get_filaments():
  1454. fil_vendor_id = filament.get("vendor_id") or filament.get("vendor", {}).get("id")
  1455. if fil_vendor_id != bambu_vendor_id:
  1456. continue
  1457. fil_material = filament.get("material") or ""
  1458. if fil_material.upper() == material_upper and color_match_key(filament.get("color_hex")) == color:
  1459. same_colour.append(filament)
  1460. existing = best_product_line_match(same_colour, color_name, sub_brand, tray.tray_type)
  1461. if existing is not None:
  1462. return existing
  1463. # Search external filaments (SpoolmanDB) — restrict to Bambu Lab only.
  1464. # The /api/v1/external/filament endpoint returns the full multi-vendor catalog
  1465. # with no server-side filter, so without a manufacturer check the first PLA/black
  1466. # hit is typically 3DJAKE or 3DXTECH, not Bambu Lab.
  1467. external = await self.get_external_filaments()
  1468. bambu_candidates = []
  1469. for filament in external:
  1470. manufacturer = (filament.get("manufacturer") or "").strip().lower()
  1471. ext_id = (filament.get("id") or "").strip().lower()
  1472. if manufacturer != "bambu lab" and not ext_id.startswith("bambulab_"):
  1473. continue
  1474. fil_material = filament.get("material") or ""
  1475. if fil_material.upper() == material_upper and color_match_key(filament.get("color_hex")) == color:
  1476. bambu_candidates.append(filament)
  1477. # SpoolmanDB folds the product line into the name ("Matte Charcoal",
  1478. # "Tough+ Black", "Brown Galaxy") everywhere except PLA Basic and the
  1479. # lines with a base material of their own, which get the bare colour
  1480. # ("Black"). The catalogue keeps the line in its material column and a
  1481. # bare colour in color_name. So neither side's name equals the other's
  1482. # for most lines, and the selector has to rebuild the library's spelling
  1483. # from the line and the colour -- preferring it over the bare colour,
  1484. # which several lines share at the same hex.
  1485. chosen = best_product_line_match(bambu_candidates, color_name, sub_brand, tray.tray_type)
  1486. if chosen is not None:
  1487. return await self._create_filament_from_external(chosen, tray)
  1488. # Either the catalogue has no row for this colour -- it is seeded from
  1489. # Bambu's published list and lags new releases -- or it has one and no
  1490. # external entry carries that name. Both mean the library cannot say what
  1491. # this roll is, so build it from what the printer reported rather than
  1492. # attaching to whichever candidate happened to come first.
  1493. return await self.create_filament(
  1494. name=tray.tray_sub_brands or tray.tray_type,
  1495. vendor_id=bambu_vendor_id,
  1496. material=tray.tray_type,
  1497. color_hex=color,
  1498. weight=tray.tray_weight,
  1499. )
  1500. async def _create_filament_from_external(self, external: dict, tray: AMSTray) -> dict | None:
  1501. """Create an internal Spoolman filament from an external library entry.
  1502. The entry supplies what the printer cannot: density and label weight. The
  1503. name comes from the tray, not the entry, so a filament taken from the
  1504. library is named the same way as one built from scratch and the two modes
  1505. agree on the subtype. The library's own name ("Matte Charcoal") is a
  1506. colour name with the line folded in, and the colour name is stored on the
  1507. spool instead.
  1508. """
  1509. vendor_id = await self.ensure_bambu_vendor()
  1510. return await self.create_filament(
  1511. name=tray.tray_sub_brands or external.get("name") or tray.tray_type,
  1512. vendor_id=vendor_id,
  1513. material=external.get("material", tray.tray_type),
  1514. # `or`, not a two-argument get: an entry that carries the key with an
  1515. # explicit null would hand None to create_filament rather than reach
  1516. # the tray fallback. Only a candidate when the tray colour is empty
  1517. # too, so this is a correctness tidy, not a fix for a live path.
  1518. color_hex=external.get("color_hex") or color_match_key(tray.tray_color),
  1519. weight=external.get("weight", tray.tray_weight),
  1520. density=external.get("density"),
  1521. )
  1522. # Global client instance (initialized when settings are loaded)
  1523. _spoolman_client: SpoolmanClient | None = None
  1524. async def get_spoolman_client() -> SpoolmanClient | None:
  1525. """Return the global SpoolmanClient, or None if not configured."""
  1526. return _spoolman_client
  1527. async def init_spoolman_client(url: str) -> SpoolmanClient:
  1528. """Initialise (or reinitialise) the global SpoolmanClient; raises ValueError if url fails SSRF guard."""
  1529. from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
  1530. assert_safe_spoolman_url(url)
  1531. global _spoolman_client
  1532. if _spoolman_client:
  1533. await _spoolman_client.close()
  1534. _spoolman_client = SpoolmanClient(url)
  1535. return _spoolman_client
  1536. async def close_spoolman_client():
  1537. """Close the global Spoolman client."""
  1538. global _spoolman_client
  1539. if _spoolman_client:
  1540. await _spoolman_client.close()
  1541. _spoolman_client = None