spoolman.py 63 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442
  1. """Spoolman integration API routes."""
  2. import json
  3. import logging
  4. from typing import Literal
  5. from fastapi import APIRouter, Depends, HTTPException
  6. from pydantic import BaseModel
  7. from sqlalchemy import delete, select, text
  8. from sqlalchemy.ext.asyncio import AsyncSession
  9. from sqlalchemy.orm import selectinload
  10. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool, spoolman_net_weight
  11. from backend.app.api.routes.spoolman_inventory import _announce_freed_slots, _clear_stale_tag_links
  12. from backend.app.core.auth import (
  13. RequestPrinterScope,
  14. RequirePermissionIfAuthEnabled,
  15. RequirePrinterPermissionIfAuthEnabled,
  16. )
  17. from backend.app.core.database import get_db
  18. from backend.app.core.permissions import Permission
  19. from backend.app.core.printer_scope import PrinterScope
  20. from backend.app.core.websocket import ws_manager
  21. from backend.app.models.printer import Printer
  22. from backend.app.models.settings import Settings
  23. from backend.app.models.spool_assignment import SpoolAssignment
  24. from backend.app.models.spoolman_k_profile import SpoolmanKProfile
  25. from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
  26. from backend.app.models.user import User
  27. from backend.app.schemas.spoolman import SlotAmsId, SlotTrayId
  28. from backend.app.services import slot_unlink_grace, spool_swaps
  29. from backend.app.services.ams_slot_presence import spool_present
  30. from backend.app.services.inventory_mode import spoolman_owns_assignments
  31. from backend.app.services.printer_manager import PrinterManager, printer_manager
  32. from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
  33. from backend.app.services.slot_nozzle import resolve_slot_nozzle
  34. from backend.app.services.spool_filament_preset import resolve_spoolman_preset
  35. from backend.app.services.spoolman import (
  36. SpoolmanClientError,
  37. SpoolmanNotFoundError,
  38. SpoolmanUnavailableError,
  39. close_spoolman_client,
  40. get_spoolman_client,
  41. init_spoolman_client,
  42. )
  43. from backend.app.services.spoolman_slot_sync import write_slot_results
  44. from backend.app.utils.filament_ids import (
  45. GENERIC_FILAMENT_IDS,
  46. filament_id_to_setting_id,
  47. normalize_slicer_filament,
  48. )
  49. from backend.app.utils.filament_types import nozzle_temp_range, printer_filament_type
  50. logger = logging.getLogger(__name__)
  51. router = APIRouter(prefix="/spoolman", tags=["spoolman"])
  52. class SpoolmanStatus(BaseModel):
  53. """Spoolman connection status."""
  54. enabled: bool
  55. connected: bool
  56. url: str | None
  57. # Spoolman 0.27+ links tags natively; the settings offer the migration then.
  58. native_tags: bool = False
  59. class SkippedSpool(BaseModel):
  60. """Information about a skipped spool during sync."""
  61. location: str
  62. reason: Literal["No RFID tag and no slot assignment"]
  63. filament_type: str | None = None
  64. color: str | None = None
  65. class SyncResult(BaseModel):
  66. """Result of a Spoolman sync operation."""
  67. success: bool
  68. synced_count: int
  69. skipped_count: int = 0
  70. skipped: list[SkippedSpool] = []
  71. errors: list[str]
  72. async def get_spoolman_settings(db: AsyncSession) -> dict:
  73. """Get Spoolman settings from database.
  74. Returns:
  75. Dict with keys: enabled, url, sync_mode, disable_weight_sync
  76. """
  77. settings = {
  78. "enabled": False,
  79. "url": "",
  80. "sync_mode": "auto",
  81. "disable_weight_sync": False,
  82. }
  83. result = await db.execute(select(Settings))
  84. for setting in result.scalars().all():
  85. if setting.key == "spoolman_enabled":
  86. settings["enabled"] = setting.value.lower() == "true"
  87. elif setting.key == "spoolman_url":
  88. settings["url"] = setting.value
  89. elif setting.key == "spoolman_sync_mode":
  90. settings["sync_mode"] = setting.value
  91. elif setting.key == "spoolman_disable_weight_sync":
  92. settings["disable_weight_sync"] = setting.value.lower() == "true"
  93. return settings
  94. @router.get("/status", response_model=SpoolmanStatus)
  95. async def get_spoolman_status(
  96. db: AsyncSession = Depends(get_db, scope="function"),
  97. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  98. ):
  99. """Get Spoolman integration status.
  100. ``connected`` answers "does the configured Spoolman respond?", which means
  101. asking it. It used to answer "has some earlier request in this process left
  102. a client object lying around?" -- and roughly twenty call sites build one
  103. lazily, so the answer depended on which page happened to load first rather
  104. than on anything about Spoolman.
  105. That mattered because the UI reads this one flag twice: it offers Connect
  106. only while disconnected, and the AMS sync section only while connected.
  107. Saving the Settings page initialises a client as a side effect of syncing
  108. locations, so enabling Spoolman there reported "connected" without anything
  109. having been set up, hiding the Connect button and revealing a sync that then
  110. failed on every slot (issue #2903). Registration no longer depends on that
  111. button, but the flag was still describing Bambuddy's memory rather than the
  112. integration, so it is now resolved the same way every other route resolves
  113. it -- including the stale-URL check, so editing the URL is not reported
  114. against the old host.
  115. """
  116. sm = await get_spoolman_settings(db)
  117. enabled, url = sm["enabled"], sm["url"]
  118. connected = False
  119. native_tags = False
  120. if enabled and url:
  121. client = await get_spoolman_client()
  122. if not client or client.base_url != url.rstrip("/"):
  123. try:
  124. client = await init_spoolman_client(url)
  125. except ValueError as exc:
  126. logger.warning("Spoolman URL %r rejected by SSRF guard during status check: %s", url, exc)
  127. client = None
  128. except Exception as exc:
  129. # Every remaining way this can fail still answers the question:
  130. # replacing a client closes the previous one, and httpx's
  131. # aclose() is not guaranteed not to raise. A status poll that
  132. # 500s every 30 seconds is worse than one reporting what is
  133. # true either way -- that Spoolman could not be reached.
  134. logger.warning("Could not open a Spoolman client for %r during status check: %s", url, exc)
  135. client = None
  136. if client:
  137. connected = await client.health_check()
  138. # Asked once per client and cached, so the 30-second poll costs nothing more.
  139. native_tags = connected and await client.has_tag_api()
  140. return SpoolmanStatus(
  141. enabled=enabled,
  142. connected=connected,
  143. url=url if url else None,
  144. native_tags=native_tags,
  145. )
  146. @router.post("/connect")
  147. async def connect_spoolman(
  148. db: AsyncSession = Depends(get_db, scope="function"),
  149. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  150. ):
  151. """Connect to Spoolman server using configured URL."""
  152. sm = await get_spoolman_settings(db)
  153. enabled, url = sm["enabled"], sm["url"]
  154. if not enabled:
  155. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  156. if not url:
  157. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  158. try:
  159. client = await init_spoolman_client(url)
  160. connected = await client.health_check()
  161. if not connected:
  162. raise HTTPException(
  163. status_code=503,
  164. detail=f"Could not connect to Spoolman at {url}",
  165. )
  166. # Ensure the 'tag' extra field exists for RFID/UUID storage
  167. field_ok = await client.ensure_tag_extra_field()
  168. if not field_ok:
  169. logger.error("Spoolman tag extra field registration failed — NFC tag links may not persist")
  170. # Register slicer-preset extra fields (Spoolman rejects unknown extra keys).
  171. for field_name in ("bambu_slicer_filament", "bambu_slicer_filament_name"):
  172. if not await client.ensure_extra_field(field_name):
  173. logger.warning(
  174. "Spoolman extra field %r registration failed — spool slicer-preset edits will return 502",
  175. field_name,
  176. )
  177. return {"success": True, "message": f"Connected to Spoolman at {url}"}
  178. except ValueError as exc:
  179. logger.warning("Spoolman URL rejected: %s", exc)
  180. raise HTTPException(status_code=400, detail=str(exc)) from exc
  181. except Exception as e:
  182. logger.error("Failed to connect to Spoolman: %s", e)
  183. raise HTTPException(status_code=503, detail=str(e))
  184. @router.post("/disconnect")
  185. async def disconnect_spoolman(
  186. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  187. ):
  188. """Disconnect from Spoolman server."""
  189. await close_spoolman_client()
  190. return {"success": True, "message": "Disconnected from Spoolman"}
  191. def _empty_slot_may_be_unlinked(state, tray_data: dict) -> bool:
  192. """The automatic pass's guards for a tray that reads empty (#3186): not while
  193. the printer prints, where a runout reads empty, and not in a slot whose
  194. presence bit says a spool is there. Its grace period is left out: the sync
  195. was asked for.
  196. """
  197. if str(getattr(state, "state", "") or "").upper() in PrinterManager.ACTIVE_PRINT_STATES:
  198. return False
  199. return spool_present(tray_data) is not True
  200. async def _refresh_slot_preset(db: AsyncSession, spool: dict, tray, printer_id: int, ams_id: int) -> None:
  201. """The slot's preset row follows the spool synced into it, as in the
  202. automatic pass: the slot card reads that row ahead of the live tray, so it
  203. kept the previous spool's preset name (SSA-2)."""
  204. from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spoolman_spool
  205. await upsert_slot_preset_for_spoolman_spool(
  206. db=db,
  207. spoolman_spool=spool,
  208. tray_info_idx=tray.tray_info_idx or "",
  209. tray_sub_brands=tray.tray_sub_brands or "",
  210. tray_type=tray.tray_type or "",
  211. printer_id=printer_id,
  212. ams_id=ams_id,
  213. tray_id=tray.tray_id,
  214. )
  215. @router.post("/sync/{printer_id}", response_model=SyncResult)
  216. async def sync_printer_ams(
  217. printer_id: int,
  218. db: AsyncSession = Depends(get_db, scope="function"),
  219. _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
  220. ):
  221. """Sync AMS data from a specific printer to Spoolman."""
  222. # Check if Spoolman is enabled and connected
  223. # disable_weight_sync is deprecated (#1119); weight comes from per-print tracking.
  224. sm = await get_spoolman_settings(db)
  225. enabled, url = sm["enabled"], sm["url"]
  226. if not enabled:
  227. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  228. client = await get_spoolman_client()
  229. if not client:
  230. # Try to connect
  231. if url:
  232. client = await init_spoolman_client(url)
  233. else:
  234. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  235. if not await client.health_check():
  236. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  237. # Get printer info
  238. result = await db.execute(select(Printer).where(Printer.id == printer_id))
  239. printer = result.scalar_one_or_none()
  240. if not printer:
  241. raise HTTPException(status_code=404, detail="Printer not found")
  242. # Get current printer state with AMS data
  243. state = printer_manager.get_status(printer_id)
  244. if not state:
  245. raise HTTPException(status_code=404, detail="Printer not connected")
  246. if not state.raw_data:
  247. raise HTTPException(status_code=400, detail="No AMS data available")
  248. ams_data = state.raw_data.get("ams")
  249. if not ams_data:
  250. raise HTTPException(
  251. status_code=400,
  252. detail="No AMS data in printer state. Try triggering a slot re-read on the printer.",
  253. )
  254. # Sync each AMS tray to Spoolman
  255. synced = 0
  256. skipped: list[SkippedSpool] = []
  257. errors = []
  258. from backend.app.api.routes.settings import get_setting
  259. _auto_add_raw = await get_setting(db, "auto_add_unknown_rfid")
  260. auto_add_unknown_rfid = _auto_add_raw is None or _auto_add_raw.lower() == "true"
  261. # Handle different AMS data structures
  262. # Traditional AMS: list of {"id": N, "tray": [...]} dicts
  263. # H2D/newer printers: dict with different structure
  264. ams_units = []
  265. if isinstance(ams_data, list):
  266. ams_units = ams_data
  267. elif isinstance(ams_data, dict):
  268. # H2D format: check for "ams" key containing list, or "tray" key directly
  269. if "ams" in ams_data and isinstance(ams_data["ams"], list):
  270. ams_units = ams_data["ams"]
  271. elif "tray" in ams_data:
  272. # Single AMS unit format - wrap in list
  273. ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
  274. else:
  275. logger.info("AMS dict keys for debugging: %s", list(ams_data.keys()))
  276. if not ams_units:
  277. raise HTTPException(
  278. status_code=400,
  279. detail=(
  280. "AMS data format not supported. Keys: "
  281. f"{list(ams_data.keys()) if isinstance(ams_data, dict) else type(ams_data).__name__}"
  282. ),
  283. )
  284. # OPTIMIZATION: Fetch all spools once before processing trays
  285. # This eliminates redundant API calls (one per tray) when syncing multiple trays
  286. logger.debug("[Printer %s] Fetching spools cache for sync...", printer.name)
  287. try:
  288. cached_spools = await client.get_spools()
  289. logger.debug("[Printer %s] Cached %d spools for batch sync", printer.name, len(cached_spools))
  290. except Exception as e:
  291. logger.error("[Printer %s] Failed to fetch spools cache after retries: %s", printer.name, e)
  292. raise HTTPException(
  293. status_code=503,
  294. detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
  295. )
  296. # Load inventory weights as fallback (when AMS MQTT data lacks remain values).
  297. # Inert in Spoolman mode, as in the automatic pass (main.on_ams_change): the
  298. # built-in slot rows are left over from before the switch, and a new Spoolman
  299. # spool would be seeded with an unrelated weight (SM-C3).
  300. inv_weights: dict[tuple[int, int], float] = {}
  301. if not await spoolman_owns_assignments(db):
  302. try:
  303. assign_result = await db.execute(
  304. select(SpoolAssignment)
  305. .options(selectinload(SpoolAssignment.spool))
  306. .where(SpoolAssignment.printer_id == printer_id)
  307. )
  308. for assignment in assign_result.scalars().all():
  309. spool = assignment.spool
  310. if spool and spool.label_weight > 0:
  311. remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
  312. inv_weights[(assignment.ams_id, assignment.tray_id)] = remaining
  313. except Exception as e:
  314. logger.debug("Could not load inventory weights for printer %s: %s", printer_id, e)
  315. # Load existing Spoolman slot assignments for the no-RFID fallback path
  316. spoolman_slot_map: dict[tuple[int, int], int] = {}
  317. slot_map_read = False
  318. try:
  319. slot_result = await db.execute(
  320. select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == printer_id)
  321. )
  322. for slot in slot_result.scalars().all():
  323. spoolman_slot_map[(slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
  324. slot_map_read = True
  325. except Exception as e:
  326. logger.warning("Could not load Spoolman slot assignments for printer %s: %s", printer_id, e)
  327. slot_changes: list[tuple[int, int, int]] = [] # (ams_id, tray_id, spoolman_spool_id)
  328. empty_slots: list[tuple[int, int]] = [] # (ams_id, tray_id) now empty
  329. for ams_unit in ams_units:
  330. if not isinstance(ams_unit, dict):
  331. continue
  332. ams_id = int(ams_unit.get("id", 0))
  333. trays = ams_unit.get("tray", [])
  334. for tray_data in trays:
  335. if not isinstance(tray_data, dict):
  336. continue
  337. tray_id_raw = int(tray_data.get("id", 0))
  338. tray = client.parse_ams_tray(ams_id, tray_data)
  339. if not tray:
  340. if _empty_slot_may_be_unlinked(state, tray_data):
  341. empty_slots.append((ams_id, tray_id_raw))
  342. continue
  343. spool_tag = (
  344. tray.tray_uuid
  345. if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
  346. else tray.tag_uid
  347. )
  348. hint = spoolman_slot_map.get((ams_id, tray.tray_id)) if not spool_tag else None
  349. try:
  350. inv_remaining = inv_weights.get((ams_id, tray.tray_id))
  351. sync_result = await client.sync_ams_tray(
  352. tray,
  353. printer.name,
  354. db,
  355. # Per-print tracking owns weight updates (#1119); manual sync
  356. # only refreshes spool metadata + slot assignments here.
  357. disable_weight_sync=True,
  358. cached_spools=cached_spools,
  359. inventory_remaining=inv_remaining,
  360. spoolman_spool_id_hint=hint,
  361. auto_add_unknown_rfid=auto_add_unknown_rfid,
  362. )
  363. if sync_result:
  364. synced += 1
  365. if sync_result.get("id"):
  366. slot_changes.append((ams_id, tray.tray_id, sync_result["id"]))
  367. spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
  368. if not spool_exists:
  369. cached_spools.append(sync_result)
  370. logger.debug("Added newly created spool %s to cache", sync_result["id"])
  371. await _refresh_slot_preset(db, sync_result, tray, printer_id, ams_id)
  372. logger.info(
  373. "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
  374. )
  375. elif spool_tag and not auto_add_unknown_rfid:
  376. skipped.append(
  377. SkippedSpool(
  378. location=f"AMS {ams_id} T{tray.tray_id}",
  379. reason="Auto-add disabled; add to inventory manually",
  380. filament_type=tray.tray_type or None,
  381. color=tray.tray_color[:6] if tray.tray_color else None,
  382. )
  383. )
  384. elif spool_tag:
  385. errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
  386. elif not hint:
  387. skipped.append(
  388. SkippedSpool(
  389. location=f"AMS {ams_id} T{tray.tray_id}",
  390. reason="No RFID tag and no slot assignment",
  391. filament_type=tray.tray_type or None,
  392. color=tray.tray_color[:6] if tray.tray_color else None,
  393. )
  394. )
  395. except Exception as e:
  396. error_msg = f"Error syncing AMS {ams_id} tray {tray.tray_id}: {e}"
  397. logger.error(error_msg)
  398. errors.append(error_msg)
  399. # Persist slot assignment changes to the local table: only what changed,
  400. # and only where the slot still holds what was read (SSA-3).
  401. if slot_changes or empty_slots:
  402. try:
  403. await write_slot_results(
  404. db,
  405. {(printer_id, a_id, t_id): spool_id for (a_id, t_id), spool_id in spoolman_slot_map.items()}
  406. if slot_map_read
  407. else None,
  408. [(printer_id, a_id, t_id, spool_id) for a_id, t_id, spool_id in slot_changes],
  409. [(printer_id, a_id, t_id) for a_id, t_id in empty_slots],
  410. )
  411. except Exception as e:
  412. await db.rollback()
  413. logger.error("Error persisting Spoolman slot assignments for printer %s: %s", printer_id, e)
  414. errors.append(f"Failed to persist slot assignments: {type(e).__name__}")
  415. return SyncResult(
  416. success=len(errors) == 0,
  417. synced_count=synced,
  418. skipped_count=len(skipped),
  419. skipped=skipped,
  420. errors=errors,
  421. )
  422. @router.post("/sync-all", response_model=SyncResult)
  423. async def sync_all_printers(
  424. db: AsyncSession = Depends(get_db, scope="function"),
  425. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
  426. printer_scope: PrinterScope = RequestPrinterScope,
  427. ):
  428. """Sync AMS data from all connected printers to Spoolman."""
  429. # Check if Spoolman is enabled
  430. # disable_weight_sync is deprecated (#1119); weight comes from per-print tracking.
  431. sm = await get_spoolman_settings(db)
  432. enabled, url = sm["enabled"], sm["url"]
  433. if not enabled:
  434. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  435. client = await get_spoolman_client()
  436. if not client:
  437. if url:
  438. client = await init_spoolman_client(url)
  439. else:
  440. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  441. if not await client.health_check():
  442. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  443. # Get all active printers
  444. result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
  445. printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
  446. total_synced = 0
  447. all_skipped: list[SkippedSpool] = []
  448. all_errors = []
  449. from backend.app.api.routes.settings import get_setting
  450. _auto_add_raw = await get_setting(db, "auto_add_unknown_rfid")
  451. auto_add_unknown_rfid = _auto_add_raw is None or _auto_add_raw.lower() == "true"
  452. # OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
  453. # This eliminates redundant API calls across all printers
  454. logger.debug("Fetching spools cache for sync-all operation...")
  455. try:
  456. cached_spools = await client.get_spools()
  457. logger.debug("Cached %d spools for batch sync across %d printers", len(cached_spools), len(printers))
  458. except Exception as e:
  459. logger.error("Failed to fetch spools cache after retries: %s", e)
  460. raise HTTPException(
  461. status_code=503,
  462. detail=f"Failed to connect to Spoolman after multiple retries: {str(e)}",
  463. )
  464. # Load inventory assignments for weight fallback (when AMS MQTT data lacks remain values)
  465. # Key: (printer_id, ams_id, tray_id) → remaining_weight in grams
  466. # Inert in Spoolman mode, as in sync_printer_ams above (SM-C3).
  467. inventory_weights: dict[tuple[int, int, int], float] = {}
  468. if not await spoolman_owns_assignments(db):
  469. try:
  470. assign_result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
  471. for assignment in assign_result.scalars().all():
  472. spool = assignment.spool
  473. if spool and spool.label_weight > 0:
  474. remaining = max(0.0, spool.label_weight - (spool.weight_used or 0))
  475. inventory_weights[(assignment.printer_id, assignment.ams_id, assignment.tray_id)] = remaining
  476. except Exception as e:
  477. logger.debug("Could not load inventory assignments for weight fallback: %s", e)
  478. # Load all Spoolman slot assignments for the no-RFID fallback
  479. # Key: (printer_id, ams_id, tray_id) → spoolman_spool_id
  480. all_slot_map: dict[tuple[int, int, int], int] = {}
  481. all_slot_map_read = False
  482. try:
  483. slot_result = await db.execute(select(SpoolmanSlotAssignment))
  484. for slot in slot_result.scalars().all():
  485. all_slot_map[(slot.printer_id, slot.ams_id, slot.tray_id)] = slot.spoolman_spool_id
  486. all_slot_map_read = True
  487. except Exception as e:
  488. logger.warning("Could not load Spoolman slot assignments: %s", e)
  489. # Collect slot changes across all printers for a single DB write at the end
  490. all_slot_changes: list[tuple[int, int, int, int]] = [] # (printer_id, ams_id, tray_id, spool_id)
  491. all_empty_slots: list[tuple[int, int, int]] = [] # (printer_id, ams_id, tray_id)
  492. for printer in printers:
  493. state = printer_manager.get_status(printer.id)
  494. if not state or not state.raw_data:
  495. continue
  496. ams_data = state.raw_data.get("ams")
  497. if not ams_data:
  498. continue
  499. # Handle different AMS data structures
  500. # Traditional AMS: list of {"id": N, "tray": [...]} dicts
  501. # H2D/newer printers: dict with different structure
  502. ams_units = []
  503. if isinstance(ams_data, list):
  504. ams_units = ams_data
  505. elif isinstance(ams_data, dict):
  506. # H2D format: check for "ams" key containing list, or "tray" key directly
  507. if "ams" in ams_data and isinstance(ams_data["ams"], list):
  508. ams_units = ams_data["ams"]
  509. elif "tray" in ams_data:
  510. # Single AMS unit format - wrap in list
  511. ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
  512. else:
  513. logger.debug("Printer %s AMS dict keys: %s", printer.name, list(ams_data.keys()))
  514. if not ams_units:
  515. logger.debug("Printer %s has no AMS units to sync (type: %s)", printer.name, type(ams_data).__name__)
  516. continue
  517. for ams_unit in ams_units:
  518. if not isinstance(ams_unit, dict):
  519. logger.debug("Skipping non-dict AMS unit: %s", type(ams_unit))
  520. continue
  521. ams_id = int(ams_unit.get("id", 0))
  522. trays = ams_unit.get("tray", [])
  523. for tray_data in trays:
  524. if not isinstance(tray_data, dict):
  525. continue
  526. tray_id_raw = int(tray_data.get("id", 0))
  527. tray = client.parse_ams_tray(ams_id, tray_data)
  528. if not tray:
  529. if _empty_slot_may_be_unlinked(state, tray_data):
  530. all_empty_slots.append((printer.id, ams_id, tray_id_raw))
  531. continue
  532. spool_tag = (
  533. tray.tray_uuid
  534. if tray.tray_uuid and tray.tray_uuid != "00000000000000000000000000000000"
  535. else tray.tag_uid
  536. )
  537. hint = all_slot_map.get((printer.id, ams_id, tray.tray_id)) if not spool_tag else None
  538. try:
  539. inv_remaining = inventory_weights.get((printer.id, ams_id, tray.tray_id))
  540. sync_result = await client.sync_ams_tray(
  541. tray,
  542. printer.name,
  543. db,
  544. # Per-print tracking owns weight updates (#1119); manual
  545. # sync-all only refreshes spool metadata + slot assignments.
  546. disable_weight_sync=True,
  547. cached_spools=cached_spools,
  548. inventory_remaining=inv_remaining,
  549. spoolman_spool_id_hint=hint,
  550. auto_add_unknown_rfid=auto_add_unknown_rfid,
  551. )
  552. if sync_result:
  553. total_synced += 1
  554. if sync_result.get("id"):
  555. all_slot_changes.append((printer.id, ams_id, tray.tray_id, sync_result["id"]))
  556. spool_exists = any(s.get("id") == sync_result["id"] for s in cached_spools)
  557. if not spool_exists:
  558. cached_spools.append(sync_result)
  559. logger.debug("Added newly created spool %s to cache", sync_result["id"])
  560. await _refresh_slot_preset(db, sync_result, tray, printer.id, ams_id)
  561. elif spool_tag and not auto_add_unknown_rfid:
  562. all_skipped.append(
  563. SkippedSpool(
  564. location=f"{printer.name} AMS {ams_id} T{tray.tray_id}",
  565. reason="Auto-add disabled; add to inventory manually",
  566. filament_type=tray.tray_type or None,
  567. color=tray.tray_color[:6] if tray.tray_color else None,
  568. )
  569. )
  570. elif spool_tag:
  571. all_errors.append(f"Spool not found in Spoolman: {printer.name} AMS {ams_id}:{tray.tray_id}")
  572. elif not hint:
  573. all_skipped.append(
  574. SkippedSpool(
  575. location=f"{printer.name} AMS {ams_id} T{tray.tray_id}",
  576. reason="No RFID tag and no slot assignment",
  577. filament_type=tray.tray_type or None,
  578. color=tray.tray_color[:6] if tray.tray_color else None,
  579. )
  580. )
  581. except Exception as e:
  582. all_errors.append(f"{printer.name} AMS {ams_id}:{tray.tray_id}: {e}")
  583. # Persist slot assignment changes across all printers: only what changed,
  584. # and only where the slot still holds what was read (SSA-3).
  585. if all_slot_changes or all_empty_slots:
  586. try:
  587. await write_slot_results(db, all_slot_map if all_slot_map_read else None, all_slot_changes, all_empty_slots)
  588. except Exception as e:
  589. await db.rollback()
  590. logger.error("Error persisting Spoolman slot assignments: %s", e)
  591. all_errors.append(f"Failed to persist slot assignments: {type(e).__name__}")
  592. return SyncResult(
  593. success=len(all_errors) == 0,
  594. synced_count=total_synced,
  595. skipped_count=len(all_skipped),
  596. skipped=all_skipped,
  597. errors=all_errors,
  598. )
  599. @router.get("/spools")
  600. async def get_spools(
  601. db: AsyncSession = Depends(get_db, scope="function"),
  602. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  603. ):
  604. """Get all spools from Spoolman."""
  605. sm = await get_spoolman_settings(db)
  606. enabled, url = sm["enabled"], sm["url"]
  607. if not enabled:
  608. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  609. client = await get_spoolman_client()
  610. if not client:
  611. if url:
  612. client = await init_spoolman_client(url)
  613. else:
  614. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  615. if not await client.health_check():
  616. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  617. spools = await client.get_spools()
  618. return {"spools": spools}
  619. @router.get("/filaments")
  620. async def get_filaments(
  621. db: AsyncSession = Depends(get_db, scope="function"),
  622. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  623. ):
  624. """Get all filaments from Spoolman."""
  625. sm = await get_spoolman_settings(db)
  626. enabled, url = sm["enabled"], sm["url"]
  627. if not enabled:
  628. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  629. client = await get_spoolman_client()
  630. if not client:
  631. if url:
  632. client = await init_spoolman_client(url)
  633. else:
  634. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  635. if not await client.health_check():
  636. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  637. filaments = await client.get_filaments()
  638. return {"filaments": filaments}
  639. class UnlinkedSpool(BaseModel):
  640. """A Spoolman spool that is not linked to any AMS tray."""
  641. id: int
  642. filament_name: str | None
  643. filament_vendor: str | None
  644. filament_material: str | None
  645. filament_color_hex: str | None
  646. remaining_weight: float | None
  647. location: str | None
  648. @router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
  649. async def get_unlinked_spools(
  650. db: AsyncSession = Depends(get_db, scope="function"),
  651. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  652. ):
  653. """Get all Spoolman spools not currently assigned to an AMS slot."""
  654. sm = await get_spoolman_settings(db)
  655. enabled, url = sm["enabled"], sm["url"]
  656. if not enabled:
  657. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  658. client = await get_spoolman_client()
  659. if not client:
  660. if url:
  661. client = await init_spoolman_client(url)
  662. else:
  663. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  664. if not await client.health_check():
  665. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  666. spools = await client.get_spools()
  667. # A spool is "assignable" iff it does not currently occupy an AMS slot.
  668. # Assignability is decided by the spoolman_slot_assignments ledger — NOT by
  669. # the presence of extra.tag. extra.tag is only an RFID/NFC matching key, and
  670. # OpenSpoolman writes its own NFC tag value into that same field (#1122);
  671. # treating any non-empty extra.tag as "linked" hid every OpenSpoolman-tagged
  672. # spool from this picker even when it occupied no slot. Both link_spool and
  673. # the AMS auto-sync upsert a row here for every occupied slot, so the ledger
  674. # is a complete record of what is actually assigned.
  675. assigned_result = await db.execute(select(SpoolmanSlotAssignment.spoolman_spool_id))
  676. assigned_spool_ids = set(assigned_result.scalars().all())
  677. unlinked = []
  678. for spool in spools:
  679. if spool["id"] in assigned_spool_ids:
  680. continue
  681. filament = spool.get("filament", {}) or {}
  682. unlinked.append(
  683. UnlinkedSpool(
  684. id=spool["id"],
  685. filament_name=filament.get("name"),
  686. filament_vendor=(filament.get("vendor") or {}).get("name"),
  687. filament_material=filament.get("material"),
  688. filament_color_hex=filament.get("color_hex"),
  689. remaining_weight=spool.get("remaining_weight"),
  690. location=spool.get("location"),
  691. )
  692. )
  693. return unlinked
  694. @router.get("/spools/linked")
  695. async def get_linked_spools(
  696. db: AsyncSession = Depends(get_db, scope="function"),
  697. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
  698. ):
  699. """Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
  700. sm = await get_spoolman_settings(db)
  701. enabled, url = sm["enabled"], sm["url"]
  702. if not enabled:
  703. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  704. client = await get_spoolman_client()
  705. if not client:
  706. if url:
  707. client = await init_spoolman_client(url)
  708. else:
  709. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  710. if not await client.health_check():
  711. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  712. spools = await client.get_spools()
  713. linked: dict[str, dict] = {}
  714. for spool in spools:
  715. # Check if spool has a tag in extra field
  716. extra = spool.get("extra", {}) or {}
  717. tag = extra.get("tag", "")
  718. if tag:
  719. # Remove quotes if present (JSON encoded string)
  720. clean_tag = tag.strip('"').upper()
  721. if clean_tag:
  722. linked[clean_tag] = {
  723. "id": spool["id"],
  724. "remaining_weight": spool.get("remaining_weight"),
  725. # The spool's own net weight, falling back to the
  726. # filament's; the key predates initial_weight (#3194).
  727. "filament_weight": spoolman_net_weight(spool),
  728. }
  729. return {"linked": linked}
  730. class LinkSpoolRequest(BaseModel):
  731. """Request to link a Spoolman spool to an AMS tag (tray_uuid or tag_uid)."""
  732. spool_tag: str | None = None
  733. tray_uuid: str | None = None
  734. tag_uid: str | None = None
  735. printer_id: int | None = None
  736. ams_id: SlotAmsId | None = None
  737. tray_id: SlotTrayId | None = None
  738. @router.post("/spools/{spool_id}/link")
  739. async def link_spool(
  740. spool_id: int,
  741. request: LinkSpoolRequest,
  742. db: AsyncSession = Depends(get_db, scope="function"),
  743. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
  744. printer_scope: PrinterScope = RequestPrinterScope,
  745. ):
  746. """Link a Spoolman spool to an AMS tag by setting Spoolman extra.tag."""
  747. sm = await get_spoolman_settings(db)
  748. enabled, url = sm["enabled"], sm["url"]
  749. if not enabled:
  750. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  751. client = await get_spoolman_client()
  752. if not client:
  753. if url:
  754. client = await init_spoolman_client(url)
  755. else:
  756. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  757. if not await client.health_check():
  758. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  759. # Resolve and validate spool tag (supports tray_uuid=32 hex and tag_uid=16 hex)
  760. spool_tag = (request.spool_tag or request.tray_uuid or request.tag_uid or "").strip()
  761. if not spool_tag:
  762. raise HTTPException(status_code=400, detail="Missing spool tag (tray_uuid or tag_uid)")
  763. if len(spool_tag) not in (16, 32):
  764. raise HTTPException(status_code=400, detail="Invalid spool tag format (must be 16 or 32 hex characters)")
  765. try:
  766. int(spool_tag, 16)
  767. except ValueError:
  768. raise HTTPException(status_code=400, detail="Invalid spool tag format (must be hex)")
  769. if set(spool_tag) == {"0"}:
  770. raise HTTPException(status_code=400, detail="Invalid spool tag format (all-zero tag is not linkable)")
  771. spool_tag = spool_tag.upper()
  772. # Validate printer context when provided, but do NOT write spool.location —
  773. # that field is user-managed in Spoolman. Slot assignment is stored locally.
  774. printer_context: tuple[int, int, int] | None = None
  775. if request.printer_id is not None and request.ams_id is not None and request.tray_id is not None:
  776. printer_scope.ensure(request.printer_id)
  777. printer_result = await db.execute(select(Printer).where(Printer.id == request.printer_id))
  778. if not printer_result.scalar_one_or_none():
  779. raise HTTPException(status_code=404, detail="Printer not found")
  780. printer_context = (request.printer_id, request.ams_id, request.tray_id)
  781. # An archived spool on a slot would be charged the next print's usage;
  782. # refused before anything is written, as the slot assignment does (SSA-8).
  783. try:
  784. linked = await client.get_spool(spool_id)
  785. except SpoolmanNotFoundError:
  786. raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
  787. except SpoolmanClientError:
  788. raise HTTPException(status_code=502, detail="Spoolman rejected the request")
  789. except SpoolmanUnavailableError:
  790. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  791. if linked.get("archived"):
  792. raise HTTPException(status_code=400, detail="Cannot assign an archived spool")
  793. try:
  794. # Spoolman 0.27+: a tray UUID also becomes a native tag of the spool. The
  795. # 16-character values this route takes are either a slot's fallback ID or
  796. # the AMS's padded chip UID - neither is what a reader sees, so they stay in
  797. # extra.tag only; the AMS sync adds the real chip UID when it reads the tag.
  798. # The native link goes first, as the step Spoolman can refuse, so a refusal
  799. # leaves extra.tag untouched; a failed extra.tag write takes back what was added.
  800. added_native = False
  801. if len(spool_tag) == 32 and await client.has_tag_api():
  802. before = await client.get_spool(spool_id)
  803. had = any(t.get("uid") == spool_tag for t in before.get("tags") or [])
  804. holder = await client.claim_native_tag(spool_id, spool_tag, "bambu")
  805. if holder is not None:
  806. logger.warning("Native tag %s belongs to spool %s, left there", spool_tag, holder)
  807. added_native = holder is None and not had
  808. try:
  809. await client.merge_spool_extra(spool_id, {"tag": json.dumps(spool_tag)})
  810. except Exception:
  811. if added_native:
  812. try:
  813. await client.unlink_native_tag(spool_id, spool_tag)
  814. except (SpoolmanClientError, SpoolmanUnavailableError) as exc:
  815. logger.warning("Could not take back native tag %s from spool %s: %s", spool_tag, spool_id, exc)
  816. raise
  817. except SpoolmanNotFoundError:
  818. raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
  819. except SpoolmanClientError:
  820. raise HTTPException(status_code=502, detail="Spoolman rejected the request")
  821. except SpoolmanUnavailableError:
  822. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  823. # Upsert slot assignment locally when printer context was supplied
  824. if printer_context:
  825. p_id, a_id, t_id = printer_context
  826. try:
  827. await db.execute(
  828. text(
  829. "INSERT INTO spoolman_slot_assignments"
  830. " (printer_id, ams_id, tray_id, spoolman_spool_id)"
  831. " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
  832. " ON CONFLICT(printer_id, ams_id, tray_id)"
  833. " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
  834. ),
  835. {"printer_id": p_id, "ams_id": a_id, "tray_id": t_id, "spool_id": spool_id},
  836. )
  837. await spool_swaps.record_assignment(db, p_id, a_id, t_id, spool_id, spool_swaps.SPOOLMAN)
  838. await db.commit()
  839. slot_unlink_grace.forget_slot(p_id, a_id, t_id)
  840. except Exception as e:
  841. await db.rollback()
  842. logger.error(
  843. "Linked spool %s in Spoolman but failed to persist local slot assignment "
  844. "(printer=%s ams=%s tray=%s): %s",
  845. spool_id,
  846. p_id,
  847. a_id,
  848. t_id,
  849. e,
  850. )
  851. raise HTTPException(
  852. status_code=500,
  853. detail=(
  854. "Spool linked in Spoolman but the local slot assignment could not be saved. "
  855. "Please re-open the link dialog to retry."
  856. ),
  857. ) from e
  858. logger.info("Linked Spoolman spool %s to tag %s", spool_id, spool_tag)
  859. # #1457: clear stale tag links on OTHER spools still claiming this exact tag.
  860. # A given AMS-slot tag (RFID or deterministic fallback) belongs to one
  861. # physical spool; without this cleanup the previous holder's extra.tag
  862. # keeps it visible in the hover card / fill-level lookup.
  863. await _clear_stale_tag_links(
  864. client,
  865. tag=spool_tag,
  866. keep_spool_id=spool_id,
  867. log_context=(
  868. f"printer={printer_context[0]} ams={printer_context[1]} tray={printer_context[2]}"
  869. if printer_context
  870. else "via /spools/{id}/link"
  871. ),
  872. )
  873. # Pages open elsewhere find a slot's spool by its tag, and by the slot row
  874. # when one was written: both changed (#3402).
  875. await ws_manager.broadcast({"type": "inventory_changed"})
  876. if printer_context:
  877. await _announce_freed_slots([printer_context])
  878. # Auto-configure AMS slot via MQTT (best-effort; tag link and slot assignment already persisted)
  879. if printer_context:
  880. p_id, a_id, t_id = printer_context
  881. try:
  882. spool_data = await client.get_spool(spool_id)
  883. mapped = _map_spoolman_spool(spool_data)
  884. mqtt_client = printer_manager.get_client(p_id)
  885. if mqtt_client:
  886. # Spoolman's material is free text, so it arrives as whatever
  887. # the user typed there -- "PLA+", "PolyTerra PLA". The sub-brand
  888. # keeps that wording; the slot's type has to be one the printer
  889. # and the slicer know (issue #2902).
  890. material = mapped.get("material") or ""
  891. tray_type = printer_filament_type(material)
  892. brand = mapped.get("brand") or ""
  893. subtype = mapped.get("subtype") or ""
  894. if brand:
  895. tray_sub_brands = f"{brand} {material} {subtype}".strip()
  896. elif subtype:
  897. tray_sub_brands = f"{material} {subtype}".strip()
  898. else:
  899. tray_sub_brands = material
  900. tray_color = (mapped.get("rgba") or "808080FF").upper()
  901. if len(tray_color) == 6:
  902. tray_color = tray_color + "FF"
  903. # Pull printer state via printer_manager (mqtt_client.printer_state
  904. # was a non-existent attribute — the hasattr check silently
  905. # returned None, defeating every state-based lookup below).
  906. state = printer_manager.get_status(p_id)
  907. slot_nozzle = resolve_slot_nozzle(state, a_id, t_id, printer_manager.get_model(p_id))
  908. nozzle_diameter = slot_nozzle.diameter
  909. # Resolve the spool's own preset before falling back to a
  910. # generic material id. This path used to skip that entirely and
  911. # configure every linked slot as generic PLA/PETG, so a spool
  912. # with a preset set in inventory lost it the moment it was
  913. # linked by tag — the same defect #1713 fixed on the assign
  914. # path, in the function next door. The per-model override
  915. # cascade applies here for the same reason it does there: the
  916. # preset is bound to a printer model.
  917. slot_slicer_filament, slot_slicer_filament_name = await resolve_spoolman_preset(
  918. db,
  919. spoolman_spool_id=spool_id,
  920. printer_model=printer_manager.get_model(p_id),
  921. nozzle_diameter=nozzle_diameter,
  922. fallback_filament=mapped.get("slicer_filament"),
  923. fallback_name=mapped.get("slicer_filament_name"),
  924. )
  925. tray_info_idx, setting_id, sub_brand_override, type_override = await resolve_slicer_filament(
  926. db=db,
  927. current_user=None,
  928. slicer_filament=slot_slicer_filament,
  929. slicer_filament_name=slot_slicer_filament_name,
  930. material=material,
  931. )
  932. if sub_brand_override:
  933. tray_sub_brands = sub_brand_override
  934. if type_override:
  935. tray_type = printer_filament_type(type_override)
  936. # The spool's own wording is tried first and the reduced type
  937. # only as a further fallback, so a material that already
  938. # resolves keeps resolving to the same id: "PETG HF" has its
  939. # own generic preset (GFG96) that reducing it to "PETG" would
  940. # trade away for GFG99.
  941. material_upper = material.upper().strip()
  942. if not tray_info_idx:
  943. tray_info_idx = (
  944. GENERIC_FILAMENT_IDS.get(material_upper)
  945. or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
  946. or GENERIC_FILAMENT_IDS.get(tray_type.upper())
  947. or ""
  948. )
  949. if tray_info_idx and not setting_id:
  950. setting_id = filament_id_to_setting_id(tray_info_idx)
  951. temp_defaults = nozzle_temp_range(material, tray_type)
  952. temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
  953. temp_max = temp_defaults[1]
  954. kp_result = await db.execute(
  955. select(SpoolmanKProfile).where(
  956. SpoolmanKProfile.spoolman_spool_id == spool_id,
  957. SpoolmanKProfile.printer_id == p_id,
  958. )
  959. )
  960. kp_rows = kp_result.scalars().all()
  961. slot_extruder = slot_nozzle.extruder
  962. # Prefer exact extruder match, fall back to extruder-agnostic kp
  963. # for the same nozzle. Hard-skip on extruder mismatch silently
  964. # dropped valid stored profiles when the AMS-extruder map
  965. # shifted since calibration.
  966. exact_kp = None
  967. fallback_kp = None
  968. for kp in kp_rows:
  969. if kp.nozzle_diameter != nozzle_diameter or kp.cali_idx is None:
  970. continue
  971. if not slot_nozzle.flow_matches(kp.nozzle_type):
  972. continue
  973. if slot_extruder is not None and kp.extruder is not None and kp.extruder == slot_extruder:
  974. exact_kp = kp
  975. break
  976. if fallback_kp is None:
  977. fallback_kp = kp
  978. matching_kp = exact_kp or fallback_kp
  979. # Resolve printer-side calibration entry by cali_idx — the
  980. # printer keys its calibration table by filament_id, not by
  981. # setting_id. Stored kp.setting_id alone isn't enough.
  982. printer_kp = None
  983. if matching_kp and state and state.kprofiles:
  984. for pkp in state.kprofiles:
  985. if pkp.slot_id == matching_kp.cali_idx and pkp.nozzle_diameter == nozzle_diameter:
  986. printer_kp = pkp
  987. break
  988. # Realign slot's filament context to the kp's calibration
  989. # context so ams_filament_setting and extrusion_cali_sel
  990. # reference the same preset; otherwise the printer drops the
  991. # cali_idx to default. PFUS-prefix cloud-user presets are
  992. # rejected by the slicer in tray_info_idx — skip realignment
  993. # in that case.
  994. effective_tray_info_idx = tray_info_idx
  995. effective_setting_id = setting_id
  996. if printer_kp and printer_kp.filament_id:
  997. if not printer_kp.filament_id.startswith("PFUS"):
  998. effective_tray_info_idx = printer_kp.filament_id
  999. if printer_kp.setting_id:
  1000. effective_setting_id = printer_kp.setting_id
  1001. elif matching_kp and matching_kp.setting_id:
  1002. derived = normalize_slicer_filament(matching_kp.setting_id)[0]
  1003. if derived and not derived.startswith("PFUS"):
  1004. effective_tray_info_idx = derived
  1005. effective_setting_id = matching_kp.setting_id
  1006. if effective_tray_info_idx != tray_info_idx or effective_setting_id != setting_id:
  1007. logger.info(
  1008. "Spoolman link: realigning tray_info_idx %r → %r, setting_id %r → %r (kp_id=%s, source=%s)",
  1009. tray_info_idx,
  1010. effective_tray_info_idx,
  1011. setting_id,
  1012. effective_setting_id,
  1013. matching_kp.id if matching_kp else None,
  1014. "printer" if printer_kp else "stored",
  1015. )
  1016. mqtt_client.ams_set_filament_setting(
  1017. ams_id=a_id,
  1018. tray_id=t_id,
  1019. tray_info_idx=effective_tray_info_idx,
  1020. tray_type=tray_type,
  1021. tray_sub_brands=tray_sub_brands,
  1022. tray_color=tray_color,
  1023. nozzle_temp_min=temp_min,
  1024. nozzle_temp_max=temp_max,
  1025. setting_id=effective_setting_id,
  1026. )
  1027. sent_cali_idx: int | None = None
  1028. if matching_kp and matching_kp.cali_idx is not None:
  1029. sent_cali_idx = matching_kp.cali_idx
  1030. cali_filament_id = (
  1031. printer_kp.filament_id if printer_kp and printer_kp.filament_id else None
  1032. ) or effective_tray_info_idx
  1033. mqtt_client.extrusion_cali_sel(
  1034. ams_id=a_id,
  1035. tray_id=t_id,
  1036. cali_idx=matching_kp.cali_idx,
  1037. filament_id=cali_filament_id,
  1038. nozzle_diameter=nozzle_diameter,
  1039. )
  1040. logger.info(
  1041. "Spoolman link: applied K-profile cali_idx=%d "
  1042. "(kp_id=%d, filament_id=%s) for spool %d on printer %d AMS%d-T%d",
  1043. matching_kp.cali_idx,
  1044. matching_kp.id,
  1045. cali_filament_id,
  1046. spool_id,
  1047. p_id,
  1048. a_id,
  1049. t_id,
  1050. )
  1051. else:
  1052. from backend.app.api.routes.inventory import _find_tray_in_ams_data # noqa: PLC0415
  1053. live_tray = None
  1054. if state and state.raw_data:
  1055. ams_raw = state.raw_data.get("ams", [])
  1056. if isinstance(ams_raw, dict):
  1057. ams_raw = ams_raw.get("ams", [])
  1058. live_tray = _find_tray_in_ams_data(ams_raw, a_id, t_id)
  1059. live_cali_idx = (live_tray or {}).get("cali_idx")
  1060. if live_cali_idx is not None and live_cali_idx >= 0:
  1061. sent_cali_idx = live_cali_idx
  1062. mqtt_client.extrusion_cali_sel(
  1063. ams_id=a_id,
  1064. tray_id=t_id,
  1065. cali_idx=live_cali_idx,
  1066. filament_id=effective_tray_info_idx,
  1067. nozzle_diameter=nozzle_diameter,
  1068. )
  1069. # Read-back verification, as the assign paths register it
  1070. # (#2582); the K-profile only when one was sent.
  1071. mqtt_client.register_assignment_verification(
  1072. ams_id=a_id,
  1073. tray_id=t_id,
  1074. tray_info_idx=effective_tray_info_idx,
  1075. tray_color=tray_color,
  1076. cali_idx=sent_cali_idx,
  1077. )
  1078. # Fresh telemetry for the check to compare against.
  1079. mqtt_client.request_status_update()
  1080. logger.info(
  1081. "Auto-configured AMS slot ams=%d tray=%d after linking Spoolman spool %d on printer %d",
  1082. a_id,
  1083. t_id,
  1084. spool_id,
  1085. p_id,
  1086. )
  1087. except (SpoolmanNotFoundError, SpoolmanUnavailableError) as e:
  1088. logger.warning(
  1089. "Could not fetch Spoolman spool %d for MQTT configure after tag link: %s",
  1090. spool_id,
  1091. e,
  1092. )
  1093. except Exception:
  1094. logger.exception(
  1095. "Failed to auto-configure AMS slot after linking Spoolman spool %d (printer=%d ams=%d tray=%d)",
  1096. spool_id,
  1097. p_id,
  1098. a_id,
  1099. t_id,
  1100. )
  1101. return {"success": True, "message": f"Spool {spool_id} linked to AMS tag"}
  1102. @router.post("/spools/{spool_id}/unlink")
  1103. async def unlink_spool(
  1104. spool_id: int,
  1105. db: AsyncSession = Depends(get_db, scope="function"),
  1106. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
  1107. ):
  1108. """Unlink a Spoolman spool from AMS by clearing Spoolman extra.tag."""
  1109. sm = await get_spoolman_settings(db)
  1110. enabled, url = sm["enabled"], sm["url"]
  1111. if not enabled:
  1112. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  1113. client = await get_spoolman_client()
  1114. if not client:
  1115. if url:
  1116. client = await init_spoolman_client(url)
  1117. else:
  1118. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  1119. if not await client.health_check():
  1120. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  1121. # Spoolman PATCHes the extra dict by MERGING with the existing keys —
  1122. # popping "tag" from a copy of the dict and sending the rest doesn't
  1123. # clear it; Spoolman keeps the old value because the key wasn't in the
  1124. # payload. To actually clear a key we must explicitly send it as the
  1125. # JSON-encoded empty string ('""'), which the read-side filters in
  1126. # _map_spoolman_spool and get_linked_spools strip via .strip('"').
  1127. #
  1128. # merge_spool_extra acquires extra_lock(spool_id) internally — wrapping
  1129. # this call in another `async with client.extra_lock(spool_id)` would
  1130. # deadlock (asyncio.Lock is not reentrant).
  1131. try:
  1132. await client.merge_spool_extra(spool_id, {"tag": json.dumps("")})
  1133. # And the native tags, so an unlinked spool is found by none of them.
  1134. if await client.has_tag_api():
  1135. await client.unlink_all_native_tags(await client.get_spool(spool_id))
  1136. except SpoolmanNotFoundError:
  1137. raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
  1138. except SpoolmanClientError:
  1139. raise HTTPException(status_code=502, detail="Spoolman rejected the request")
  1140. except SpoolmanUnavailableError:
  1141. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  1142. # Remove local slot assignment for this spool (all slots — a spool can only be in one at a time)
  1143. try:
  1144. result = await db.execute(
  1145. delete(SpoolmanSlotAssignment)
  1146. .where(SpoolmanSlotAssignment.spoolman_spool_id == spool_id)
  1147. .returning(SpoolmanSlotAssignment.printer_id, SpoolmanSlotAssignment.ams_id, SpoolmanSlotAssignment.tray_id)
  1148. )
  1149. freed = list(result.tuples())
  1150. await db.commit()
  1151. except Exception:
  1152. await db.rollback()
  1153. logger.exception("DB error removing slot assignment for spool %s", spool_id)
  1154. raise HTTPException(status_code=500, detail="Failed to remove local slot assignment")
  1155. logger.info("Unlinked Spoolman spool %s", spool_id)
  1156. await ws_manager.broadcast({"type": "inventory_changed"})
  1157. await _announce_freed_slots(freed)
  1158. return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
  1159. class CreateSpoolFromSlotRequest(BaseModel):
  1160. printer_id: int
  1161. ams_id: int
  1162. tray_id: int
  1163. @router.post("/spools/from-slot")
  1164. async def create_spool_from_slot(
  1165. req: CreateSpoolFromSlotRequest,
  1166. db: AsyncSession = Depends(get_db, scope="function"),
  1167. _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
  1168. printer_scope: PrinterScope = RequestPrinterScope,
  1169. ):
  1170. """Explicit user action: create a Spoolman spool from an AMS slot's current tray data.
  1171. Used by the "+ Add to inventory" affordance when auto_add_unknown_rfid is disabled —
  1172. the user looked at the slot and chose to register it. Calls sync_ams_tray with the
  1173. auto-add override on so the spool is created even when the global setting is off.
  1174. """
  1175. sm = await get_spoolman_settings(db)
  1176. if not sm["enabled"]:
  1177. raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
  1178. client = await get_spoolman_client()
  1179. if not client:
  1180. if sm["url"]:
  1181. client = await init_spoolman_client(sm["url"])
  1182. else:
  1183. raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
  1184. if not await client.health_check():
  1185. raise HTTPException(status_code=503, detail="Spoolman is not reachable")
  1186. printer_scope.ensure(req.printer_id)
  1187. result = await db.execute(select(Printer).where(Printer.id == req.printer_id))
  1188. printer = result.scalar_one_or_none()
  1189. if not printer:
  1190. raise HTTPException(status_code=404, detail="Printer not found")
  1191. state = printer_manager.get_status(req.printer_id)
  1192. if not state or not state.raw_data:
  1193. raise HTTPException(status_code=404, detail="Printer not connected or no state available")
  1194. ams_data = state.raw_data.get("ams")
  1195. ams_units: list[dict] = []
  1196. if isinstance(ams_data, list):
  1197. ams_units = ams_data
  1198. elif isinstance(ams_data, dict):
  1199. if "ams" in ams_data and isinstance(ams_data["ams"], list):
  1200. ams_units = ams_data["ams"]
  1201. elif "tray" in ams_data:
  1202. ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
  1203. tray = None
  1204. for unit in ams_units:
  1205. if not isinstance(unit, dict):
  1206. continue
  1207. if int(unit.get("id", -1)) != req.ams_id:
  1208. continue
  1209. for t in unit.get("tray", []):
  1210. if isinstance(t, dict) and int(t.get("id", -1)) == req.tray_id:
  1211. tray = client.parse_ams_tray(req.ams_id, t)
  1212. break
  1213. if tray:
  1214. break
  1215. if not tray:
  1216. raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
  1217. # Same ghost-spool guard as the inventory route: no tag → no stable
  1218. # identity → confirm would just create a fresh Spoolman row per push.
  1219. from backend.app.services.spool_tag_matcher import is_valid_tag
  1220. if not is_valid_tag(tray.tag_uid or "", tray.tray_uuid or ""):
  1221. raise HTTPException(status_code=400, detail="Slot has no RFID tag")
  1222. sync_result = await client.sync_ams_tray(
  1223. tray,
  1224. printer.name,
  1225. db,
  1226. disable_weight_sync=True,
  1227. auto_add_unknown_rfid=True,
  1228. )
  1229. if not sync_result:
  1230. raise HTTPException(status_code=500, detail="Spoolman did not create a spool from the slot")
  1231. # Persist the slot assignment so the new spool shows on the slot tile.
  1232. # If this fails, surface a 500 — silently returning success while the
  1233. # binding rolled back leaves the user thinking the spool was added,
  1234. # then watching the modal re-fire on the next MQTT push.
  1235. if sync_result.get("id"):
  1236. try:
  1237. await db.execute(
  1238. text(
  1239. "INSERT INTO spoolman_slot_assignments"
  1240. " (printer_id, ams_id, tray_id, spoolman_spool_id)"
  1241. " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
  1242. " ON CONFLICT(printer_id, ams_id, tray_id)"
  1243. " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
  1244. ),
  1245. {
  1246. "printer_id": req.printer_id,
  1247. "ams_id": req.ams_id,
  1248. "tray_id": req.tray_id,
  1249. "spool_id": sync_result["id"],
  1250. },
  1251. )
  1252. await spool_swaps.record_assignment(
  1253. db, req.printer_id, req.ams_id, req.tray_id, sync_result["id"], spool_swaps.SPOOLMAN
  1254. )
  1255. await db.commit()
  1256. slot_unlink_grace.forget_slot(req.printer_id, req.ams_id, req.tray_id)
  1257. except Exception as exc:
  1258. await db.rollback()
  1259. logger.exception("Failed to persist Spoolman slot assignment")
  1260. raise HTTPException(
  1261. status_code=500,
  1262. detail=f"Spool created in Spoolman but slot assignment failed: {exc}",
  1263. ) from exc
  1264. if sync_result.get("id"):
  1265. await ws_manager.broadcast({"type": "inventory_changed"})
  1266. await _announce_freed_slots([(req.printer_id, req.ams_id, req.tray_id)])
  1267. return {"success": True, "spool_id": sync_result.get("id")}