spoolbuddy.py 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326
  1. """SpoolBuddy device management API routes."""
  2. import asyncio
  3. import json
  4. import logging
  5. import time
  6. from datetime import datetime, timedelta, timezone
  7. from urllib.parse import urlparse
  8. import httpx
  9. from fastapi import APIRouter, Depends, HTTPException
  10. from sqlalchemy import select
  11. from sqlalchemy.ext.asyncio import AsyncSession
  12. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  13. from backend.app.core.database import get_db
  14. from backend.app.core.permissions import Permission
  15. from backend.app.core.websocket import ws_manager
  16. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  17. from backend.app.models.user import User
  18. from backend.app.schemas.spoolbuddy import (
  19. CalibrationResponse,
  20. DeviceRegisterRequest,
  21. DeviceResponse,
  22. DiagnosticResultRequest,
  23. DisplaySettingsRequest,
  24. HeartbeatRequest,
  25. HeartbeatResponse,
  26. ScaleReadingRequest,
  27. SetCalibrationFactorRequest,
  28. SetTareRequest,
  29. SystemCommandRequest,
  30. SystemCommandResultRequest,
  31. SystemConfigRequest,
  32. TagRemovedRequest,
  33. TagScannedRequest,
  34. UpdateSpoolWeightRequest,
  35. UpdateStatusRequest,
  36. WriteTagRequest,
  37. WriteTagResultRequest,
  38. )
  39. from backend.app.services.spool_tag_matcher import get_spool_by_tag
  40. from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError
  41. logger = logging.getLogger(__name__)
  42. router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
  43. OFFLINE_THRESHOLD_SECONDS = 30
  44. ONLINE_BROADCAST_INTERVAL_SECONDS = 10
  45. _spoolbuddy_online_last_broadcast: dict[str, float] = {}
  46. _diagnostic_results: dict[tuple[str, str], dict] = {}
  47. async def _get_spoolman_client_or_none(db: AsyncSession):
  48. """Return a SpoolmanClient if Spoolman is enabled with a safe URL, else None."""
  49. from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
  50. from backend.app.models.settings import Settings
  51. from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client
  52. settings_result = await db.execute(select(Settings))
  53. settings_dict = {s.key: s.value for s in settings_result.scalars().all()}
  54. spoolman_url = settings_dict.get("spoolman_url", "").strip()
  55. spoolman_enabled = settings_dict.get("spoolman_enabled", "false").lower() == "true" and bool(spoolman_url)
  56. if not spoolman_enabled:
  57. return None
  58. # SSRF guard: reject dangerous schemes and private/loopback/link-local/multicast IPs.
  59. try:
  60. assert_safe_spoolman_url(spoolman_url)
  61. except ValueError as exc:
  62. logger.warning(
  63. "Spoolman integration disabled: URL %r rejected by SSRF guard: %s",
  64. spoolman_url,
  65. exc,
  66. )
  67. return None
  68. client = await get_spoolman_client()
  69. if not client or client.base_url != spoolman_url.rstrip("/"):
  70. try:
  71. client = await init_spoolman_client(spoolman_url)
  72. except ValueError as exc:
  73. logger.warning(
  74. "Spoolman integration disabled: URL %r rejected on re-initialisation: %s",
  75. spoolman_url,
  76. exc,
  77. )
  78. return None
  79. return client
  80. def _is_online(device: SpoolBuddyDevice) -> bool:
  81. if not device.last_seen:
  82. return False
  83. return (
  84. datetime.now(timezone.utc) - device.last_seen.replace(tzinfo=timezone.utc)
  85. ).total_seconds() < OFFLINE_THRESHOLD_SECONDS
  86. def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
  87. return DeviceResponse(
  88. id=device.id,
  89. device_id=device.device_id,
  90. hostname=device.hostname,
  91. ip_address=device.ip_address,
  92. firmware_version=device.firmware_version,
  93. has_nfc=device.has_nfc,
  94. has_scale=device.has_scale,
  95. tare_offset=device.tare_offset,
  96. calibration_factor=device.calibration_factor,
  97. nfc_reader_type=device.nfc_reader_type,
  98. nfc_connection=device.nfc_connection,
  99. backend_url=device.backend_url,
  100. display_brightness=device.display_brightness,
  101. display_blank_timeout=device.display_blank_timeout,
  102. has_backlight=device.has_backlight,
  103. last_calibrated_at=device.last_calibrated_at,
  104. last_seen=device.last_seen,
  105. pending_command=device.pending_command,
  106. nfc_ok=device.nfc_ok,
  107. scale_ok=device.scale_ok,
  108. uptime_s=device.uptime_s,
  109. update_status=device.update_status,
  110. update_message=device.update_message,
  111. system_stats=json.loads(device.system_stats) if device.system_stats else None,
  112. online=_is_online(device),
  113. created_at=device.created_at,
  114. updated_at=device.updated_at,
  115. )
  116. def _should_broadcast_online(device_id: str, force: bool = False) -> bool:
  117. if force:
  118. _spoolbuddy_online_last_broadcast[device_id] = time.time()
  119. return True
  120. now_ts = time.time()
  121. last_ts = _spoolbuddy_online_last_broadcast.get(device_id, 0.0)
  122. if now_ts - last_ts >= ONLINE_BROADCAST_INTERVAL_SECONDS:
  123. _spoolbuddy_online_last_broadcast[device_id] = now_ts
  124. return True
  125. return False
  126. # --- Device endpoints ---
  127. @router.post("/devices/register", response_model=DeviceResponse)
  128. async def register_device(
  129. req: DeviceRegisterRequest,
  130. db: AsyncSession = Depends(get_db),
  131. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  132. ):
  133. """Register or re-register a SpoolBuddy device."""
  134. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
  135. device = result.scalar_one_or_none()
  136. now = datetime.now(timezone.utc)
  137. if device:
  138. device.hostname = req.hostname
  139. device.ip_address = req.ip_address
  140. device.firmware_version = req.firmware_version
  141. device.has_nfc = req.has_nfc
  142. device.has_scale = req.has_scale
  143. device.nfc_reader_type = req.nfc_reader_type
  144. device.nfc_connection = req.nfc_connection
  145. if req.backend_url:
  146. device.backend_url = req.backend_url
  147. device.has_backlight = req.has_backlight
  148. device.last_seen = now
  149. # Clear stale update status on re-registration (daemon restarted after update)
  150. if device.update_status in ("pending", "updating", "complete", "error"):
  151. device.update_status = None
  152. device.update_message = None
  153. logger.info("SpoolBuddy device re-registered: %s (%s)", req.device_id, req.hostname)
  154. else:
  155. device = SpoolBuddyDevice(
  156. device_id=req.device_id,
  157. hostname=req.hostname,
  158. ip_address=req.ip_address,
  159. firmware_version=req.firmware_version,
  160. has_nfc=req.has_nfc,
  161. has_scale=req.has_scale,
  162. tare_offset=req.tare_offset,
  163. calibration_factor=req.calibration_factor,
  164. nfc_reader_type=req.nfc_reader_type,
  165. nfc_connection=req.nfc_connection,
  166. has_backlight=req.has_backlight,
  167. backend_url=req.backend_url,
  168. last_seen=now,
  169. )
  170. db.add(device)
  171. logger.info("SpoolBuddy device registered: %s (%s)", req.device_id, req.hostname)
  172. await db.commit()
  173. await db.refresh(device)
  174. _spoolbuddy_online_last_broadcast[device.device_id] = time.time()
  175. await ws_manager.broadcast(
  176. {
  177. "type": "spoolbuddy_online",
  178. "device_id": device.device_id,
  179. "hostname": device.hostname,
  180. }
  181. )
  182. response = _device_to_response(device)
  183. # Include SSH public key so the daemon can auto-deploy it
  184. try:
  185. from backend.app.services.spoolbuddy_ssh import get_public_key
  186. response.ssh_public_key = await get_public_key()
  187. except Exception as exc:
  188. logger.warning("Could not attach SSH public key to heartbeat response: %s", exc)
  189. return response
  190. @router.get("/devices", response_model=list[DeviceResponse])
  191. async def list_devices(
  192. db: AsyncSession = Depends(get_db),
  193. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  194. ):
  195. """List all registered SpoolBuddy devices."""
  196. result = await db.execute(select(SpoolBuddyDevice).order_by(SpoolBuddyDevice.hostname))
  197. devices = list(result.scalars().all())
  198. return [_device_to_response(d) for d in devices]
  199. @router.delete("/devices/{device_id}")
  200. async def unregister_device(
  201. device_id: str,
  202. db: AsyncSession = Depends(get_db),
  203. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_DELETE),
  204. ):
  205. """Unregister a SpoolBuddy device. The daemon can re-register via heartbeat later."""
  206. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  207. device = result.scalar_one_or_none()
  208. if not device:
  209. raise HTTPException(status_code=404, detail="Device not registered")
  210. await db.delete(device)
  211. await db.commit()
  212. _spoolbuddy_online_last_broadcast.pop(device_id, None)
  213. logger.info("SpoolBuddy device unregistered: %s (%s)", device_id, device.hostname)
  214. await ws_manager.broadcast({"type": "spoolbuddy_unregistered", "device_id": device_id})
  215. return {"status": "deleted", "device_id": device_id}
  216. @router.post("/devices/{device_id}/heartbeat", response_model=HeartbeatResponse)
  217. async def device_heartbeat(
  218. device_id: str,
  219. req: HeartbeatRequest,
  220. db: AsyncSession = Depends(get_db),
  221. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  222. ):
  223. """Daemon heartbeat — updates status and returns pending commands."""
  224. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  225. device = result.scalar_one_or_none()
  226. if not device:
  227. raise HTTPException(status_code=404, detail="Device not registered")
  228. was_offline = not _is_online(device)
  229. now = datetime.now(timezone.utc)
  230. device.last_seen = now
  231. device.nfc_ok = req.nfc_ok
  232. device.scale_ok = req.scale_ok
  233. device.uptime_s = req.uptime_s
  234. if req.firmware_version:
  235. device.firmware_version = req.firmware_version
  236. if req.ip_address:
  237. device.ip_address = req.ip_address
  238. if req.nfc_reader_type:
  239. device.nfc_reader_type = req.nfc_reader_type
  240. if req.nfc_connection:
  241. device.nfc_connection = req.nfc_connection
  242. if req.backend_url:
  243. device.backend_url = req.backend_url
  244. if req.system_stats is not None:
  245. device.system_stats = json.dumps(req.system_stats)
  246. # Return and clear pending command
  247. pending = device.pending_command
  248. pending_write = None
  249. pending_system = None
  250. if pending == "write_tag" and device.pending_write_payload:
  251. # Parse the stored JSON payload to include in response
  252. try:
  253. pending_write = json.loads(device.pending_write_payload)
  254. except (json.JSONDecodeError, TypeError):
  255. pending_write = None
  256. # Don't clear write_tag command — it gets cleared by write-result
  257. elif pending == "apply_system_config" and device.pending_system_payload:
  258. try:
  259. pending_system = json.loads(device.pending_system_payload)
  260. except (json.JSONDecodeError, TypeError):
  261. pending_system = None
  262. # Don't clear config command — it gets cleared by daemon command-result callback
  263. elif pending and pending.startswith("run_") and pending.endswith("_diag"):
  264. # Don't clear diagnostic commands — they get cleared by the device reporting results
  265. pass
  266. else:
  267. device.pending_command = None
  268. await db.commit()
  269. # Emit online presence on offline->online transitions immediately, and
  270. # periodically while online so newly connected UIs can bootstrap state.
  271. if _should_broadcast_online(device.device_id, force=was_offline):
  272. await ws_manager.broadcast(
  273. {
  274. "type": "spoolbuddy_online",
  275. "device_id": device.device_id,
  276. "hostname": device.hostname,
  277. }
  278. )
  279. if was_offline:
  280. logger.info("SpoolBuddy device back online: %s", device.device_id)
  281. return HeartbeatResponse(
  282. pending_command=pending,
  283. pending_write_payload=pending_write,
  284. pending_system_payload=pending_system,
  285. tare_offset=device.tare_offset,
  286. calibration_factor=device.calibration_factor,
  287. display_brightness=device.display_brightness,
  288. display_blank_timeout=device.display_blank_timeout,
  289. )
  290. # --- NFC endpoints ---
  291. @router.post("/nfc/tag-scanned")
  292. async def nfc_tag_scanned(
  293. req: TagScannedRequest,
  294. db: AsyncSession = Depends(get_db),
  295. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  296. ):
  297. """RPi reports NFC tag detected — lookup spool and broadcast."""
  298. spool = await get_spool_by_tag(db, req.tag_uid, req.tray_uuid or "")
  299. if spool:
  300. await ws_manager.broadcast(
  301. {
  302. "type": "spoolbuddy_tag_matched",
  303. "device_id": req.device_id,
  304. "tag_uid": req.tag_uid,
  305. "spool": {
  306. "id": spool.id,
  307. "material": spool.material,
  308. "subtype": spool.subtype,
  309. "color_name": spool.color_name,
  310. "rgba": spool.rgba,
  311. "brand": spool.brand,
  312. "label_weight": spool.label_weight,
  313. "core_weight": spool.core_weight,
  314. "weight_used": spool.weight_used,
  315. },
  316. }
  317. )
  318. logger.info("SpoolBuddy tag matched (local): %s -> spool %d", req.tag_uid, spool.id)
  319. return {"status": "ok", "matched": True, "spool_id": spool.id}
  320. # Local DB miss — fall back to Spoolman when enabled
  321. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
  322. client = await _get_spoolman_client_or_none(db)
  323. if client is not None:
  324. try:
  325. cached_spools = await client.get_spools()
  326. sm_spool: dict | None = None
  327. if req.tray_uuid:
  328. sm_spool = await client.find_spool_by_tag(req.tray_uuid, cached_spools=cached_spools)
  329. if sm_spool is None and req.tag_uid:
  330. sm_spool = await client.find_spool_by_tag(req.tag_uid, cached_spools=cached_spools)
  331. if sm_spool is not None:
  332. mapped = _map_spoolman_spool(sm_spool)
  333. await ws_manager.broadcast(
  334. {
  335. "type": "spoolbuddy_tag_matched",
  336. "device_id": req.device_id,
  337. "tag_uid": req.tag_uid,
  338. "spool": {
  339. "id": mapped["id"],
  340. "material": mapped["material"],
  341. "subtype": mapped["subtype"],
  342. "color_name": mapped["color_name"],
  343. "rgba": mapped["rgba"],
  344. "brand": mapped["brand"],
  345. "label_weight": mapped["label_weight"],
  346. "core_weight": mapped["core_weight"],
  347. "weight_used": mapped["weight_used"],
  348. },
  349. }
  350. )
  351. logger.info("SpoolBuddy tag matched (Spoolman): %s -> spool %d", req.tag_uid, mapped["id"])
  352. return {"status": "ok", "matched": True, "spool_id": mapped["id"]}
  353. except ValueError as exc:
  354. logger.error(
  355. "Spoolman returned malformed spool data during tag lookup for %s: %s",
  356. req.tag_uid,
  357. exc,
  358. )
  359. return {"status": "ok", "matched": False, "spool_id": None}
  360. except (httpx.RequestError, httpx.HTTPStatusError, SpoolmanUnavailableError):
  361. logger.warning(
  362. "Spoolman unreachable during tag lookup for %s",
  363. req.tag_uid,
  364. )
  365. # Degrade gracefully on any Spoolman connectivity failure — device must not receive 500.
  366. # Also suppresses unknown_tag broadcast: the UI cannot distinguish a Spoolman outage
  367. # from "spool not registered", which would trigger duplicate-registration flows.
  368. return {"status": "ok", "matched": False, "spool_id": None}
  369. except Exception as exc:
  370. logger.error(
  371. "Spoolman tag lookup failed unexpectedly for %s: %s",
  372. req.tag_uid,
  373. exc,
  374. )
  375. # Same silent-return policy: an unexpected error must not break device operation
  376. # or trigger spurious duplicate-registration flows in the UI.
  377. return {"status": "ok", "matched": False, "spool_id": None}
  378. await ws_manager.broadcast(
  379. {
  380. "type": "spoolbuddy_unknown_tag",
  381. "device_id": req.device_id,
  382. "tag_uid": req.tag_uid,
  383. "sak": req.sak,
  384. "tag_type": req.tag_type,
  385. }
  386. )
  387. logger.info(
  388. "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
  389. req.tag_uid,
  390. len(req.tag_uid or ""),
  391. req.tray_uuid,
  392. len(req.tray_uuid or ""),
  393. req.tag_type,
  394. req.sak,
  395. )
  396. return {"status": "ok", "matched": False, "spool_id": None}
  397. @router.post("/nfc/tag-removed")
  398. async def nfc_tag_removed(
  399. req: TagRemovedRequest,
  400. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  401. ):
  402. """RPi reports NFC tag removed — broadcast event."""
  403. await ws_manager.broadcast(
  404. {
  405. "type": "spoolbuddy_tag_removed",
  406. "device_id": req.device_id,
  407. "tag_uid": req.tag_uid,
  408. }
  409. )
  410. return {"status": "ok"}
  411. @router.post("/nfc/write-tag")
  412. async def nfc_write_tag(
  413. req: WriteTagRequest,
  414. db: AsyncSession = Depends(get_db),
  415. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  416. ):
  417. """Queue an NFC tag write command for a SpoolBuddy device."""
  418. from backend.app.models.spool import Spool
  419. from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped
  420. # Find the device first (required regardless of spool source)
  421. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
  422. device = result.scalar_one_or_none()
  423. if not device:
  424. raise HTTPException(status_code=404, detail="Device not registered")
  425. # Try local DB first
  426. result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
  427. spool = result.scalar_one_or_none()
  428. nfc_warnings: list[str] = []
  429. if spool:
  430. ndef_data = encode_opentag3d(spool)
  431. data_origin = "local"
  432. else:
  433. # Local DB miss — fall back to Spoolman when enabled
  434. from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool
  435. sm_client = await _get_spoolman_client_or_none(db)
  436. if sm_client is None:
  437. raise HTTPException(status_code=404, detail="Spool not found")
  438. try:
  439. sm_spool = await sm_client.get_spool(req.spool_id)
  440. except SpoolmanNotFoundError:
  441. raise HTTPException(status_code=404, detail="Spool not found")
  442. except SpoolmanUnavailableError:
  443. raise HTTPException(status_code=503, detail="Spoolman server is not reachable")
  444. try:
  445. mapped = _map_spoolman_spool(sm_spool)
  446. except ValueError as exc:
  447. logger.warning("Spoolman returned invalid spool for write-tag: %s", exc)
  448. raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data")
  449. if not mapped.get("material"):
  450. raise HTTPException(
  451. status_code=400,
  452. detail="Spoolman spool has no material set — cannot encode NFC tag",
  453. )
  454. ndef_data = encode_opentag3d_from_mapped(mapped)
  455. data_origin = "spoolman"
  456. # Warn when fields that drive NFC content are absent in Spoolman.
  457. if not mapped.get("color_name"):
  458. nfc_warnings.append("color_name not set in Spoolman — tag encodes empty color name")
  459. if not mapped.get("nozzle_temp_min"):
  460. nfc_warnings.append("nozzle_temp_min not set in Spoolman — tag encodes 0 °C")
  461. if not mapped.get("subtype"):
  462. nfc_warnings.append("subtype not set in Spoolman — tag encodes empty subtype")
  463. if not mapped.get("brand"):
  464. nfc_warnings.append("brand/vendor not set in Spoolman — tag encodes empty brand")
  465. if not mapped.get("rgba"):
  466. nfc_warnings.append("rgba not set in Spoolman — tag encodes default colour")
  467. if not mapped.get("label_weight"):
  468. nfc_warnings.append("label_weight not set in Spoolman — tag encodes 0 g")
  469. if nfc_warnings:
  470. logger.warning(
  471. "NFC encode for Spoolman spool %d has incomplete data: %s",
  472. req.spool_id,
  473. "; ".join(nfc_warnings),
  474. )
  475. # Store write payload and set pending command
  476. device.pending_write_payload = json.dumps(
  477. {
  478. "spool_id": req.spool_id,
  479. "ndef_data_hex": ndef_data.hex(),
  480. "data_origin": data_origin,
  481. }
  482. )
  483. device.pending_command = "write_tag"
  484. await db.commit()
  485. logger.info(
  486. "Write tag queued for device %s, spool %d (%s, %d bytes)",
  487. req.device_id,
  488. req.spool_id,
  489. data_origin,
  490. len(ndef_data),
  491. )
  492. result: dict = {"status": "queued"}
  493. if nfc_warnings:
  494. result["warnings"] = nfc_warnings
  495. return result
  496. @router.post("/nfc/write-result")
  497. async def nfc_write_result(
  498. req: WriteTagResultRequest,
  499. db: AsyncSession = Depends(get_db),
  500. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  501. ):
  502. """Handle NFC tag write result from SpoolBuddy daemon."""
  503. # Find the device
  504. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
  505. device = result.scalar_one_or_none()
  506. if not device:
  507. raise HTTPException(status_code=404, detail="Device not registered")
  508. # Capture data_origin before clearing the payload
  509. try:
  510. payload_dict = json.loads(device.pending_write_payload or "{}")
  511. except (json.JSONDecodeError, TypeError):
  512. payload_dict = {}
  513. logger.warning("Malformed pending_write_payload for device %s — treating as local", req.device_id)
  514. data_origin = payload_dict.get("data_origin", "local")
  515. device.pending_command = None
  516. device.pending_write_payload = None
  517. if req.success:
  518. if data_origin == "spoolman":
  519. # Update Spoolman extra.tag with the written NFC UID using a safe merge
  520. # (fetches current extra first to avoid overwriting other custom fields).
  521. sm_client = await _get_spoolman_client_or_none(db)
  522. if sm_client is None:
  523. logger.warning("Spoolman not configured; cannot persist tag link for spool %d", req.spool_id)
  524. await db.commit()
  525. await ws_manager.broadcast(
  526. {
  527. "type": "spoolbuddy_tag_link_failed",
  528. "device_id": req.device_id,
  529. "spool_id": req.spool_id,
  530. "tag_uid": req.tag_uid,
  531. "message": "Spoolman not configured",
  532. }
  533. )
  534. raise HTTPException(
  535. status_code=502,
  536. detail="Tag written to NFC but Spoolman is not configured; link not persisted",
  537. )
  538. _tag_link_ok = False
  539. try:
  540. tag_value = json.dumps(req.tag_uid.upper())
  541. await sm_client.merge_spool_extra(req.spool_id, {"tag": tag_value})
  542. logger.info(
  543. "Spoolman tag written and linked: spool %d -> tag %s",
  544. req.spool_id,
  545. req.tag_uid,
  546. )
  547. _tag_link_ok = True
  548. except SpoolmanNotFoundError:
  549. logger.error(
  550. "Spoolman spool %d deleted before tag write-back could be persisted",
  551. req.spool_id,
  552. )
  553. # fall through to broadcast + raise 502 below
  554. except SpoolmanUnavailableError:
  555. logger.error(
  556. "Spoolman unreachable during tag write-back for spool %d",
  557. req.spool_id,
  558. )
  559. # fall through to broadcast + raise 502 below
  560. except Exception:
  561. logger.exception(
  562. "Unexpected error during Spoolman tag write-back for spool %d",
  563. req.spool_id,
  564. )
  565. # fall through to broadcast + raise 502 below
  566. await db.commit()
  567. if _tag_link_ok:
  568. await ws_manager.broadcast(
  569. {
  570. "type": "spoolbuddy_tag_written",
  571. "device_id": req.device_id,
  572. "spool_id": req.spool_id,
  573. "tag_uid": req.tag_uid,
  574. }
  575. )
  576. else:
  577. await ws_manager.broadcast(
  578. {
  579. "type": "spoolbuddy_tag_link_failed",
  580. "device_id": req.device_id,
  581. "spool_id": req.spool_id,
  582. "tag_uid": req.tag_uid,
  583. # Generic message — full exception (may contain internal URLs/hostnames)
  584. # is logged server-side only to prevent information leakage via WebSocket.
  585. "message": "Spoolman link failed",
  586. }
  587. )
  588. raise HTTPException(
  589. status_code=502,
  590. detail="Tag written to NFC but Spoolman link failed",
  591. )
  592. else:
  593. # Link the tag to the local DB spool
  594. from backend.app.models.spool import Spool
  595. result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
  596. spool = result.scalar_one_or_none()
  597. if spool is None:
  598. logger.warning(
  599. "NFC tag written for spool %d but it no longer exists in local DB; tag is orphaned",
  600. req.spool_id,
  601. )
  602. await db.commit()
  603. await ws_manager.broadcast(
  604. {
  605. "type": "spoolbuddy_tag_link_failed",
  606. "device_id": req.device_id,
  607. "spool_id": req.spool_id,
  608. "message": "Spool not found",
  609. }
  610. )
  611. return {"status": "ok", "linked": False, "message": "Spool not found"}
  612. spool.tag_uid = req.tag_uid.upper()
  613. spool.tag_type = "ntag"
  614. spool.data_origin = "opentag3d"
  615. spool.encode_time = datetime.now(timezone.utc)
  616. logger.info("Tag written and linked: spool %d -> tag %s", spool.id, req.tag_uid)
  617. await db.commit()
  618. await ws_manager.broadcast(
  619. {
  620. "type": "spoolbuddy_tag_written",
  621. "device_id": req.device_id,
  622. "spool_id": req.spool_id,
  623. "tag_uid": req.tag_uid,
  624. }
  625. )
  626. else:
  627. await db.commit()
  628. await ws_manager.broadcast(
  629. {
  630. "type": "spoolbuddy_tag_write_failed",
  631. "device_id": req.device_id,
  632. "spool_id": req.spool_id,
  633. "message": req.message,
  634. }
  635. )
  636. logger.warning("Tag write failed for device %s: %s", req.device_id, req.message)
  637. return {"status": "ok"}
  638. @router.post("/devices/{device_id}/cancel-write")
  639. async def cancel_write(
  640. device_id: str,
  641. db: AsyncSession = Depends(get_db),
  642. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  643. ):
  644. """Cancel a pending write-tag command."""
  645. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  646. device = result.scalar_one_or_none()
  647. if not device:
  648. raise HTTPException(status_code=404, detail="Device not registered")
  649. if device.pending_command == "write_tag":
  650. device.pending_command = None
  651. device.pending_write_payload = None
  652. await db.commit()
  653. logger.info("Write tag cancelled for device %s", device_id)
  654. return {"status": "ok"}
  655. # --- Scale endpoints ---
  656. @router.post("/scale/reading")
  657. async def scale_reading(
  658. req: ScaleReadingRequest,
  659. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  660. ):
  661. """RPi reports scale weight — broadcast to all clients."""
  662. await ws_manager.broadcast(
  663. {
  664. "type": "spoolbuddy_weight",
  665. "device_id": req.device_id,
  666. "weight_grams": req.weight_grams,
  667. "stable": req.stable,
  668. "raw_adc": req.raw_adc,
  669. }
  670. )
  671. return {"status": "ok"}
  672. @router.post("/scale/update-spool-weight")
  673. async def update_spool_weight(
  674. req: UpdateSpoolWeightRequest,
  675. db: AsyncSession = Depends(get_db),
  676. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  677. ):
  678. """Update spool's used weight from scale reading."""
  679. from backend.app.api.routes._spoolman_helpers import _safe_float
  680. sm_client = await _get_spoolman_client_or_none(db)
  681. if sm_client is not None:
  682. try:
  683. sm_spool = await sm_client.get_spool(req.spool_id)
  684. except SpoolmanNotFoundError:
  685. raise HTTPException(status_code=404, detail="Spool not found in Spoolman")
  686. except SpoolmanUnavailableError:
  687. raise HTTPException(status_code=503, detail="Spoolman server is not reachable")
  688. filament = sm_spool.get("filament") or {}
  689. raw_spool_weight = filament.get("spool_weight")
  690. if not raw_spool_weight:
  691. logger.warning(
  692. "Spoolman spool %d has no spool_weight set; using 250g fallback for tare",
  693. req.spool_id,
  694. )
  695. core_weight = _safe_float(raw_spool_weight, 250.0)
  696. label_weight = _safe_float(filament.get("weight"), 1000.0)
  697. remaining_weight = max(0.0, req.weight_grams - core_weight)
  698. result = await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight)
  699. if result is None:
  700. raise HTTPException(status_code=502, detail="Failed to update spool weight in Spoolman")
  701. weight_used = max(0.0, label_weight - remaining_weight)
  702. logger.info(
  703. "SpoolBuddy updated Spoolman spool %d: %.1fg on scale, core=%.1fg → %.1fg remaining",
  704. req.spool_id,
  705. req.weight_grams,
  706. core_weight,
  707. remaining_weight,
  708. )
  709. return {"status": "ok", "weight_used": weight_used}
  710. # Local DB mode
  711. from backend.app.models.spool import Spool
  712. db_result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
  713. spool = db_result.scalar_one_or_none()
  714. if not spool:
  715. raise HTTPException(status_code=404, detail="Spool not found")
  716. # net weight = total on scale minus empty spool core
  717. net_filament = max(0, req.weight_grams - spool.core_weight)
  718. spool.weight_used = max(0, spool.label_weight - net_filament)
  719. spool.last_scale_weight = req.weight_grams
  720. spool.last_weighed_at = datetime.now(timezone.utc)
  721. await db.commit()
  722. logger.info(
  723. "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
  724. spool.id,
  725. req.weight_grams,
  726. spool.weight_used,
  727. )
  728. return {"status": "ok", "weight_used": spool.weight_used}
  729. # --- Calibration endpoints ---
  730. @router.post("/devices/{device_id}/calibration/tare")
  731. async def tare_scale(
  732. device_id: str,
  733. db: AsyncSession = Depends(get_db),
  734. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  735. ):
  736. """Set pending tare command for the device to pick up."""
  737. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  738. device = result.scalar_one_or_none()
  739. if not device:
  740. raise HTTPException(status_code=404, detail="Device not registered")
  741. device.pending_command = "tare"
  742. await db.commit()
  743. return {"status": "ok", "message": "Tare command queued"}
  744. @router.post("/devices/{device_id}/calibration/set-tare")
  745. async def set_tare_offset(
  746. device_id: str,
  747. req: SetTareRequest,
  748. db: AsyncSession = Depends(get_db),
  749. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  750. ):
  751. """Store tare offset reported by the daemon after executing a tare."""
  752. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  753. device = result.scalar_one_or_none()
  754. if not device:
  755. raise HTTPException(status_code=404, detail="Device not registered")
  756. device.tare_offset = req.tare_offset
  757. device.last_calibrated_at = datetime.now(timezone.utc)
  758. await db.commit()
  759. logger.info("SpoolBuddy %s tare offset set to %d", device_id, req.tare_offset)
  760. return CalibrationResponse(
  761. tare_offset=device.tare_offset,
  762. calibration_factor=device.calibration_factor,
  763. )
  764. @router.post("/devices/{device_id}/calibration/set-factor")
  765. async def set_calibration_factor(
  766. device_id: str,
  767. req: SetCalibrationFactorRequest,
  768. db: AsyncSession = Depends(get_db),
  769. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  770. ):
  771. """Calculate and store calibration factor from a known weight."""
  772. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  773. device = result.scalar_one_or_none()
  774. if not device:
  775. raise HTTPException(status_code=404, detail="Device not registered")
  776. tare = req.tare_raw_adc if req.tare_raw_adc is not None else device.tare_offset
  777. raw_delta = req.raw_adc - tare
  778. if raw_delta == 0:
  779. raise HTTPException(status_code=400, detail="Raw ADC value equals tare offset — place weight on scale")
  780. device.calibration_factor = req.known_weight_grams / raw_delta
  781. if req.tare_raw_adc is not None:
  782. device.tare_offset = tare
  783. device.last_calibrated_at = datetime.now(timezone.utc)
  784. await db.commit()
  785. logger.info(
  786. "SpoolBuddy %s calibration factor set to %.6f (known=%.1fg, raw=%d, tare=%d)",
  787. device_id,
  788. device.calibration_factor,
  789. req.known_weight_grams,
  790. req.raw_adc,
  791. tare,
  792. )
  793. return CalibrationResponse(
  794. tare_offset=device.tare_offset,
  795. calibration_factor=device.calibration_factor,
  796. )
  797. @router.get("/devices/{device_id}/calibration", response_model=CalibrationResponse)
  798. async def get_calibration(
  799. device_id: str,
  800. db: AsyncSession = Depends(get_db),
  801. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  802. ):
  803. """Get current calibration values for a device."""
  804. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  805. device = result.scalar_one_or_none()
  806. if not device:
  807. raise HTTPException(status_code=404, detail="Device not registered")
  808. return CalibrationResponse(
  809. tare_offset=device.tare_offset,
  810. calibration_factor=device.calibration_factor,
  811. )
  812. # --- Display settings ---
  813. @router.get("/devices/{device_id}/display")
  814. async def get_display_settings(
  815. device_id: str,
  816. db: AsyncSession = Depends(get_db),
  817. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  818. ):
  819. """Read current display brightness and screen blank timeout for a device.
  820. Used by the SpoolBuddy kiosk idle watchdog on autostart to configure
  821. swayidle with the same timeout the user picked in the UI, without having
  822. to wait for the daemon heartbeat to arrive first.
  823. """
  824. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  825. device = result.scalar_one_or_none()
  826. if not device:
  827. raise HTTPException(status_code=404, detail="Device not registered")
  828. return {
  829. "brightness": device.display_brightness,
  830. "blank_timeout": device.display_blank_timeout,
  831. }
  832. @router.put("/devices/{device_id}/display")
  833. async def update_display_settings(
  834. device_id: str,
  835. req: DisplaySettingsRequest,
  836. db: AsyncSession = Depends(get_db),
  837. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  838. ):
  839. """Update display brightness and screen blank timeout for a device."""
  840. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  841. device = result.scalar_one_or_none()
  842. if not device:
  843. raise HTTPException(status_code=404, detail="Device not registered")
  844. device.display_brightness = req.brightness
  845. device.display_blank_timeout = req.blank_timeout
  846. await db.commit()
  847. logger.info(
  848. "SpoolBuddy %s display updated: brightness=%d%%, blank_timeout=%ds",
  849. device_id,
  850. req.brightness,
  851. req.blank_timeout,
  852. )
  853. return {"status": "ok", "brightness": req.brightness, "blank_timeout": req.blank_timeout}
  854. @router.post("/devices/{device_id}/system/config")
  855. async def queue_system_config_update(
  856. device_id: str,
  857. req: SystemConfigRequest,
  858. db: AsyncSession = Depends(get_db),
  859. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  860. ):
  861. """Queue update of SpoolBuddy .env config on the device."""
  862. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  863. device = result.scalar_one_or_none()
  864. if not device:
  865. raise HTTPException(status_code=404, detail="Device not registered")
  866. parsed = urlparse(req.backend_url.strip())
  867. if parsed.scheme not in ("http", "https") or not parsed.netloc:
  868. raise HTTPException(
  869. status_code=400,
  870. detail="backend_url must be a full URL with scheme, e.g. http://192.168.1.100:5000 or http://bambuddy.local",
  871. )
  872. payload = {
  873. "backend_url": req.backend_url.strip(),
  874. }
  875. if req.api_key is not None and req.api_key.strip():
  876. payload["api_key"] = req.api_key.strip()
  877. device.pending_system_payload = json.dumps(payload)
  878. device.pending_command = "apply_system_config"
  879. await db.commit()
  880. logger.info("Queued system config update for device %s", device_id)
  881. return {"status": "queued", "message": "System config update queued"}
  882. VALID_SYSTEM_COMMANDS = {"reboot", "shutdown", "restart_daemon", "restart_browser"}
  883. @router.post("/devices/{device_id}/system/command")
  884. async def queue_system_command(
  885. device_id: str,
  886. req: SystemCommandRequest,
  887. db: AsyncSession = Depends(get_db),
  888. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  889. ):
  890. """Queue a system command (reboot, shutdown, restart_daemon, restart_browser) for the SpoolBuddy device."""
  891. if req.command not in VALID_SYSTEM_COMMANDS:
  892. raise HTTPException(
  893. status_code=400,
  894. detail=f"Invalid command. Must be one of: {', '.join(sorted(VALID_SYSTEM_COMMANDS))}",
  895. )
  896. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  897. device = result.scalar_one_or_none()
  898. if not device:
  899. raise HTTPException(status_code=404, detail="Device not registered")
  900. if not _is_online(device):
  901. raise HTTPException(status_code=409, detail="Device is offline")
  902. device.pending_command = req.command
  903. await db.commit()
  904. logger.info("System command queued for device %s: %s", device_id, req.command)
  905. return {"status": "queued", "command": req.command}
  906. @router.post("/devices/{device_id}/system/command-result")
  907. async def system_command_result(
  908. device_id: str,
  909. req: SystemCommandResultRequest,
  910. db: AsyncSession = Depends(get_db),
  911. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  912. ):
  913. """Receive completion status for queued system command from daemon."""
  914. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  915. device = result.scalar_one_or_none()
  916. if not device:
  917. raise HTTPException(status_code=404, detail="Device not registered")
  918. if not device.pending_command:
  919. logger.info("System command result from %s with no pending command: %s", device_id, req.command)
  920. return {"status": "ok", "message": "No pending command"}
  921. if req.command != device.pending_command:
  922. raise HTTPException(
  923. status_code=409,
  924. detail=f"Command mismatch: pending '{device.pending_command}', got '{req.command}'",
  925. )
  926. if req.command == "apply_system_config":
  927. device.pending_system_payload = None
  928. device.pending_command = None
  929. await db.commit()
  930. logger.info(
  931. "System command result from %s: %s success=%s message=%s",
  932. device_id,
  933. req.command,
  934. req.success,
  935. req.message,
  936. )
  937. return {"status": "ok"}
  938. # --- Diagnostics ---
  939. @router.post("/diagnostics/{device_id}/run")
  940. async def queue_diagnostic(
  941. device_id: str,
  942. diagnostic: str,
  943. db: AsyncSession = Depends(get_db),
  944. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  945. ):
  946. """Queue a hardware diagnostic to run on the SpoolBuddy device.
  947. Args:
  948. device_id: The device ID
  949. diagnostic: 'scale' or 'nfc' to select which diagnostic to run
  950. Returns:
  951. Status message indicating diagnostic was queued
  952. """
  953. if diagnostic not in ("scale", "nfc", "read_tag"):
  954. raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
  955. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  956. device = result.scalar_one_or_none()
  957. if not device:
  958. raise HTTPException(status_code=404, detail="Device not registered")
  959. device.pending_command = f"run_{diagnostic}_diag"
  960. _diagnostic_results.pop((device_id, diagnostic), None)
  961. await db.commit()
  962. logger.info("Diagnostic queued for device %s: %s", device_id, diagnostic)
  963. return {"status": "queued", "diagnostic": diagnostic, "message": f"Diagnostic '{diagnostic}' queued for device"}
  964. @router.get("/diagnostics/{device_id}/result")
  965. async def get_diagnostic_result(
  966. device_id: str,
  967. diagnostic: str,
  968. db: AsyncSession = Depends(get_db),
  969. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  970. ):
  971. """Get the latest diagnostic result for a device.
  972. Args:
  973. device_id: The device ID
  974. diagnostic: 'scale' or 'nfc'
  975. Returns:
  976. Diagnostic result or 404 if not found
  977. """
  978. if diagnostic not in ("scale", "nfc", "read_tag"):
  979. raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
  980. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  981. device = result.scalar_one_or_none()
  982. if not device:
  983. raise HTTPException(status_code=404, detail="Device not registered")
  984. diag_result = _diagnostic_results.get((device_id, diagnostic))
  985. if not diag_result:
  986. raise HTTPException(status_code=404, detail=f"No {diagnostic} diagnostic results available yet")
  987. return diag_result
  988. @router.post("/diagnostics/{device_id}/result")
  989. async def report_diagnostic_result(
  990. device_id: str,
  991. req: DiagnosticResultRequest,
  992. db: AsyncSession = Depends(get_db),
  993. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  994. ):
  995. """Report diagnostic result from SpoolBuddy device."""
  996. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  997. device = result.scalar_one_or_none()
  998. if not device:
  999. raise HTTPException(status_code=404, detail="Device not registered")
  1000. if req.diagnostic not in ("nfc", "scale", "read_tag"):
  1001. raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
  1002. _diagnostic_results[(device_id, req.diagnostic)] = {
  1003. "diagnostic": req.diagnostic,
  1004. "success": req.success,
  1005. "output": req.output,
  1006. "exit_code": req.exit_code,
  1007. }
  1008. device.pending_command = None
  1009. await db.commit()
  1010. logger.info("Diagnostic result received for device %s: %s (success=%s)", device_id, req.diagnostic, req.success)
  1011. return {"status": "ok", "message": "Diagnostic result recorded"}
  1012. # --- Update check ---
  1013. @router.get("/devices/{device_id}/update-check")
  1014. async def check_daemon_update(
  1015. device_id: str,
  1016. db: AsyncSession = Depends(get_db),
  1017. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  1018. ):
  1019. """Check if the SpoolBuddy daemon needs updating to match the Bambuddy backend version."""
  1020. from backend.app.api.routes.updates import is_newer_version
  1021. from backend.app.core.config import APP_VERSION
  1022. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1023. device = result.scalar_one_or_none()
  1024. if not device:
  1025. raise HTTPException(status_code=404, detail="Device not registered")
  1026. current = device.firmware_version or "0.0.0"
  1027. return {
  1028. "current_version": current,
  1029. "latest_version": APP_VERSION,
  1030. "update_available": is_newer_version(APP_VERSION, current),
  1031. }
  1032. @router.post("/devices/{device_id}/update")
  1033. async def trigger_daemon_update(
  1034. device_id: str,
  1035. req: dict | None = None,
  1036. db: AsyncSession = Depends(get_db),
  1037. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  1038. ):
  1039. """Trigger a SpoolBuddy update over SSH.
  1040. Bambuddy SSHes into the device, pulls the matching branch, installs deps,
  1041. and restarts the daemon. Progress is broadcast via WebSocket.
  1042. """
  1043. from backend.app.services.spoolbuddy_ssh import perform_ssh_update
  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 not _is_online(device):
  1049. raise HTTPException(status_code=409, detail="Device is offline")
  1050. if device.update_status == "updating":
  1051. return {"status": "already_updating", "message": "Update already in progress"}
  1052. device.update_status = "pending"
  1053. device.update_message = "Starting SSH update..."
  1054. await db.commit()
  1055. logger.info("SpoolBuddy %s: SSH update triggered (ip=%s)", device_id, device.ip_address)
  1056. await ws_manager.broadcast(
  1057. {
  1058. "type": "spoolbuddy_update",
  1059. "device_id": device_id,
  1060. "update_status": "pending",
  1061. }
  1062. )
  1063. # Run the SSH update in the background
  1064. asyncio.create_task(perform_ssh_update(device_id, device.ip_address))
  1065. return {"status": "ok", "message": "SSH update started"}
  1066. @router.get("/ssh/public-key")
  1067. async def get_ssh_public_key(
  1068. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  1069. ):
  1070. """Return the SSH public key for SpoolBuddy pairing."""
  1071. from backend.app.services.spoolbuddy_ssh import get_public_key
  1072. try:
  1073. key = await get_public_key()
  1074. return {"public_key": key}
  1075. except Exception as e:
  1076. logger.error("Failed to get SSH public key: %s", e)
  1077. raise HTTPException(status_code=500, detail="Failed to retrieve SSH public key") from e
  1078. @router.post("/devices/{device_id}/update-status")
  1079. async def report_update_status(
  1080. device_id: str,
  1081. req: UpdateStatusRequest,
  1082. db: AsyncSession = Depends(get_db),
  1083. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  1084. ):
  1085. """Daemon reports update progress back to the backend."""
  1086. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  1087. device = result.scalar_one_or_none()
  1088. if not device:
  1089. raise HTTPException(status_code=404, detail="Device not registered")
  1090. device.update_status = req.status
  1091. device.update_message = req.message
  1092. # Only "complete" clears pending_command here. "error" leaves it set so the user can retry
  1093. # via the UI. The SSH service's own _update_progress clears on both "complete" and "error"
  1094. # because it owns the full update lifecycle end-to-end.
  1095. if req.status == "complete":
  1096. device.pending_command = None
  1097. await db.commit()
  1098. logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, req.status, req.message)
  1099. await ws_manager.broadcast(
  1100. {
  1101. "type": "spoolbuddy_update",
  1102. "device_id": device_id,
  1103. "update_status": req.status,
  1104. "update_message": req.message,
  1105. }
  1106. )
  1107. return {"status": "ok"}
  1108. # --- Background watchdog ---
  1109. async def spoolbuddy_watchdog():
  1110. """Check for devices that have gone offline (no heartbeat for 30s).
  1111. Called periodically from the main app's background task loop.
  1112. """
  1113. from backend.app.core.database import async_session
  1114. async with async_session() as db:
  1115. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.last_seen.isnot(None)))
  1116. devices = list(result.scalars().all())
  1117. threshold = datetime.now(timezone.utc) - timedelta(seconds=OFFLINE_THRESHOLD_SECONDS)
  1118. for device in devices:
  1119. last_seen = device.last_seen.replace(tzinfo=timezone.utc) if device.last_seen else None
  1120. if last_seen and last_seen < threshold:
  1121. # Only broadcast once — clear last_seen after marking offline
  1122. await ws_manager.broadcast(
  1123. {
  1124. "type": "spoolbuddy_offline",
  1125. "device_id": device.device_id,
  1126. }
  1127. )
  1128. device.last_seen = None
  1129. logger.info("SpoolBuddy device offline: %s", device.device_id)
  1130. await db.commit()