spoolbuddy.py 73 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769
  1. """SpoolBuddy device management API routes."""
  2. import asyncio
  3. import contextlib
  4. import json
  5. import logging
  6. import re
  7. import time
  8. from datetime import datetime, timedelta, timezone
  9. from urllib.parse import urlparse
  10. import httpx
  11. from fastapi import APIRouter, Depends, HTTPException
  12. from sqlalchemy import func, select
  13. from sqlalchemy.ext.asyncio import AsyncSession
  14. from backend.app.api.routes.spoolman_inventory import _clear_stale_tag_links
  15. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  16. from backend.app.core.database import get_db
  17. from backend.app.core.permissions import Permission
  18. from backend.app.core.websocket import ws_manager
  19. from backend.app.models.spool import Spool
  20. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  21. from backend.app.models.user import User
  22. from backend.app.schemas.spoolbuddy import (
  23. CalibrationResponse,
  24. DeviceRegisterRequest,
  25. DeviceResponse,
  26. DiagnosticResultRequest,
  27. DisplaySettingsRequest,
  28. HeartbeatRequest,
  29. HeartbeatResponse,
  30. ScaleReadingRequest,
  31. SetCalibrationFactorRequest,
  32. SetTareRequest,
  33. SystemCommandRequest,
  34. SystemCommandResultRequest,
  35. SystemConfigRequest,
  36. TagRemovedRequest,
  37. TagScannedRequest,
  38. UpdateSpoolWeightRequest,
  39. WriteTagRequest,
  40. WriteTagResultRequest,
  41. )
  42. from backend.app.services.bambu_tag_blocks import spool_prefill_from_tag
  43. from backend.app.services.spool_tag_matcher import (
  44. begins_with_cascade_level_1,
  45. cascade_level_1_uid,
  46. cascade_level_1_uid_start,
  47. get_spool_by_tag,
  48. warn_cascade_level_1_ambiguous,
  49. )
  50. from backend.app.services.spoolman import SpoolmanClientError, SpoolmanNotFoundError, SpoolmanUnavailableError
  51. from backend.app.utils.tag_normalization import (
  52. is_bambu_tray_uuid,
  53. normalize_hex,
  54. normalize_tag_uid,
  55. normalize_tray_uuid,
  56. )
  57. logger = logging.getLogger(__name__)
  58. router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
  59. OFFLINE_THRESHOLD_SECONDS = 30
  60. ONLINE_BROADCAST_INTERVAL_SECONDS = 10
  61. _SSRF_WARN_THROTTLE_SECONDS = 60
  62. _spoolbuddy_online_last_broadcast: dict[str, float] = {}
  63. _ssrf_warn_last_broadcast: dict[str, float] = {}
  64. _diagnostic_results: dict[tuple[str, str], dict] = {}
  65. @contextlib.asynccontextmanager
  66. async def _translate_spoolbuddy_errors():
  67. """Translate Spoolman typed exceptions to HTTP for SpoolBuddy endpoints."""
  68. try:
  69. yield
  70. except SpoolmanNotFoundError as exc:
  71. raise HTTPException(status_code=404, detail="Spool not found in Spoolman") from exc
  72. except SpoolmanClientError as exc:
  73. raise HTTPException(status_code=502, detail="Spoolman rejected the request") from exc
  74. except SpoolmanUnavailableError as exc:
  75. raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc
  76. async def _spoolman_mode(db: AsyncSession) -> bool:
  77. """Spoolman mode as every SpoolBuddy screen reads it: switched on, with a URL.
  78. Distinct from having a usable client: a URL the SSRF guard refuses leaves the
  79. mode on but no client, and the spools are still Spoolman's.
  80. """
  81. from backend.app.api.routes.settings import get_setting
  82. enabled = await get_setting(db, "spoolman_enabled")
  83. url = await get_setting(db, "spoolman_url")
  84. return bool(enabled) and enabled.lower() == "true" and bool((url or "").strip())
  85. async def _get_spoolman_client_or_none(db: AsyncSession):
  86. """Return a SpoolmanClient if Spoolman is enabled with a safe URL, else None."""
  87. from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
  88. from backend.app.models.settings import Settings
  89. from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client
  90. settings_result = await db.execute(select(Settings))
  91. settings_dict = {s.key: s.value for s in settings_result.scalars().all()}
  92. spoolman_url = settings_dict.get("spoolman_url", "").strip()
  93. spoolman_enabled = settings_dict.get("spoolman_enabled", "false").lower() == "true" and bool(spoolman_url)
  94. if not spoolman_enabled:
  95. return None
  96. # SSRF guard: reject dangerous schemes, cloud-metadata IPs (169.254.169.254, 100.100.100.200,
  97. # fd00:ec2::254), multicast and unspecified addresses — loopback and RFC-1918 ranges are
  98. # intentionally permitted (Spoolman commonly runs on the same host or home LAN).
  99. try:
  100. assert_safe_spoolman_url(spoolman_url)
  101. except ValueError as exc:
  102. logger.warning(
  103. "Spoolman integration disabled: URL %r rejected by SSRF guard: %s",
  104. spoolman_url,
  105. exc,
  106. )
  107. now = time.monotonic()
  108. if now - _ssrf_warn_last_broadcast.get(spoolman_url, 0) > _SSRF_WARN_THROTTLE_SECONDS:
  109. _ssrf_warn_last_broadcast[spoolman_url] = now
  110. await ws_manager.broadcast(
  111. {
  112. "type": "spoolman_ssrf_blocked",
  113. "detail": "Spoolman URL was rejected by the SSRF guard",
  114. }
  115. )
  116. return None
  117. client = await get_spoolman_client()
  118. if not client or client.base_url != spoolman_url.rstrip("/"):
  119. try:
  120. client = await init_spoolman_client(spoolman_url)
  121. except ValueError as exc:
  122. logger.warning(
  123. "Spoolman integration disabled: URL %r rejected on re-initialisation: %s",
  124. spoolman_url,
  125. exc,
  126. )
  127. return None
  128. return client
  129. def _is_online(device: SpoolBuddyDevice) -> bool:
  130. if not device.last_seen:
  131. return False
  132. return (
  133. datetime.now(timezone.utc) - device.last_seen.replace(tzinfo=timezone.utc)
  134. ).total_seconds() < OFFLINE_THRESHOLD_SECONDS
  135. def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
  136. return DeviceResponse(
  137. id=device.id,
  138. device_id=device.device_id,
  139. hostname=device.hostname,
  140. ip_address=device.ip_address,
  141. firmware_version=device.firmware_version,
  142. has_nfc=device.has_nfc,
  143. has_scale=device.has_scale,
  144. tare_offset=device.tare_offset,
  145. calibration_factor=device.calibration_factor,
  146. nfc_reader_type=device.nfc_reader_type,
  147. nfc_connection=device.nfc_connection,
  148. backend_url=device.backend_url,
  149. display_brightness=device.display_brightness,
  150. display_blank_timeout=device.display_blank_timeout,
  151. has_backlight=device.has_backlight,
  152. last_calibrated_at=device.last_calibrated_at,
  153. last_seen=device.last_seen,
  154. pending_command=device.pending_command,
  155. nfc_ok=device.nfc_ok,
  156. scale_ok=device.scale_ok,
  157. uptime_s=device.uptime_s,
  158. update_status=device.update_status,
  159. update_message=device.update_message,
  160. system_stats=json.loads(device.system_stats) if device.system_stats else None,
  161. online=_is_online(device),
  162. created_at=device.created_at,
  163. updated_at=device.updated_at,
  164. )
  165. def _should_broadcast_online(device_id: str, force: bool = False) -> bool:
  166. if force:
  167. _spoolbuddy_online_last_broadcast[device_id] = time.time()
  168. return True
  169. now_ts = time.time()
  170. last_ts = _spoolbuddy_online_last_broadcast.get(device_id, 0.0)
  171. if now_ts - last_ts >= ONLINE_BROADCAST_INTERVAL_SECONDS:
  172. _spoolbuddy_online_last_broadcast[device_id] = now_ts
  173. return True
  174. return False
  175. # --- Device endpoints ---
  176. @router.post("/devices/register", response_model=DeviceResponse)
  177. async def register_device(
  178. req: DeviceRegisterRequest,
  179. db: AsyncSession = Depends(get_db, scope="function"),
  180. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  181. ):
  182. """Register or re-register a SpoolBuddy device."""
  183. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
  184. device = result.scalar_one_or_none()
  185. now = datetime.now(timezone.utc)
  186. if device:
  187. device.hostname = req.hostname
  188. device.ip_address = req.ip_address
  189. device.firmware_version = req.firmware_version
  190. device.has_nfc = req.has_nfc
  191. device.has_scale = req.has_scale
  192. device.nfc_reader_type = req.nfc_reader_type
  193. device.nfc_connection = req.nfc_connection
  194. if req.backend_url:
  195. device.backend_url = req.backend_url
  196. device.has_backlight = req.has_backlight
  197. device.last_seen = now
  198. # Clear stale update status on re-registration (daemon restarted after update)
  199. if device.update_status in ("pending", "updating", "complete", "error"):
  200. device.update_status = None
  201. device.update_message = None
  202. logger.info("SpoolBuddy device re-registered: %s (%s)", req.device_id, req.hostname)
  203. else:
  204. device = SpoolBuddyDevice(
  205. device_id=req.device_id,
  206. hostname=req.hostname,
  207. ip_address=req.ip_address,
  208. firmware_version=req.firmware_version,
  209. has_nfc=req.has_nfc,
  210. has_scale=req.has_scale,
  211. tare_offset=req.tare_offset,
  212. calibration_factor=req.calibration_factor,
  213. nfc_reader_type=req.nfc_reader_type,
  214. nfc_connection=req.nfc_connection,
  215. has_backlight=req.has_backlight,
  216. backend_url=req.backend_url,
  217. last_seen=now,
  218. )
  219. db.add(device)
  220. logger.info("SpoolBuddy device registered: %s (%s)", req.device_id, req.hostname)
  221. await db.commit()
  222. await db.refresh(device)
  223. _spoolbuddy_online_last_broadcast[device.device_id] = time.time()
  224. await ws_manager.broadcast(
  225. {
  226. "type": "spoolbuddy_online",
  227. "device_id": device.device_id,
  228. "hostname": device.hostname,
  229. }
  230. )
  231. response = _device_to_response(device)
  232. # Include SSH public key so the daemon can auto-deploy it
  233. try:
  234. from backend.app.services.spoolbuddy_ssh import get_public_key
  235. response.ssh_public_key = await get_public_key()
  236. except Exception as exc:
  237. logger.warning("Could not attach SSH public key to heartbeat response: %s", exc)
  238. return response
  239. @router.get("/devices", response_model=list[DeviceResponse])
  240. async def list_devices(
  241. db: AsyncSession = Depends(get_db, scope="function"),
  242. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  243. ):
  244. """List all registered SpoolBuddy devices."""
  245. result = await db.execute(select(SpoolBuddyDevice).order_by(SpoolBuddyDevice.hostname))
  246. devices = list(result.scalars().all())
  247. return [_device_to_response(d) for d in devices]
  248. @router.delete("/devices/{device_id}")
  249. async def unregister_device(
  250. device_id: str,
  251. db: AsyncSession = Depends(get_db, scope="function"),
  252. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_DELETE),
  253. ):
  254. """Unregister a SpoolBuddy device. The daemon can re-register via heartbeat later."""
  255. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  256. device = result.scalar_one_or_none()
  257. if not device:
  258. raise HTTPException(status_code=404, detail="Device not registered")
  259. await db.delete(device)
  260. await db.commit()
  261. _spoolbuddy_online_last_broadcast.pop(device_id, None)
  262. logger.info("SpoolBuddy device unregistered: %s (%s)", device_id, device.hostname)
  263. await ws_manager.broadcast({"type": "spoolbuddy_unregistered", "device_id": device_id})
  264. return {"status": "deleted", "device_id": device_id}
  265. @router.post("/devices/{device_id}/heartbeat", response_model=HeartbeatResponse)
  266. async def device_heartbeat(
  267. device_id: str,
  268. req: HeartbeatRequest,
  269. db: AsyncSession = Depends(get_db, scope="function"),
  270. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  271. ):
  272. """Daemon heartbeat — updates status and returns pending commands."""
  273. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  274. device = result.scalar_one_or_none()
  275. if not device:
  276. raise HTTPException(status_code=404, detail="Device not registered")
  277. was_offline = not _is_online(device)
  278. now = datetime.now(timezone.utc)
  279. device.last_seen = now
  280. device.nfc_ok = req.nfc_ok
  281. device.scale_ok = req.scale_ok
  282. device.uptime_s = req.uptime_s
  283. if req.firmware_version:
  284. device.firmware_version = req.firmware_version
  285. if req.ip_address:
  286. device.ip_address = req.ip_address
  287. if req.nfc_reader_type:
  288. device.nfc_reader_type = req.nfc_reader_type
  289. if req.nfc_connection:
  290. device.nfc_connection = req.nfc_connection
  291. if req.backend_url:
  292. device.backend_url = req.backend_url
  293. if req.system_stats is not None:
  294. device.system_stats = json.dumps(req.system_stats)
  295. # Return and clear pending command
  296. pending = device.pending_command
  297. pending_write = None
  298. pending_system = None
  299. if pending == "write_tag" and device.pending_write_payload:
  300. # Parse the stored JSON payload to include in response
  301. try:
  302. pending_write = json.loads(device.pending_write_payload)
  303. except (json.JSONDecodeError, TypeError):
  304. pending_write = None
  305. # Don't clear write_tag command — it gets cleared by write-result
  306. elif pending == "apply_system_config" and device.pending_system_payload:
  307. try:
  308. pending_system = json.loads(device.pending_system_payload)
  309. except (json.JSONDecodeError, TypeError):
  310. pending_system = None
  311. # Don't clear config command — it gets cleared by daemon command-result callback
  312. elif pending and pending.startswith("run_") and pending.endswith("_diag"):
  313. # Don't clear diagnostic commands — they get cleared by the device reporting results
  314. pass
  315. else:
  316. device.pending_command = None
  317. await db.commit()
  318. # Emit online presence on offline->online transitions immediately, and
  319. # periodically while online so newly connected UIs can bootstrap state.
  320. if _should_broadcast_online(device.device_id, force=was_offline):
  321. await ws_manager.broadcast(
  322. {
  323. "type": "spoolbuddy_online",
  324. "device_id": device.device_id,
  325. "hostname": device.hostname,
  326. }
  327. )
  328. if was_offline:
  329. logger.info("SpoolBuddy device back online: %s", device.device_id)
  330. # Include current SSH public key so the daemon can re-deploy it whenever
  331. # Bambuddy's keypair rotates (data dir wiped, container recreated, etc.) —
  332. # otherwise SSH updates fail until the daemon restarts.
  333. ssh_public_key: str | None = None
  334. try:
  335. from backend.app.services.spoolbuddy_ssh import get_public_key
  336. ssh_public_key = await get_public_key()
  337. except Exception:
  338. pass
  339. return HeartbeatResponse(
  340. pending_command=pending,
  341. pending_write_payload=pending_write,
  342. pending_system_payload=pending_system,
  343. tare_offset=device.tare_offset,
  344. calibration_factor=device.calibration_factor,
  345. display_brightness=device.display_brightness,
  346. display_blank_timeout=device.display_blank_timeout,
  347. ssh_public_key=ssh_public_key,
  348. )
  349. # --- NFC endpoints ---
  350. async def _backfill_local_tray_uuid(db: AsyncSession, spool: Spool, tag_uid: str, tray_uuid: str) -> None:
  351. """Give a spool matched by its exact tag UID the tray UUID read from that tag.
  352. Spools added on the kiosk before #984 carry only the UID of the tag that was
  353. scanned then, so the spool's other tag and the AMS never found them. The scan
  354. read block 9 of this very tag, so the UUID belongs to this spool. Only an
  355. exact UID match counts: a fuzzy (suffix or first-byte) match may be another
  356. spool's tag and must never write anything. A UUID any other spool carries,
  357. archived ones included, is left alone.
  358. """
  359. if spool.tray_uuid or normalize_tag_uid(spool.tag_uid) != normalize_tag_uid(tag_uid):
  360. return
  361. try:
  362. holder = await db.execute(
  363. select(Spool.id).where(func.upper(Spool.tray_uuid) == tray_uuid, Spool.id != spool.id).limit(1)
  364. )
  365. if holder.scalar_one_or_none() is not None:
  366. return
  367. spool.tray_uuid = tray_uuid
  368. await db.commit()
  369. logger.info("SpoolBuddy: saved tray_uuid %s on spool %d, matched by tag %s", tray_uuid, spool.id, tag_uid)
  370. except Exception:
  371. await db.rollback()
  372. logger.exception("SpoolBuddy: could not save tray_uuid %s on spool %d", tray_uuid, spool.id)
  373. async def _backfill_spoolman_tray_uuid(client, sm_spool: dict, tag_uid: str, tray_uuid: str) -> None:
  374. """Store the tray UUID as the tag of a Spoolman spool matched by its exact tag UID.
  375. Spoolman has one extra.tag per spool, and the AMS sync keys Bambu spools by
  376. the tray UUID there, so the UUID replaces the tag UID. The caller already
  377. looked the UUID up among the active spools and found none, the same check
  378. the link route makes. See _backfill_local_tray_uuid for why only an exact
  379. UID match counts.
  380. """
  381. extra = sm_spool.get("extra")
  382. raw_tag = extra.get("tag") if isinstance(extra, dict) else None
  383. stored = raw_tag.strip('"').upper() if isinstance(raw_tag, str) else ""
  384. spool_id = sm_spool.get("id")
  385. if not isinstance(spool_id, int) or not stored or stored != tag_uid.strip('"').upper():
  386. return
  387. try:
  388. await client.merge_spool_extra(spool_id, {"tag": json.dumps(tray_uuid)})
  389. logger.info("SpoolBuddy: stored tray_uuid %s as the tag of Spoolman spool %d", tray_uuid, spool_id)
  390. await ws_manager.broadcast({"type": "inventory_changed"})
  391. except Exception:
  392. logger.exception("SpoolBuddy: could not store tray_uuid %s on Spoolman spool %d", tray_uuid, spool_id)
  393. def _spoolman_native_tag_uids(sm_spool: dict) -> set[str]:
  394. """The UIDs a Spoolman spool holds as native tags (Spoolman 0.27+)."""
  395. uids = {normalize_hex(t.get("uid")) for t in sm_spool.get("tags") or [] if isinstance(t, dict)}
  396. uids.discard("")
  397. return uids
  398. def _spoolman_extra_tag_uid(sm_spool: dict) -> str:
  399. """The tag UID in a Spoolman spool's extra.tag, which holds it JSON-encoded; empty when none."""
  400. extra = sm_spool.get("extra")
  401. raw_tag = extra.get("tag") if isinstance(extra, dict) else None
  402. return normalize_hex(raw_tag) if isinstance(raw_tag, str) else ""
  403. def _spoolman_tag_links(sm_spool: dict) -> set[str]:
  404. """Every UID a Spoolman spool is linked by: its native tags and extra.tag."""
  405. return _spoolman_native_tag_uids(sm_spool) | ({_spoolman_extra_tag_uid(sm_spool)} - {""})
  406. def _spoolman_spool_by_cascade_level_1(spools: list[dict], tag_uid: str, sak: int | None = None) -> dict | None:
  407. """Match a tag UID and a Spoolman link stored from the tag's cascade level 1, either way round.
  408. The Spoolman counterpart of the built-in inventory's fallback in
  409. get_spool_by_tag (#3258), with the same rules. A full 7- or 10-byte read
  410. finds the active spool linked as 88 + the UID's first three bytes, as a
  411. native tag or in extra.tag. A level-1 read from a daemon not updated yet
  412. finds the active spool linked by the 7-byte UID it begins. Three bytes can
  413. begin more than one tag, so only a single fitting spool is matched.
  414. """
  415. level1_uid = cascade_level_1_uid(tag_uid)
  416. uid_start = cascade_level_1_uid_start(tag_uid, sak)
  417. if not level1_uid and not uid_start:
  418. return None
  419. matches: list[dict] = []
  420. for sm_spool in spools:
  421. if not isinstance(sm_spool, dict) or sm_spool.get("archived"):
  422. continue
  423. links = _spoolman_tag_links(sm_spool)
  424. if level1_uid in links or (
  425. uid_start is not None and any(begins_with_cascade_level_1(uid, uid_start) for uid in links)
  426. ):
  427. matches.append(sm_spool)
  428. if len(matches) == 1:
  429. logger.info(
  430. "Matched Spoolman spool %s by cascade level 1 of tag %s: links %s",
  431. matches[0].get("id"),
  432. normalize_hex(tag_uid),
  433. sorted(_spoolman_tag_links(matches[0])),
  434. )
  435. return matches[0]
  436. if len(matches) > 1:
  437. warn_cascade_level_1_ambiguous(normalize_hex(tag_uid), [sm_spool.get("id") for sm_spool in matches])
  438. return None
  439. async def _upgrade_spoolman_cascade_level_1_link(client, sm_spool: dict, full_uid: str, level1_uid: str) -> None:
  440. """Move a Spoolman spool's tag link stored from cascade level 1 over to the full UID (#3258).
  441. The scan has linked the full UID as a native tag already (add_native_tags).
  442. An extra.tag holding the 88 form is rewritten to the full UID, and the 88
  443. native tag is unlinked once the spool is linked by the full UID, natively
  444. or in extra.tag, so the spool is never left without a link.
  445. Best-effort: a write Spoolman refuses or fails is logged and the scan keeps
  446. its match. Whatever is left of the 88 form stays on the spool, and the next
  447. full scan of the tag comes here again and finishes the move.
  448. """
  449. spool_id = sm_spool.get("id")
  450. if not isinstance(spool_id, int):
  451. return
  452. changed = False
  453. if _spoolman_extra_tag_uid(sm_spool) == level1_uid:
  454. try:
  455. await client.merge_spool_extra(spool_id, {"tag": json.dumps(full_uid)})
  456. changed = True
  457. logger.info(
  458. "SpoolBuddy: extra.tag %s of Spoolman spool %d rewritten to the full UID %s",
  459. level1_uid,
  460. spool_id,
  461. full_uid,
  462. )
  463. except Exception as exc:
  464. logger.warning(
  465. "SpoolBuddy: could not rewrite extra.tag %s of Spoolman spool %d to %s, trying again on the next scan: %s",
  466. level1_uid,
  467. spool_id,
  468. full_uid,
  469. exc,
  470. )
  471. if level1_uid in _spoolman_native_tag_uids(sm_spool):
  472. try:
  473. current = await client.get_spool(spool_id)
  474. if full_uid in _spoolman_tag_links(current):
  475. await client.unlink_native_tag(spool_id, level1_uid)
  476. changed = True
  477. logger.info(
  478. "SpoolBuddy: native tag %s unlinked from Spoolman spool %d, now linked by the full UID %s",
  479. level1_uid,
  480. spool_id,
  481. full_uid,
  482. )
  483. else:
  484. logger.info(
  485. "SpoolBuddy: Spoolman spool %d keeps native tag %s until the full UID %s is linked",
  486. spool_id,
  487. level1_uid,
  488. full_uid,
  489. )
  490. except Exception as exc:
  491. logger.warning(
  492. "SpoolBuddy: could not unlink native tag %s from Spoolman spool %d, trying again on the next scan: %s",
  493. level1_uid,
  494. spool_id,
  495. exc,
  496. )
  497. if changed:
  498. await ws_manager.broadcast({"type": "inventory_changed"})
  499. @router.post("/nfc/tag-scanned")
  500. async def nfc_tag_scanned(
  501. req: TagScannedRequest,
  502. db: AsyncSession = Depends(get_db, scope="function"),
  503. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  504. ):
  505. """RPi reports NFC tag detected — lookup spool and broadcast.
  506. Routes the lookup to the inventory backend Bambuddy is configured for:
  507. Spoolman exclusively when ``spoolman_enabled`` is true, local DB
  508. exclusively otherwise. The previous implementation always tried local
  509. first and only consulted Spoolman as a fallback on local-DB miss, which
  510. meant a stale local copy of a tag would silently win over the
  511. authoritative Spoolman row, and deleting the local copy was the only way
  512. to surface the Spoolman match. Operators expect the SpoolBuddy lookup to
  513. follow the inventory mode they selected in Bambuddy settings.
  514. """
  515. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
  516. # Daemons before #984 read the filament type from blocks 4-5 and sent it as
  517. # tray_uuid. Drop anything that is not a real tray UUID, so an old daemon
  518. # falls back to tag_uid matching and its value is never offered for saving.
  519. tray_uuid: str | None = normalize_tray_uuid(req.tray_uuid) or None
  520. if tray_uuid and not is_bambu_tray_uuid(tray_uuid):
  521. logger.info(
  522. "SpoolBuddy %s sent tray_uuid %s for tag %s, which is not a Bambu tray UUID; ignoring it. "
  523. "Update the SpoolBuddy daemon.",
  524. req.device_id,
  525. tray_uuid,
  526. req.tag_uid,
  527. )
  528. tray_uuid = None
  529. # A usable client when Spoolman is on and its URL passes the SSRF guard.
  530. # With the mode on but no usable client the spools are still Spoolman's,
  531. # and the built-in table must not answer.
  532. client = await _get_spoolman_client_or_none(db)
  533. if client is None and await _spoolman_mode(db):
  534. await ws_manager.broadcast(
  535. {
  536. "type": "spoolman_unavailable",
  537. "device_id": req.device_id,
  538. "context": "nfc_tag_scanned",
  539. }
  540. )
  541. return {"status": "ok", "matched": False, "spool_id": None}
  542. if client is not None:
  543. # Spoolman mode — exclusive lookup, no local-DB fallback.
  544. try:
  545. sm_spool: dict | None = None
  546. native = await client.has_tag_api()
  547. cached_spools: list[dict] | None = None
  548. # The tray UUID is settled before the tag UID, as it always was: a spool
  549. # that carries the UUID only in extra.tag still wins over one holding the
  550. # chip UID natively. Spoolman 0.27+ answers each identifier with one exact
  551. # query; the whole inventory is loaded only when that misses.
  552. for uid in (tray_uuid, req.tag_uid):
  553. if not uid or sm_spool is not None:
  554. continue
  555. if native:
  556. try:
  557. sm_spool = await client.find_spool_by_native_tag(uid.upper())
  558. except SpoolmanClientError as exc:
  559. logger.warning("Native tag lookup refused for %s, using extra.tag: %s", uid, exc)
  560. if sm_spool is None:
  561. if cached_spools is None:
  562. cached_spools = await client.get_spools()
  563. sm_spool = await client.find_spool_by_tag(uid, cached_spools=cached_spools)
  564. # Last, a link stored from the tag's cascade level 1 (#3258), as
  565. # get_spool_by_tag falls back to in the built-in inventory.
  566. level1_read = cascade_level_1_uid_start(req.tag_uid, req.sak) is not None
  567. level1_read_match = False
  568. if sm_spool is None and (level1_read or cascade_level_1_uid(req.tag_uid)):
  569. if cached_spools is None:
  570. cached_spools = await client.get_spools()
  571. sm_spool = _spoolman_spool_by_cascade_level_1(cached_spools, req.tag_uid, req.sak)
  572. level1_read_match = level1_read and sm_spool is not None
  573. if sm_spool is not None:
  574. # What this scan read becomes a native tag of the spool: a spool found
  575. # through extra.tag moves over on its first scan, and a Bambu spool
  576. # collects the chip UID of each side as it is read. A level-1 read
  577. # that matched through the fallback is not one: 88 + three bytes can
  578. # begin more than one tag, and the spool is linked by the full UID.
  579. scanned_uids = [tray_uuid] if level1_read_match else [tray_uuid, req.tag_uid]
  580. await client.add_native_tags(sm_spool, scanned_uids, "bambu" if tray_uuid else None, retry_refused=True)
  581. mapped = _map_spoolman_spool(sm_spool)
  582. await ws_manager.broadcast(
  583. {
  584. "type": "spoolbuddy_tag_matched",
  585. "device_id": req.device_id,
  586. "tag_uid": req.tag_uid,
  587. "tray_uuid": tray_uuid,
  588. "spool": {
  589. "id": mapped["id"],
  590. "material": mapped["material"],
  591. "subtype": mapped["subtype"],
  592. "color_name": mapped["color_name"],
  593. # Spoolman stores no colour name, so `color_name`
  594. # here is usually the spool's subtype standing in
  595. # for one. The kiosk needs to know that to prefer
  596. # the colour catalog over "Silk+" (#3090).
  597. "color_name_is_synthesized": mapped["color_name_is_synthesized"],
  598. "rgba": mapped["rgba"],
  599. # The kiosk paints the disc from these, as the
  600. # Filament page does (#3033).
  601. "extra_colors": mapped["extra_colors"],
  602. "effect_type": mapped["effect_type"],
  603. "brand": mapped["brand"],
  604. "label_weight": mapped["label_weight"],
  605. "core_weight": mapped["core_weight"],
  606. "weight_used": mapped["weight_used"],
  607. },
  608. }
  609. )
  610. logger.info("SpoolBuddy tag matched (Spoolman): %s -> spool %d", req.tag_uid, mapped["id"])
  611. # A full read of a spool still linked by the 88 form, matched by the
  612. # fallback or exactly after an earlier move stopped half-way.
  613. level1_uid = cascade_level_1_uid(req.tag_uid)
  614. if level1_uid and level1_uid in _spoolman_tag_links(sm_spool):
  615. await _upgrade_spoolman_cascade_level_1_link(
  616. client, sm_spool, normalize_hex(req.tag_uid), level1_uid
  617. )
  618. if tray_uuid:
  619. await _backfill_spoolman_tray_uuid(client, sm_spool, req.tag_uid, tray_uuid)
  620. return {"status": "ok", "matched": True, "spool_id": mapped["id"]}
  621. except ValueError as exc:
  622. logger.error(
  623. "Spoolman returned malformed spool data during tag lookup for %s: %s",
  624. req.tag_uid,
  625. exc,
  626. )
  627. return {"status": "ok", "matched": False, "spool_id": None}
  628. except (httpx.RequestError, httpx.HTTPStatusError, SpoolmanUnavailableError):
  629. logger.warning(
  630. "Spoolman unreachable during tag lookup for %s",
  631. req.tag_uid,
  632. )
  633. # Broadcast a diagnostic event so the UI can surface "Spoolman down" to the user.
  634. # Use a distinct type from spoolbuddy_unknown_tag — Spoolman outage != unregistered spool.
  635. await ws_manager.broadcast(
  636. {
  637. "type": "spoolman_unavailable",
  638. "device_id": req.device_id,
  639. "context": "nfc_tag_scanned",
  640. }
  641. )
  642. return {"status": "ok", "matched": False, "spool_id": None}
  643. except Exception as exc:
  644. logger.error(
  645. "Spoolman tag lookup failed unexpectedly for %s: %s",
  646. req.tag_uid,
  647. exc,
  648. )
  649. # Broadcast a distinct error event so operators can distinguish
  650. # "unexpected backend error" from "unregistered tag".
  651. await ws_manager.broadcast(
  652. {
  653. "type": "spoolbuddy_lookup_error",
  654. "device_id": req.device_id,
  655. }
  656. )
  657. # Same silent-return policy: an unexpected error must not break device operation
  658. # or trigger spurious duplicate-registration flows in the UI.
  659. return {"status": "ok", "matched": False, "spool_id": None}
  660. else:
  661. # Local mode — exclusive lookup, no Spoolman fallback.
  662. spool = await get_spool_by_tag(db, req.tag_uid, tray_uuid or "", req.sak)
  663. if spool:
  664. await ws_manager.broadcast(
  665. {
  666. "type": "spoolbuddy_tag_matched",
  667. "device_id": req.device_id,
  668. "tag_uid": req.tag_uid,
  669. "tray_uuid": tray_uuid,
  670. "spool": {
  671. "id": spool.id,
  672. "material": spool.material,
  673. "subtype": spool.subtype,
  674. "color_name": spool.color_name,
  675. # Local inventory stores what the user or their tag
  676. # set, and nothing else — never a stand-in (#3090).
  677. "color_name_is_synthesized": False,
  678. "rgba": spool.rgba,
  679. "extra_colors": spool.extra_colors,
  680. "effect_type": spool.effect_type,
  681. "brand": spool.brand,
  682. "label_weight": spool.label_weight,
  683. "core_weight": spool.core_weight,
  684. "weight_used": spool.weight_used,
  685. },
  686. }
  687. )
  688. logger.info("SpoolBuddy tag matched (local): %s -> spool %d", req.tag_uid, spool.id)
  689. if tray_uuid:
  690. await _backfill_local_tray_uuid(db, spool, req.tag_uid, tray_uuid)
  691. return {"status": "ok", "matched": True, "spool_id": spool.id}
  692. # What the tag itself says about the roll, so the kiosk's Quick Add can
  693. # create the spool AMS sync would have created, not a blank PLA (#2685)
  694. tag_data = None
  695. if req.raw_blocks:
  696. try:
  697. tag_data = await spool_prefill_from_tag(db, req.raw_blocks)
  698. except Exception:
  699. logger.warning("Could not decode Bambu tag blocks for %s", req.tag_uid, exc_info=True)
  700. await ws_manager.broadcast(
  701. {
  702. "type": "spoolbuddy_unknown_tag",
  703. "device_id": req.device_id,
  704. "tag_uid": req.tag_uid,
  705. "tray_uuid": tray_uuid,
  706. "sak": req.sak,
  707. "tag_type": req.tag_type,
  708. "tag_data": tag_data,
  709. }
  710. )
  711. logger.info(
  712. "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
  713. req.tag_uid,
  714. len(req.tag_uid or ""),
  715. tray_uuid,
  716. len(tray_uuid or ""),
  717. req.tag_type,
  718. req.sak,
  719. )
  720. return {"status": "ok", "matched": False, "spool_id": None}
  721. @router.post("/nfc/tag-removed")
  722. async def nfc_tag_removed(
  723. req: TagRemovedRequest,
  724. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  725. ):
  726. """RPi reports NFC tag removed — broadcast event."""
  727. await ws_manager.broadcast(
  728. {
  729. "type": "spoolbuddy_tag_removed",
  730. "device_id": req.device_id,
  731. "tag_uid": req.tag_uid,
  732. }
  733. )
  734. return {"status": "ok"}
  735. @router.post("/nfc/write-tag")
  736. async def nfc_write_tag(
  737. req: WriteTagRequest,
  738. db: AsyncSession = Depends(get_db, scope="function"),
  739. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  740. ):
  741. """Queue an NFC tag write command for a SpoolBuddy device.
  742. The spool comes from the inventory in use: Spoolman when it is enabled,
  743. otherwise Bambuddy's own. Built-in spools are kept after switching to
  744. Spoolman, so one with the same id must not stand in for the Spoolman spool.
  745. """
  746. from backend.app.models.spool import Spool
  747. from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped
  748. # Find the device first (required regardless of spool source)
  749. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
  750. device = result.scalar_one_or_none()
  751. if not device:
  752. raise HTTPException(status_code=404, detail="Device not registered")
  753. sm_client = await _get_spoolman_client_or_none(db)
  754. if sm_client is None and await _spoolman_mode(db):
  755. raise HTTPException(status_code=503, detail="Spoolman is unavailable")
  756. nfc_warnings: list[str] = []
  757. if sm_client is None:
  758. spool = (await db.execute(select(Spool).where(Spool.id == req.spool_id))).scalar_one_or_none()
  759. if spool is None:
  760. raise HTTPException(status_code=404, detail="Spool not found")
  761. ndef_data = encode_opentag3d(spool)
  762. data_origin = "local"
  763. else:
  764. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
  765. async with _translate_spoolbuddy_errors():
  766. sm_spool = await sm_client.get_spool(req.spool_id)
  767. try:
  768. mapped = _map_spoolman_spool(sm_spool)
  769. except ValueError as exc:
  770. logger.warning("Spoolman returned invalid spool for write-tag: %s", exc)
  771. raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data")
  772. if not mapped.get("material"):
  773. raise HTTPException(
  774. status_code=400,
  775. detail="Spoolman spool has no material set — cannot encode NFC tag",
  776. )
  777. # A Bambu spool created from the AMS is linked by its tray UUID in
  778. # extra.tag; a sticker would replace it there, and the next AMS sync,
  779. # no longer finding the spool, would create a duplicate (SM-C5). Not
  780. # when Spoolman also keeps that UUID as a native tag (0.27+): the sync
  781. # finds the spool by it first, and the sticker leaves it alone.
  782. from backend.app.api.routes._spoolman_helpers import _extract_extra_str
  783. extra_tag = _extract_extra_str(sm_spool.get("extra") or {}, "tag").strip().upper()
  784. native_uids = {str(t.get("uid", "")).upper() for t in sm_spool.get("tags") or [] if isinstance(t, dict)}
  785. if re.fullmatch(r"[0-9A-F]{32}", extra_tag) and extra_tag not in native_uids:
  786. raise HTTPException(
  787. status_code=409,
  788. detail="This spool is linked to its Bambu RFID tag through the AMS; "
  789. "a sticker would replace that link in Spoolman",
  790. )
  791. ndef_data = encode_opentag3d_from_mapped(mapped)
  792. data_origin = "spoolman"
  793. # Warn when fields that drive NFC content are absent in Spoolman.
  794. # The colour name is read the way the tag gets it: from the spool's
  795. # extra (where Bambuddy keeps it -- Spoolman's filament has no such
  796. # field, so checking that warned on every spool, SM-C6), else the
  797. # subtype stands in for it.
  798. if mapped.get("color_name") is None:
  799. nfc_warnings.append("color_name not set in Spoolman — tag encodes an empty color name")
  800. elif mapped.get("color_name_is_synthesized"):
  801. nfc_warnings.append(
  802. f"color_name not set in Spoolman — tag encodes the subtype ({mapped['color_name']}) as its color name"
  803. )
  804. if not mapped.get("nozzle_temp_min"):
  805. nfc_warnings.append("nozzle_temp_min not set in Spoolman — tag encodes 0 °C")
  806. if not mapped.get("subtype"):
  807. nfc_warnings.append("subtype not set in Spoolman — tag encodes empty subtype")
  808. if not mapped.get("brand"):
  809. nfc_warnings.append("brand/vendor not set in Spoolman — tag encodes empty brand")
  810. if not mapped.get("rgba"):
  811. nfc_warnings.append("rgba not set in Spoolman — tag encodes default colour")
  812. if not mapped.get("label_weight"):
  813. nfc_warnings.append("label_weight not set in Spoolman — tag encodes 0 g")
  814. if nfc_warnings:
  815. logger.warning(
  816. "NFC encode for Spoolman spool %d has incomplete data: %s",
  817. req.spool_id,
  818. "; ".join(nfc_warnings),
  819. )
  820. # Store write payload and set pending command
  821. device.pending_write_payload = json.dumps(
  822. {
  823. "spool_id": req.spool_id,
  824. "ndef_data_hex": ndef_data.hex(),
  825. "data_origin": data_origin,
  826. }
  827. )
  828. device.pending_command = "write_tag"
  829. await db.commit()
  830. logger.info(
  831. "Write tag queued for device %s, spool %d (%s, %d bytes)",
  832. req.device_id,
  833. req.spool_id,
  834. data_origin,
  835. len(ndef_data),
  836. )
  837. result: dict = {"status": "queued"}
  838. if nfc_warnings:
  839. result["warnings"] = nfc_warnings
  840. return result
  841. @router.post("/nfc/write-result")
  842. async def nfc_write_result(
  843. req: WriteTagResultRequest,
  844. db: AsyncSession = Depends(get_db, scope="function"),
  845. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  846. ):
  847. """Handle NFC tag write result from SpoolBuddy daemon."""
  848. # Find the device
  849. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
  850. device = result.scalar_one_or_none()
  851. if not device:
  852. raise HTTPException(status_code=404, detail="Device not registered")
  853. # Capture data_origin before clearing the payload
  854. try:
  855. payload_dict = json.loads(device.pending_write_payload or "{}")
  856. except (json.JSONDecodeError, TypeError):
  857. payload_dict = {}
  858. logger.warning("Malformed pending_write_payload for device %s — treating as local", req.device_id)
  859. data_origin = payload_dict.get("data_origin", "local")
  860. device.pending_command = None
  861. device.pending_write_payload = None
  862. if req.success:
  863. if data_origin == "spoolman":
  864. # Update Spoolman extra.tag with the written NFC UID using a safe merge
  865. # (fetches current extra first to avoid overwriting other custom fields).
  866. sm_client = await _get_spoolman_client_or_none(db)
  867. if sm_client is None:
  868. logger.warning("Spoolman not configured; cannot persist tag link for spool %d", req.spool_id)
  869. await db.commit()
  870. await ws_manager.broadcast(
  871. {
  872. "type": "spoolbuddy_tag_link_failed",
  873. "device_id": req.device_id,
  874. "spool_id": req.spool_id,
  875. "tag_uid": req.tag_uid,
  876. "message": "Spoolman not configured",
  877. "reason": "spoolman_not_configured",
  878. }
  879. )
  880. raise HTTPException(
  881. status_code=502,
  882. detail="Tag written to NFC but Spoolman is not configured; link not persisted",
  883. )
  884. _tag_link_ok = False
  885. try:
  886. tag_value = json.dumps(req.tag_uid.upper())
  887. # Tag uniqueness: a single physical NFC UID must map to at most
  888. # one Spoolman spool, otherwise find_spool_by_tag returns
  889. # whichever spool comes first in the cached list (usually the
  890. # older one) and the dashboard shows the wrong spool when the
  891. # tag is scanned. Before binding the new owner, clear extra.tag
  892. # on every other spool that holds it -- JSON-encoded, as Spoolman
  893. # refuses a bare "" (#3312). Best-effort: cleanup failure does
  894. # not block the write itself; each failed spool is logged.
  895. try:
  896. await _clear_stale_tag_links(
  897. sm_client, tag=req.tag_uid, keep_spool_id=req.spool_id, log_context="SpoolBuddy tag write"
  898. )
  899. except Exception:
  900. logger.exception(
  901. "Spoolman: unexpected error clearing duplicate tag %s before binding to spool %d (proceeding anyway)",
  902. req.tag_uid,
  903. req.spool_id,
  904. )
  905. # Spoolman 0.27+: the same tag as a native one. It was just written onto
  906. # this spool, so a previous holder gives it up here too. Linked before
  907. # extra.tag, as the step Spoolman can refuse; a failed extra.tag write
  908. # takes back what was added.
  909. uid = req.tag_uid.upper()
  910. added_native = False
  911. if await sm_client.has_tag_api():
  912. before = await sm_client.get_spool(req.spool_id)
  913. had = any(t.get("uid") == uid for t in before.get("tags") or [])
  914. # A spool that already holds the tag has nothing to link or move.
  915. if not had:
  916. holder = await sm_client.link_native_tag(req.spool_id, uid)
  917. if holder is not None and holder > 0:
  918. previous = holder
  919. await sm_client.unlink_native_tag(previous, uid)
  920. holder = await sm_client.link_native_tag(req.spool_id, uid)
  921. logger.info(
  922. "Spoolman: native tag %s moved from spool %d to %d", uid, previous, req.spool_id
  923. )
  924. if holder is not None:
  925. logger.warning("Native tag %s belongs to a filament or location, not linked", uid)
  926. added_native = holder is None
  927. try:
  928. await sm_client.merge_spool_extra(req.spool_id, {"tag": tag_value})
  929. except Exception:
  930. if added_native:
  931. try:
  932. await sm_client.unlink_native_tag(req.spool_id, uid)
  933. except (SpoolmanClientError, SpoolmanUnavailableError) as undo_exc:
  934. logger.warning(
  935. "Could not take back native tag %s from spool %d: %s", uid, req.spool_id, undo_exc
  936. )
  937. raise
  938. logger.info(
  939. "Spoolman tag written and linked: spool %d -> tag %s",
  940. req.spool_id,
  941. req.tag_uid,
  942. )
  943. _tag_link_ok = True
  944. except (SpoolmanNotFoundError, SpoolmanUnavailableError, SpoolmanClientError) as exc:
  945. logger.error(
  946. "Spoolman error during tag write-back for spool %d (type=%s, status=%s): %s",
  947. req.spool_id,
  948. type(exc).__name__,
  949. getattr(exc, "status_code", "N/A"),
  950. exc,
  951. )
  952. # fall through to broadcast + raise 502 below
  953. except Exception:
  954. logger.exception(
  955. "Unexpected error during Spoolman tag write-back for spool %d",
  956. req.spool_id,
  957. )
  958. # fall through to broadcast + raise 502 below
  959. await db.commit()
  960. # The tag moved between Spoolman spools, or the stale holders were
  961. # cleared before a link that then failed: either way pages open
  962. # elsewhere hold a tag -> spool map that changed (#3402).
  963. await ws_manager.broadcast({"type": "inventory_changed"})
  964. if _tag_link_ok:
  965. await ws_manager.broadcast(
  966. {
  967. "type": "spoolbuddy_tag_written",
  968. "device_id": req.device_id,
  969. "spool_id": req.spool_id,
  970. "tag_uid": req.tag_uid,
  971. }
  972. )
  973. else:
  974. await ws_manager.broadcast(
  975. {
  976. "type": "spoolbuddy_tag_link_failed",
  977. "device_id": req.device_id,
  978. "spool_id": req.spool_id,
  979. "tag_uid": req.tag_uid,
  980. # Generic message — full exception (may contain internal URLs/hostnames)
  981. # is logged server-side only to prevent information leakage via WebSocket.
  982. "message": "Spoolman link failed",
  983. "reason": "spoolman_link_failed",
  984. }
  985. )
  986. raise HTTPException(
  987. status_code=502,
  988. detail="Tag written to NFC but Spoolman link failed",
  989. )
  990. else:
  991. # Link the tag to the local DB spool
  992. from backend.app.models.spool import Spool
  993. result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
  994. spool = result.scalar_one_or_none()
  995. if spool is None:
  996. logger.warning(
  997. "NFC tag written for spool %d but it no longer exists in local DB; tag is orphaned",
  998. req.spool_id,
  999. )
  1000. await db.commit()
  1001. await ws_manager.broadcast(
  1002. {
  1003. "type": "spoolbuddy_tag_link_failed",
  1004. "device_id": req.device_id,
  1005. "spool_id": req.spool_id,
  1006. "message": "Spool not found",
  1007. "reason": "spool_not_found",
  1008. }
  1009. )
  1010. return {"status": "ok", "linked": False, "message": "Spool not found"}
  1011. spool.tag_uid = req.tag_uid.upper()
  1012. spool.tag_type = "ntag"
  1013. spool.data_origin = "opentag3d"
  1014. spool.encode_time = datetime.now(timezone.utc)
  1015. logger.info("Tag written and linked: spool %d -> tag %s", spool.id, req.tag_uid)
  1016. await db.commit()
  1017. await ws_manager.broadcast(
  1018. {
  1019. "type": "spoolbuddy_tag_written",
  1020. "device_id": req.device_id,
  1021. "spool_id": req.spool_id,
  1022. "tag_uid": req.tag_uid,
  1023. }
  1024. )
  1025. else:
  1026. await db.commit()
  1027. await ws_manager.broadcast(
  1028. {
  1029. "type": "spoolbuddy_tag_write_failed",
  1030. "device_id": req.device_id,
  1031. "spool_id": req.spool_id,
  1032. "message": req.message,
  1033. }
  1034. )
  1035. logger.warning("Tag write failed for device %s: %s", req.device_id, req.message)
  1036. return {"status": "ok"}
  1037. @router.post("/devices/{device_id}/cancel-write")
  1038. async def cancel_write(
  1039. device_id: str,
  1040. db: AsyncSession = Depends(get_db, scope="function"),
  1041. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1042. ):
  1043. """Cancel a pending write-tag command."""
  1044. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1045. device = result.scalar_one_or_none()
  1046. if not device:
  1047. raise HTTPException(status_code=404, detail="Device not registered")
  1048. if device.pending_command == "write_tag":
  1049. device.pending_command = None
  1050. device.pending_write_payload = None
  1051. await db.commit()
  1052. logger.info("Write tag cancelled for device %s", device_id)
  1053. return {"status": "ok"}
  1054. # --- Scale endpoints ---
  1055. @router.post("/scale/reading")
  1056. async def scale_reading(
  1057. req: ScaleReadingRequest,
  1058. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1059. ):
  1060. """RPi reports scale weight — broadcast to all clients."""
  1061. await ws_manager.broadcast(
  1062. {
  1063. "type": "spoolbuddy_weight",
  1064. "device_id": req.device_id,
  1065. "weight_grams": req.weight_grams,
  1066. "stable": req.stable,
  1067. "raw_adc": req.raw_adc,
  1068. }
  1069. )
  1070. return {"status": "ok"}
  1071. @router.post("/scale/update-spool-weight")
  1072. async def update_spool_weight(
  1073. req: UpdateSpoolWeightRequest,
  1074. db: AsyncSession = Depends(get_db, scope="function"),
  1075. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1076. ):
  1077. """Update spool's used weight from scale reading.
  1078. Routes the update to whichever inventory backend Bambuddy is configured
  1079. for: Spoolman exclusively when ``spoolman_enabled`` is true, local DB
  1080. exclusively otherwise. The previous implementation tried local first and
  1081. only consulted Spoolman on a local-DB miss, which meant a stale local row
  1082. sharing a numeric id with a Spoolman spool would silently absorb the
  1083. update while the Spoolman row the user is actually looking at stayed
  1084. unchanged (#1530). Mirrors the routing already used by ``nfc/tag-scanned``.
  1085. """
  1086. from backend.app.api.routes._spoolman_helpers import _safe_float, spoolman_net_weight, spoolman_tare
  1087. from backend.app.models.spool import Spool
  1088. sm_client = await _get_spoolman_client_or_none(db)
  1089. if sm_client is None and await _spoolman_mode(db):
  1090. raise HTTPException(status_code=503, detail="Spoolman is unavailable")
  1091. if sm_client is None:
  1092. # Local mode — exclusive update, no Spoolman fallback.
  1093. db_result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
  1094. spool = db_result.scalar_one_or_none()
  1095. if not spool:
  1096. raise HTTPException(status_code=404, detail="Spool not found")
  1097. net_filament = max(0, req.weight_grams - spool.core_weight)
  1098. spool.weight_used = max(0, spool.label_weight - net_filament)
  1099. spool.last_scale_weight = req.weight_grams
  1100. spool.last_weighed_at = datetime.now(timezone.utc)
  1101. # A measured figure, which the AMS remain% sync must not overwrite
  1102. # (as a hand-set one, see update_spool).
  1103. spool.weight_locked = True
  1104. await db.commit()
  1105. await ws_manager.broadcast({"type": "inventory_changed"})
  1106. logger.info(
  1107. "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
  1108. spool.id,
  1109. req.weight_grams,
  1110. spool.weight_used,
  1111. )
  1112. return {"status": "ok", "weight_used": spool.weight_used}
  1113. # Spoolman mode — exclusive update, never touch local DB.
  1114. async with _translate_spoolbuddy_errors():
  1115. sm_spool = await sm_client.get_spool(req.spool_id)
  1116. core_weight, tare_source = spoolman_tare(sm_spool)
  1117. spool_weight_warning: str | None = None
  1118. if tare_source == "fallback":
  1119. logger.warning(
  1120. "Spoolman spool %d has no spool_weight or vendor empty_spool_weight set; using 250g fallback for tare",
  1121. req.spool_id,
  1122. )
  1123. spool_weight_warning = (
  1124. "spool_weight_not_set: Spoolman spool, filament and vendor have no empty-spool weight configured; "
  1125. "weight estimate uses 250g fallback"
  1126. )
  1127. label_weight = _safe_float(spoolman_net_weight(sm_spool), 1000.0)
  1128. remaining_weight = max(0.0, req.weight_grams - core_weight)
  1129. async with _translate_spoolbuddy_errors():
  1130. await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight)
  1131. await ws_manager.broadcast({"type": "inventory_changed"})
  1132. weight_used = max(0.0, label_weight - remaining_weight)
  1133. logger.info(
  1134. "SpoolBuddy updated Spoolman spool %d: %.1fg on scale, core=%.1fg → %.1fg remaining",
  1135. req.spool_id,
  1136. req.weight_grams,
  1137. core_weight,
  1138. remaining_weight,
  1139. )
  1140. result: dict = {"status": "ok", "weight_used": weight_used}
  1141. if spool_weight_warning:
  1142. result["warnings"] = [spool_weight_warning]
  1143. return result
  1144. # --- Calibration endpoints ---
  1145. @router.post("/devices/{device_id}/calibration/tare")
  1146. async def tare_scale(
  1147. device_id: str,
  1148. db: AsyncSession = Depends(get_db, scope="function"),
  1149. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1150. ):
  1151. """Set pending tare command for the device to pick up."""
  1152. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1153. device = result.scalar_one_or_none()
  1154. if not device:
  1155. raise HTTPException(status_code=404, detail="Device not registered")
  1156. device.pending_command = "tare"
  1157. await db.commit()
  1158. return {"status": "ok", "message": "Tare command queued"}
  1159. @router.post("/devices/{device_id}/calibration/set-tare")
  1160. async def set_tare_offset(
  1161. device_id: str,
  1162. req: SetTareRequest,
  1163. db: AsyncSession = Depends(get_db, scope="function"),
  1164. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1165. ):
  1166. """Store tare offset reported by the daemon after executing a tare."""
  1167. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1168. device = result.scalar_one_or_none()
  1169. if not device:
  1170. raise HTTPException(status_code=404, detail="Device not registered")
  1171. device.tare_offset = req.tare_offset
  1172. device.last_calibrated_at = datetime.now(timezone.utc)
  1173. await db.commit()
  1174. logger.info("SpoolBuddy %s tare offset set to %d", device_id, req.tare_offset)
  1175. return CalibrationResponse(
  1176. tare_offset=device.tare_offset,
  1177. calibration_factor=device.calibration_factor,
  1178. )
  1179. @router.post("/devices/{device_id}/calibration/set-factor")
  1180. async def set_calibration_factor(
  1181. device_id: str,
  1182. req: SetCalibrationFactorRequest,
  1183. db: AsyncSession = Depends(get_db, scope="function"),
  1184. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1185. ):
  1186. """Calculate and store calibration factor from a known weight."""
  1187. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1188. device = result.scalar_one_or_none()
  1189. if not device:
  1190. raise HTTPException(status_code=404, detail="Device not registered")
  1191. tare = req.tare_raw_adc if req.tare_raw_adc is not None else device.tare_offset
  1192. raw_delta = req.raw_adc - tare
  1193. if raw_delta == 0:
  1194. raise HTTPException(status_code=400, detail="Raw ADC value equals tare offset — place weight on scale")
  1195. device.calibration_factor = req.known_weight_grams / raw_delta
  1196. if req.tare_raw_adc is not None:
  1197. device.tare_offset = tare
  1198. device.last_calibrated_at = datetime.now(timezone.utc)
  1199. await db.commit()
  1200. logger.info(
  1201. "SpoolBuddy %s calibration factor set to %.6f (known=%.1fg, raw=%d, tare=%d)",
  1202. device_id,
  1203. device.calibration_factor,
  1204. req.known_weight_grams,
  1205. req.raw_adc,
  1206. tare,
  1207. )
  1208. return CalibrationResponse(
  1209. tare_offset=device.tare_offset,
  1210. calibration_factor=device.calibration_factor,
  1211. )
  1212. @router.get("/devices/{device_id}/calibration", response_model=CalibrationResponse)
  1213. async def get_calibration(
  1214. device_id: str,
  1215. db: AsyncSession = Depends(get_db, scope="function"),
  1216. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1217. ):
  1218. """Get current calibration values for a device."""
  1219. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1220. device = result.scalar_one_or_none()
  1221. if not device:
  1222. raise HTTPException(status_code=404, detail="Device not registered")
  1223. return CalibrationResponse(
  1224. tare_offset=device.tare_offset,
  1225. calibration_factor=device.calibration_factor,
  1226. )
  1227. # --- Display settings ---
  1228. @router.get("/devices/{device_id}/display")
  1229. async def get_display_settings(
  1230. device_id: str,
  1231. db: AsyncSession = Depends(get_db, scope="function"),
  1232. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1233. ):
  1234. """Read current display brightness and screen blank timeout for a device.
  1235. Used by the SpoolBuddy kiosk idle watchdog on autostart to configure
  1236. swayidle with the same timeout the user picked in the UI, without having
  1237. to wait for the daemon heartbeat to arrive first.
  1238. """
  1239. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1240. device = result.scalar_one_or_none()
  1241. if not device:
  1242. raise HTTPException(status_code=404, detail="Device not registered")
  1243. return {
  1244. "brightness": device.display_brightness,
  1245. "blank_timeout": device.display_blank_timeout,
  1246. }
  1247. @router.put("/devices/{device_id}/display")
  1248. async def update_display_settings(
  1249. device_id: str,
  1250. req: DisplaySettingsRequest,
  1251. db: AsyncSession = Depends(get_db, scope="function"),
  1252. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1253. ):
  1254. """Update display brightness and screen blank timeout for a device."""
  1255. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1256. device = result.scalar_one_or_none()
  1257. if not device:
  1258. raise HTTPException(status_code=404, detail="Device not registered")
  1259. device.display_brightness = req.brightness
  1260. device.display_blank_timeout = req.blank_timeout
  1261. await db.commit()
  1262. logger.info(
  1263. "SpoolBuddy %s display updated: brightness=%d%%, blank_timeout=%ds",
  1264. device_id,
  1265. req.brightness,
  1266. req.blank_timeout,
  1267. )
  1268. return {"status": "ok", "brightness": req.brightness, "blank_timeout": req.blank_timeout}
  1269. @router.post("/devices/{device_id}/system/config")
  1270. async def queue_system_config_update(
  1271. device_id: str,
  1272. req: SystemConfigRequest,
  1273. db: AsyncSession = Depends(get_db, scope="function"),
  1274. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1275. ):
  1276. """Queue update of SpoolBuddy .env config on the device."""
  1277. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1278. device = result.scalar_one_or_none()
  1279. if not device:
  1280. raise HTTPException(status_code=404, detail="Device not registered")
  1281. parsed = urlparse(req.backend_url.strip())
  1282. if parsed.scheme not in ("http", "https") or not parsed.netloc:
  1283. raise HTTPException(
  1284. status_code=400,
  1285. detail="backend_url must be a full URL with scheme, e.g. http://192.168.1.100:5000 or http://bambuddy.local",
  1286. )
  1287. payload = {
  1288. "backend_url": req.backend_url.strip(),
  1289. }
  1290. if req.api_key is not None and req.api_key.strip():
  1291. payload["api_key"] = req.api_key.strip()
  1292. device.pending_system_payload = json.dumps(payload)
  1293. device.pending_command = "apply_system_config"
  1294. await db.commit()
  1295. logger.info("Queued system config update for device %s", device_id)
  1296. return {"status": "queued", "message": "System config update queued"}
  1297. VALID_SYSTEM_COMMANDS = {"reboot", "shutdown", "restart_daemon", "restart_browser"}
  1298. @router.post("/devices/{device_id}/system/command")
  1299. async def queue_system_command(
  1300. device_id: str,
  1301. req: SystemCommandRequest,
  1302. db: AsyncSession = Depends(get_db, scope="function"),
  1303. # Aligns with the rest of the kiosk-scoped device routes (calibration,
  1304. # display, cancel-write, command-result — all INVENTORY_UPDATE). The
  1305. # previous SETTINGS_UPDATE gate locked operators out of the QuickMenu's
  1306. # Restart-Daemon / Restart-Browser / Reboot / Shutdown buttons even
  1307. # though they had access to every other operation on the same device.
  1308. # Reboot and shutdown remain recoverable via physical access — the
  1309. # operator already has the kiosk in front of them.
  1310. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1311. ):
  1312. """Queue a system command (reboot, shutdown, restart_daemon, restart_browser) for the SpoolBuddy device."""
  1313. if req.command not in VALID_SYSTEM_COMMANDS:
  1314. raise HTTPException(
  1315. status_code=400,
  1316. detail=f"Invalid command. Must be one of: {', '.join(sorted(VALID_SYSTEM_COMMANDS))}",
  1317. )
  1318. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1319. device = result.scalar_one_or_none()
  1320. if not device:
  1321. raise HTTPException(status_code=404, detail="Device not registered")
  1322. if not _is_online(device):
  1323. raise HTTPException(status_code=409, detail="Device is offline")
  1324. device.pending_command = req.command
  1325. await db.commit()
  1326. logger.info("System command queued for device %s: %s", device_id, req.command)
  1327. return {"status": "queued", "command": req.command}
  1328. @router.post("/devices/{device_id}/system/command-result")
  1329. async def system_command_result(
  1330. device_id: str,
  1331. req: SystemCommandResultRequest,
  1332. db: AsyncSession = Depends(get_db, scope="function"),
  1333. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1334. ):
  1335. """Receive completion status for queued system command from daemon."""
  1336. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1337. device = result.scalar_one_or_none()
  1338. if not device:
  1339. raise HTTPException(status_code=404, detail="Device not registered")
  1340. if not device.pending_command:
  1341. logger.info("System command result from %s with no pending command: %s", device_id, req.command)
  1342. return {"status": "ok", "message": "No pending command"}
  1343. if req.command != device.pending_command:
  1344. raise HTTPException(
  1345. status_code=409,
  1346. detail=f"Command mismatch: pending '{device.pending_command}', got '{req.command}'",
  1347. )
  1348. if req.command == "apply_system_config":
  1349. device.pending_system_payload = None
  1350. device.pending_command = None
  1351. await db.commit()
  1352. logger.info(
  1353. "System command result from %s: %s success=%s message=%s",
  1354. device_id,
  1355. req.command,
  1356. req.success,
  1357. req.message,
  1358. )
  1359. return {"status": "ok"}
  1360. # --- Diagnostics ---
  1361. @router.post("/diagnostics/{device_id}/run")
  1362. async def queue_diagnostic(
  1363. device_id: str,
  1364. diagnostic: str,
  1365. db: AsyncSession = Depends(get_db, scope="function"),
  1366. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1367. ):
  1368. """Queue a hardware diagnostic to run on the SpoolBuddy device.
  1369. Args:
  1370. device_id: The device ID
  1371. diagnostic: 'scale' or 'nfc' to select which diagnostic to run
  1372. Returns:
  1373. Status message indicating diagnostic was queued
  1374. """
  1375. if diagnostic not in ("scale", "nfc", "read_tag"):
  1376. raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
  1377. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1378. device = result.scalar_one_or_none()
  1379. if not device:
  1380. raise HTTPException(status_code=404, detail="Device not registered")
  1381. device.pending_command = f"run_{diagnostic}_diag"
  1382. _diagnostic_results.pop((device_id, diagnostic), None)
  1383. await db.commit()
  1384. logger.info("Diagnostic queued for device %s: %s", device_id, diagnostic)
  1385. return {"status": "queued", "diagnostic": diagnostic, "message": f"Diagnostic '{diagnostic}' queued for device"}
  1386. @router.get("/diagnostics/{device_id}/result")
  1387. async def get_diagnostic_result(
  1388. device_id: str,
  1389. diagnostic: str,
  1390. db: AsyncSession = Depends(get_db, scope="function"),
  1391. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1392. ):
  1393. """Get the latest diagnostic result for a device.
  1394. Args:
  1395. device_id: The device ID
  1396. diagnostic: 'scale' or 'nfc'
  1397. Returns:
  1398. Diagnostic result or 404 if not found
  1399. """
  1400. if diagnostic not in ("scale", "nfc", "read_tag"):
  1401. raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
  1402. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1403. device = result.scalar_one_or_none()
  1404. if not device:
  1405. raise HTTPException(status_code=404, detail="Device not registered")
  1406. diag_result = _diagnostic_results.get((device_id, diagnostic))
  1407. if not diag_result:
  1408. raise HTTPException(status_code=404, detail=f"No {diagnostic} diagnostic results available yet")
  1409. return diag_result
  1410. @router.post("/diagnostics/{device_id}/result")
  1411. async def report_diagnostic_result(
  1412. device_id: str,
  1413. req: DiagnosticResultRequest,
  1414. db: AsyncSession = Depends(get_db, scope="function"),
  1415. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1416. ):
  1417. """Report diagnostic result from SpoolBuddy device."""
  1418. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1419. device = result.scalar_one_or_none()
  1420. if not device:
  1421. raise HTTPException(status_code=404, detail="Device not registered")
  1422. if req.diagnostic not in ("nfc", "scale", "read_tag"):
  1423. raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
  1424. _diagnostic_results[(device_id, req.diagnostic)] = {
  1425. "diagnostic": req.diagnostic,
  1426. "success": req.success,
  1427. "output": req.output,
  1428. "exit_code": req.exit_code,
  1429. }
  1430. device.pending_command = None
  1431. await db.commit()
  1432. logger.info("Diagnostic result received for device %s: %s (success=%s)", device_id, req.diagnostic, req.success)
  1433. return {"status": "ok", "message": "Diagnostic result recorded"}
  1434. # --- Update check ---
  1435. @router.get("/devices/{device_id}/update-check")
  1436. async def check_daemon_update(
  1437. device_id: str,
  1438. db: AsyncSession = Depends(get_db, scope="function"),
  1439. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1440. ):
  1441. """Check if the SpoolBuddy daemon needs updating to match the Bambuddy backend version."""
  1442. from backend.app.api.routes.updates import is_newer_version
  1443. from backend.app.core.config import APP_VERSION
  1444. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1445. device = result.scalar_one_or_none()
  1446. if not device:
  1447. raise HTTPException(status_code=404, detail="Device not registered")
  1448. current = device.firmware_version or "0.0.0"
  1449. return {
  1450. "current_version": current,
  1451. "latest_version": APP_VERSION,
  1452. "update_available": is_newer_version(APP_VERSION, current),
  1453. }
  1454. @router.post("/devices/{device_id}/update")
  1455. async def trigger_daemon_update(
  1456. device_id: str,
  1457. req: dict | None = None,
  1458. db: AsyncSession = Depends(get_db, scope="function"),
  1459. # Aligns with the rest of the kiosk-scoped device routes (calibration,
  1460. # display, cancel-write, system/command — all INVENTORY_UPDATE).
  1461. # SETTINGS_UPDATE is on the API-key deny-list, which blocks the Update
  1462. # button from the kiosk's own Settings page even when the operator has
  1463. # physical access. Update only acts on the device the operator already
  1464. # controls (git fetch + pip install + systemctl restart on that one
  1465. # host) — same blast radius as the restart_daemon command.
  1466. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1467. ):
  1468. """Trigger a SpoolBuddy update over SSH.
  1469. Bambuddy SSHes into the device, pulls the matching branch, installs deps,
  1470. and restarts the daemon. Progress is broadcast via WebSocket.
  1471. """
  1472. from backend.app.services.spoolbuddy_ssh import perform_ssh_update
  1473. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1474. device = result.scalar_one_or_none()
  1475. if not device:
  1476. raise HTTPException(status_code=404, detail="Device not registered")
  1477. if not _is_online(device):
  1478. raise HTTPException(status_code=409, detail="Device is offline")
  1479. if device.update_status == "updating":
  1480. return {"status": "already_updating", "message": "Update already in progress"}
  1481. device.update_status = "pending"
  1482. device.update_message = "Starting SSH update..."
  1483. await db.commit()
  1484. logger.info("SpoolBuddy %s: SSH update triggered (ip=%s)", device_id, device.ip_address)
  1485. await ws_manager.broadcast(
  1486. {
  1487. "type": "spoolbuddy_update",
  1488. "device_id": device_id,
  1489. "update_status": "pending",
  1490. }
  1491. )
  1492. # Run the SSH update in the background — hold reference to prevent GC cancellation
  1493. _ssh_update_task = asyncio.create_task(perform_ssh_update(device_id, device.ip_address))
  1494. _ssh_update_task.add_done_callback(
  1495. lambda t: (
  1496. logger.error(
  1497. "SSH update task for device %s ended unexpectedly (cancelled=%s)",
  1498. device_id,
  1499. t.cancelled(),
  1500. )
  1501. if (t.cancelled() or t.exception() is not None)
  1502. else None
  1503. )
  1504. )
  1505. return {"status": "ok", "message": "SSH update started"}
  1506. @router.get("/ssh/public-key")
  1507. async def get_ssh_public_key(
  1508. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  1509. ):
  1510. """Return the SSH public key for SpoolBuddy pairing."""
  1511. from backend.app.services.spoolbuddy_ssh import get_public_key
  1512. try:
  1513. key = await get_public_key()
  1514. return {"public_key": key}
  1515. except Exception as e:
  1516. logger.error("Failed to get SSH public key: %s", e)
  1517. raise HTTPException(status_code=500, detail="Failed to retrieve SSH public key") from e
  1518. # --- Background watchdog ---
  1519. async def spoolbuddy_watchdog():
  1520. """Check for devices that have gone offline (no heartbeat for 30s).
  1521. Called periodically from the main app's background task loop.
  1522. """
  1523. from backend.app.core.database import async_session
  1524. async with async_session() as db:
  1525. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.last_seen.isnot(None)))
  1526. devices = list(result.scalars().all())
  1527. threshold = datetime.now(timezone.utc) - timedelta(seconds=OFFLINE_THRESHOLD_SECONDS)
  1528. for device in devices:
  1529. last_seen = device.last_seen.replace(tzinfo=timezone.utc) if device.last_seen else None
  1530. if last_seen and last_seen < threshold:
  1531. # Only broadcast once — clear last_seen after marking offline
  1532. await ws_manager.broadcast(
  1533. {
  1534. "type": "spoolbuddy_offline",
  1535. "device_id": device.device_id,
  1536. }
  1537. )
  1538. device.last_seen = None
  1539. logger.info("SpoolBuddy device offline: %s", device.device_id)
  1540. await db.commit()