spoolbuddy.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029
  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. from fastapi import APIRouter, Depends, HTTPException
  9. from sqlalchemy import select
  10. from sqlalchemy.ext.asyncio import AsyncSession
  11. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  12. from backend.app.core.database import get_db
  13. from backend.app.core.permissions import Permission
  14. from backend.app.core.websocket import ws_manager
  15. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  16. from backend.app.models.user import User
  17. from backend.app.schemas.spoolbuddy import (
  18. CalibrationResponse,
  19. DeviceRegisterRequest,
  20. DeviceResponse,
  21. DiagnosticResultRequest,
  22. DisplaySettingsRequest,
  23. HeartbeatRequest,
  24. HeartbeatResponse,
  25. ScaleReadingRequest,
  26. SetCalibrationFactorRequest,
  27. SetTareRequest,
  28. SystemCommandRequest,
  29. SystemCommandResultRequest,
  30. SystemConfigRequest,
  31. TagRemovedRequest,
  32. TagScannedRequest,
  33. UpdateSpoolWeightRequest,
  34. WriteTagRequest,
  35. WriteTagResultRequest,
  36. )
  37. from backend.app.services.spool_tag_matcher import get_spool_by_tag
  38. logger = logging.getLogger(__name__)
  39. router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
  40. OFFLINE_THRESHOLD_SECONDS = 30
  41. ONLINE_BROADCAST_INTERVAL_SECONDS = 10
  42. _spoolbuddy_online_last_broadcast: dict[str, float] = {}
  43. _diagnostic_results: dict[tuple[str, str], dict] = {}
  44. def _is_online(device: SpoolBuddyDevice) -> bool:
  45. if not device.last_seen:
  46. return False
  47. return (
  48. datetime.now(timezone.utc) - device.last_seen.replace(tzinfo=timezone.utc)
  49. ).total_seconds() < OFFLINE_THRESHOLD_SECONDS
  50. def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
  51. return DeviceResponse(
  52. id=device.id,
  53. device_id=device.device_id,
  54. hostname=device.hostname,
  55. ip_address=device.ip_address,
  56. firmware_version=device.firmware_version,
  57. has_nfc=device.has_nfc,
  58. has_scale=device.has_scale,
  59. tare_offset=device.tare_offset,
  60. calibration_factor=device.calibration_factor,
  61. nfc_reader_type=device.nfc_reader_type,
  62. nfc_connection=device.nfc_connection,
  63. backend_url=device.backend_url,
  64. display_brightness=device.display_brightness,
  65. display_blank_timeout=device.display_blank_timeout,
  66. has_backlight=device.has_backlight,
  67. last_calibrated_at=device.last_calibrated_at,
  68. last_seen=device.last_seen,
  69. pending_command=device.pending_command,
  70. nfc_ok=device.nfc_ok,
  71. scale_ok=device.scale_ok,
  72. uptime_s=device.uptime_s,
  73. update_status=device.update_status,
  74. update_message=device.update_message,
  75. system_stats=json.loads(device.system_stats) if device.system_stats else None,
  76. online=_is_online(device),
  77. created_at=device.created_at,
  78. updated_at=device.updated_at,
  79. )
  80. def _should_broadcast_online(device_id: str, force: bool = False) -> bool:
  81. if force:
  82. _spoolbuddy_online_last_broadcast[device_id] = time.time()
  83. return True
  84. now_ts = time.time()
  85. last_ts = _spoolbuddy_online_last_broadcast.get(device_id, 0.0)
  86. if now_ts - last_ts >= ONLINE_BROADCAST_INTERVAL_SECONDS:
  87. _spoolbuddy_online_last_broadcast[device_id] = now_ts
  88. return True
  89. return False
  90. # --- Device endpoints ---
  91. @router.post("/devices/register", response_model=DeviceResponse)
  92. async def register_device(
  93. req: DeviceRegisterRequest,
  94. db: AsyncSession = Depends(get_db),
  95. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  96. ):
  97. """Register or re-register a SpoolBuddy device."""
  98. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
  99. device = result.scalar_one_or_none()
  100. now = datetime.now(timezone.utc)
  101. if device:
  102. device.hostname = req.hostname
  103. device.ip_address = req.ip_address
  104. device.firmware_version = req.firmware_version
  105. device.has_nfc = req.has_nfc
  106. device.has_scale = req.has_scale
  107. device.nfc_reader_type = req.nfc_reader_type
  108. device.nfc_connection = req.nfc_connection
  109. if req.backend_url:
  110. device.backend_url = req.backend_url
  111. device.has_backlight = req.has_backlight
  112. device.last_seen = now
  113. # Clear stale update status on re-registration (daemon restarted after update)
  114. if device.update_status in ("pending", "updating", "complete", "error"):
  115. device.update_status = None
  116. device.update_message = None
  117. logger.info("SpoolBuddy device re-registered: %s (%s)", req.device_id, req.hostname)
  118. else:
  119. device = SpoolBuddyDevice(
  120. device_id=req.device_id,
  121. hostname=req.hostname,
  122. ip_address=req.ip_address,
  123. firmware_version=req.firmware_version,
  124. has_nfc=req.has_nfc,
  125. has_scale=req.has_scale,
  126. tare_offset=req.tare_offset,
  127. calibration_factor=req.calibration_factor,
  128. nfc_reader_type=req.nfc_reader_type,
  129. nfc_connection=req.nfc_connection,
  130. has_backlight=req.has_backlight,
  131. backend_url=req.backend_url,
  132. last_seen=now,
  133. )
  134. db.add(device)
  135. logger.info("SpoolBuddy device registered: %s (%s)", req.device_id, req.hostname)
  136. await db.commit()
  137. await db.refresh(device)
  138. _spoolbuddy_online_last_broadcast[device.device_id] = time.time()
  139. await ws_manager.broadcast(
  140. {
  141. "type": "spoolbuddy_online",
  142. "device_id": device.device_id,
  143. "hostname": device.hostname,
  144. }
  145. )
  146. response = _device_to_response(device)
  147. # Include SSH public key so the daemon can auto-deploy it
  148. try:
  149. from backend.app.services.spoolbuddy_ssh import get_public_key
  150. response.ssh_public_key = await get_public_key()
  151. except Exception:
  152. pass # Key not generated yet — daemon can still work without it
  153. return response
  154. @router.get("/devices", response_model=list[DeviceResponse])
  155. async def list_devices(
  156. db: AsyncSession = Depends(get_db),
  157. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  158. ):
  159. """List all registered SpoolBuddy devices."""
  160. result = await db.execute(select(SpoolBuddyDevice).order_by(SpoolBuddyDevice.hostname))
  161. devices = list(result.scalars().all())
  162. return [_device_to_response(d) for d in devices]
  163. @router.delete("/devices/{device_id}")
  164. async def unregister_device(
  165. device_id: str,
  166. db: AsyncSession = Depends(get_db),
  167. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_DELETE),
  168. ):
  169. """Unregister a SpoolBuddy device. The daemon can re-register via heartbeat later."""
  170. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  171. device = result.scalar_one_or_none()
  172. if not device:
  173. raise HTTPException(status_code=404, detail="Device not registered")
  174. await db.delete(device)
  175. await db.commit()
  176. _spoolbuddy_online_last_broadcast.pop(device_id, None)
  177. logger.info("SpoolBuddy device unregistered: %s (%s)", device_id, device.hostname)
  178. await ws_manager.broadcast({"type": "spoolbuddy_unregistered", "device_id": device_id})
  179. return {"status": "deleted", "device_id": device_id}
  180. @router.post("/devices/{device_id}/heartbeat", response_model=HeartbeatResponse)
  181. async def device_heartbeat(
  182. device_id: str,
  183. req: HeartbeatRequest,
  184. db: AsyncSession = Depends(get_db),
  185. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  186. ):
  187. """Daemon heartbeat — updates status and returns pending commands."""
  188. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  189. device = result.scalar_one_or_none()
  190. if not device:
  191. raise HTTPException(status_code=404, detail="Device not registered")
  192. was_offline = not _is_online(device)
  193. now = datetime.now(timezone.utc)
  194. device.last_seen = now
  195. device.nfc_ok = req.nfc_ok
  196. device.scale_ok = req.scale_ok
  197. device.uptime_s = req.uptime_s
  198. if req.firmware_version:
  199. device.firmware_version = req.firmware_version
  200. if req.ip_address:
  201. device.ip_address = req.ip_address
  202. if req.nfc_reader_type:
  203. device.nfc_reader_type = req.nfc_reader_type
  204. if req.nfc_connection:
  205. device.nfc_connection = req.nfc_connection
  206. if req.backend_url:
  207. device.backend_url = req.backend_url
  208. if req.system_stats is not None:
  209. device.system_stats = json.dumps(req.system_stats)
  210. # Return and clear pending command
  211. pending = device.pending_command
  212. pending_write = None
  213. pending_system = None
  214. if pending == "write_tag" and device.pending_write_payload:
  215. # Parse the stored JSON payload to include in response
  216. try:
  217. pending_write = json.loads(device.pending_write_payload)
  218. except (json.JSONDecodeError, TypeError):
  219. pending_write = None
  220. # Don't clear write_tag command — it gets cleared by write-result
  221. elif pending == "apply_system_config" and device.pending_system_payload:
  222. try:
  223. pending_system = json.loads(device.pending_system_payload)
  224. except (json.JSONDecodeError, TypeError):
  225. pending_system = None
  226. # Don't clear config command — it gets cleared by daemon command-result callback
  227. elif pending and pending.startswith("run_") and pending.endswith("_diag"):
  228. # Don't clear diagnostic commands — they get cleared by the device reporting results
  229. pass
  230. else:
  231. device.pending_command = None
  232. await db.commit()
  233. # Emit online presence on offline->online transitions immediately, and
  234. # periodically while online so newly connected UIs can bootstrap state.
  235. if _should_broadcast_online(device.device_id, force=was_offline):
  236. await ws_manager.broadcast(
  237. {
  238. "type": "spoolbuddy_online",
  239. "device_id": device.device_id,
  240. "hostname": device.hostname,
  241. }
  242. )
  243. if was_offline:
  244. logger.info("SpoolBuddy device back online: %s", device.device_id)
  245. return HeartbeatResponse(
  246. pending_command=pending,
  247. pending_write_payload=pending_write,
  248. pending_system_payload=pending_system,
  249. tare_offset=device.tare_offset,
  250. calibration_factor=device.calibration_factor,
  251. display_brightness=device.display_brightness,
  252. display_blank_timeout=device.display_blank_timeout,
  253. )
  254. # --- NFC endpoints ---
  255. @router.post("/nfc/tag-scanned")
  256. async def nfc_tag_scanned(
  257. req: TagScannedRequest,
  258. db: AsyncSession = Depends(get_db),
  259. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  260. ):
  261. """RPi reports NFC tag detected — lookup spool and broadcast."""
  262. spool = await get_spool_by_tag(db, req.tag_uid, req.tray_uuid or "")
  263. if spool:
  264. await ws_manager.broadcast(
  265. {
  266. "type": "spoolbuddy_tag_matched",
  267. "device_id": req.device_id,
  268. "tag_uid": req.tag_uid,
  269. "spool": {
  270. "id": spool.id,
  271. "material": spool.material,
  272. "subtype": spool.subtype,
  273. "color_name": spool.color_name,
  274. "rgba": spool.rgba,
  275. "brand": spool.brand,
  276. "label_weight": spool.label_weight,
  277. "core_weight": spool.core_weight,
  278. "weight_used": spool.weight_used,
  279. },
  280. }
  281. )
  282. logger.info("SpoolBuddy tag matched: %s -> spool %d", req.tag_uid, spool.id)
  283. else:
  284. await ws_manager.broadcast(
  285. {
  286. "type": "spoolbuddy_unknown_tag",
  287. "device_id": req.device_id,
  288. "tag_uid": req.tag_uid,
  289. "sak": req.sak,
  290. "tag_type": req.tag_type,
  291. }
  292. )
  293. logger.info(
  294. "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s",
  295. req.tag_uid,
  296. len(req.tag_uid or ""),
  297. req.tray_uuid,
  298. len(req.tray_uuid or ""),
  299. req.tag_type,
  300. req.sak,
  301. )
  302. return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None}
  303. @router.post("/nfc/tag-removed")
  304. async def nfc_tag_removed(
  305. req: TagRemovedRequest,
  306. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  307. ):
  308. """RPi reports NFC tag removed — broadcast event."""
  309. await ws_manager.broadcast(
  310. {
  311. "type": "spoolbuddy_tag_removed",
  312. "device_id": req.device_id,
  313. "tag_uid": req.tag_uid,
  314. }
  315. )
  316. return {"status": "ok"}
  317. @router.post("/nfc/write-tag")
  318. async def nfc_write_tag(
  319. req: WriteTagRequest,
  320. db: AsyncSession = Depends(get_db),
  321. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  322. ):
  323. """Queue an NFC tag write command for a SpoolBuddy device."""
  324. import json
  325. from backend.app.models.spool import Spool
  326. from backend.app.services.opentag3d import encode_opentag3d
  327. # Find the spool
  328. result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
  329. spool = result.scalar_one_or_none()
  330. if not spool:
  331. raise HTTPException(status_code=404, detail="Spool not found")
  332. # Find the device
  333. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
  334. device = result.scalar_one_or_none()
  335. if not device:
  336. raise HTTPException(status_code=404, detail="Device not registered")
  337. # Encode OpenTag3D NDEF data
  338. ndef_data = encode_opentag3d(spool)
  339. # Store write payload and set pending command
  340. device.pending_write_payload = json.dumps(
  341. {
  342. "spool_id": spool.id,
  343. "ndef_data_hex": ndef_data.hex(),
  344. }
  345. )
  346. device.pending_command = "write_tag"
  347. await db.commit()
  348. logger.info("Write tag queued for device %s, spool %d (%d bytes)", req.device_id, spool.id, len(ndef_data))
  349. return {"status": "queued"}
  350. @router.post("/nfc/write-result")
  351. async def nfc_write_result(
  352. req: WriteTagResultRequest,
  353. db: AsyncSession = Depends(get_db),
  354. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  355. ):
  356. """Handle NFC tag write result from SpoolBuddy daemon."""
  357. # Find the device and clear pending state
  358. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
  359. device = result.scalar_one_or_none()
  360. if not device:
  361. raise HTTPException(status_code=404, detail="Device not registered")
  362. device.pending_command = None
  363. device.pending_write_payload = None
  364. if req.success:
  365. # Link the tag to the spool
  366. from backend.app.models.spool import Spool
  367. result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
  368. spool = result.scalar_one_or_none()
  369. if spool:
  370. spool.tag_uid = req.tag_uid.upper()
  371. spool.tag_type = "ntag"
  372. spool.data_origin = "opentag3d"
  373. spool.encode_time = datetime.now(timezone.utc)
  374. logger.info("Tag written and linked: spool %d -> tag %s", spool.id, req.tag_uid)
  375. await db.commit()
  376. await ws_manager.broadcast(
  377. {
  378. "type": "spoolbuddy_tag_written",
  379. "device_id": req.device_id,
  380. "spool_id": req.spool_id,
  381. "tag_uid": req.tag_uid,
  382. }
  383. )
  384. else:
  385. await db.commit()
  386. await ws_manager.broadcast(
  387. {
  388. "type": "spoolbuddy_tag_write_failed",
  389. "device_id": req.device_id,
  390. "spool_id": req.spool_id,
  391. "message": req.message,
  392. }
  393. )
  394. logger.warning("Tag write failed for device %s: %s", req.device_id, req.message)
  395. return {"status": "ok"}
  396. @router.post("/devices/{device_id}/cancel-write")
  397. async def cancel_write(
  398. device_id: str,
  399. db: AsyncSession = Depends(get_db),
  400. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  401. ):
  402. """Cancel a pending write-tag command."""
  403. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  404. device = result.scalar_one_or_none()
  405. if not device:
  406. raise HTTPException(status_code=404, detail="Device not registered")
  407. if device.pending_command == "write_tag":
  408. device.pending_command = None
  409. device.pending_write_payload = None
  410. await db.commit()
  411. logger.info("Write tag cancelled for device %s", device_id)
  412. return {"status": "ok"}
  413. # --- Scale endpoints ---
  414. @router.post("/scale/reading")
  415. async def scale_reading(
  416. req: ScaleReadingRequest,
  417. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  418. ):
  419. """RPi reports scale weight — broadcast to all clients."""
  420. await ws_manager.broadcast(
  421. {
  422. "type": "spoolbuddy_weight",
  423. "device_id": req.device_id,
  424. "weight_grams": req.weight_grams,
  425. "stable": req.stable,
  426. "raw_adc": req.raw_adc,
  427. }
  428. )
  429. return {"status": "ok"}
  430. @router.post("/scale/update-spool-weight")
  431. async def update_spool_weight(
  432. req: UpdateSpoolWeightRequest,
  433. db: AsyncSession = Depends(get_db),
  434. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  435. ):
  436. """Update spool's used weight from scale reading."""
  437. from backend.app.models.spool import Spool
  438. result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
  439. spool = result.scalar_one_or_none()
  440. if not spool:
  441. raise HTTPException(status_code=404, detail="Spool not found")
  442. # net weight = total on scale minus empty spool core
  443. net_filament = max(0, req.weight_grams - spool.core_weight)
  444. spool.weight_used = max(0, spool.label_weight - net_filament)
  445. spool.last_scale_weight = req.weight_grams
  446. spool.last_weighed_at = datetime.now(timezone.utc)
  447. await db.commit()
  448. logger.info(
  449. "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
  450. spool.id,
  451. req.weight_grams,
  452. spool.weight_used,
  453. )
  454. return {"status": "ok", "weight_used": spool.weight_used}
  455. # --- Calibration endpoints ---
  456. @router.post("/devices/{device_id}/calibration/tare")
  457. async def tare_scale(
  458. device_id: str,
  459. db: AsyncSession = Depends(get_db),
  460. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  461. ):
  462. """Set pending tare command for the device to pick up."""
  463. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  464. device = result.scalar_one_or_none()
  465. if not device:
  466. raise HTTPException(status_code=404, detail="Device not registered")
  467. device.pending_command = "tare"
  468. await db.commit()
  469. return {"status": "ok", "message": "Tare command queued"}
  470. @router.post("/devices/{device_id}/calibration/set-tare")
  471. async def set_tare_offset(
  472. device_id: str,
  473. req: SetTareRequest,
  474. db: AsyncSession = Depends(get_db),
  475. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  476. ):
  477. """Store tare offset reported by the daemon after executing a tare."""
  478. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  479. device = result.scalar_one_or_none()
  480. if not device:
  481. raise HTTPException(status_code=404, detail="Device not registered")
  482. device.tare_offset = req.tare_offset
  483. device.last_calibrated_at = datetime.now(timezone.utc)
  484. await db.commit()
  485. logger.info("SpoolBuddy %s tare offset set to %d", device_id, req.tare_offset)
  486. return CalibrationResponse(
  487. tare_offset=device.tare_offset,
  488. calibration_factor=device.calibration_factor,
  489. )
  490. @router.post("/devices/{device_id}/calibration/set-factor")
  491. async def set_calibration_factor(
  492. device_id: str,
  493. req: SetCalibrationFactorRequest,
  494. db: AsyncSession = Depends(get_db),
  495. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  496. ):
  497. """Calculate and store calibration factor from a known weight."""
  498. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  499. device = result.scalar_one_or_none()
  500. if not device:
  501. raise HTTPException(status_code=404, detail="Device not registered")
  502. tare = req.tare_raw_adc if req.tare_raw_adc is not None else device.tare_offset
  503. raw_delta = req.raw_adc - tare
  504. if raw_delta == 0:
  505. raise HTTPException(status_code=400, detail="Raw ADC value equals tare offset — place weight on scale")
  506. device.calibration_factor = req.known_weight_grams / raw_delta
  507. if req.tare_raw_adc is not None:
  508. device.tare_offset = tare
  509. device.last_calibrated_at = datetime.now(timezone.utc)
  510. await db.commit()
  511. logger.info(
  512. "SpoolBuddy %s calibration factor set to %.6f (known=%.1fg, raw=%d, tare=%d)",
  513. device_id,
  514. device.calibration_factor,
  515. req.known_weight_grams,
  516. req.raw_adc,
  517. tare,
  518. )
  519. return CalibrationResponse(
  520. tare_offset=device.tare_offset,
  521. calibration_factor=device.calibration_factor,
  522. )
  523. @router.get("/devices/{device_id}/calibration", response_model=CalibrationResponse)
  524. async def get_calibration(
  525. device_id: str,
  526. db: AsyncSession = Depends(get_db),
  527. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  528. ):
  529. """Get current calibration values for a device."""
  530. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  531. device = result.scalar_one_or_none()
  532. if not device:
  533. raise HTTPException(status_code=404, detail="Device not registered")
  534. return CalibrationResponse(
  535. tare_offset=device.tare_offset,
  536. calibration_factor=device.calibration_factor,
  537. )
  538. # --- Display settings ---
  539. @router.get("/devices/{device_id}/display")
  540. async def get_display_settings(
  541. device_id: str,
  542. db: AsyncSession = Depends(get_db),
  543. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  544. ):
  545. """Read current display brightness and screen blank timeout for a device.
  546. Used by the SpoolBuddy kiosk idle watchdog on autostart to configure
  547. swayidle with the same timeout the user picked in the UI, without having
  548. to wait for the daemon heartbeat to arrive first.
  549. """
  550. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  551. device = result.scalar_one_or_none()
  552. if not device:
  553. raise HTTPException(status_code=404, detail="Device not registered")
  554. return {
  555. "brightness": device.display_brightness,
  556. "blank_timeout": device.display_blank_timeout,
  557. }
  558. @router.put("/devices/{device_id}/display")
  559. async def update_display_settings(
  560. device_id: str,
  561. req: DisplaySettingsRequest,
  562. db: AsyncSession = Depends(get_db),
  563. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  564. ):
  565. """Update display brightness and screen blank timeout for a device."""
  566. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  567. device = result.scalar_one_or_none()
  568. if not device:
  569. raise HTTPException(status_code=404, detail="Device not registered")
  570. device.display_brightness = req.brightness
  571. device.display_blank_timeout = req.blank_timeout
  572. await db.commit()
  573. logger.info(
  574. "SpoolBuddy %s display updated: brightness=%d%%, blank_timeout=%ds",
  575. device_id,
  576. req.brightness,
  577. req.blank_timeout,
  578. )
  579. return {"status": "ok", "brightness": req.brightness, "blank_timeout": req.blank_timeout}
  580. @router.post("/devices/{device_id}/system/config")
  581. async def queue_system_config_update(
  582. device_id: str,
  583. req: SystemConfigRequest,
  584. db: AsyncSession = Depends(get_db),
  585. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  586. ):
  587. """Queue update of SpoolBuddy .env config on the device."""
  588. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  589. device = result.scalar_one_or_none()
  590. if not device:
  591. raise HTTPException(status_code=404, detail="Device not registered")
  592. parsed = urlparse(req.backend_url.strip())
  593. if parsed.scheme not in ("http", "https") or not parsed.netloc:
  594. raise HTTPException(
  595. status_code=400,
  596. detail="backend_url must be a full URL with scheme, e.g. http://192.168.1.100:5000 or http://bambuddy.local",
  597. )
  598. payload = {
  599. "backend_url": req.backend_url.strip(),
  600. }
  601. if req.api_key is not None and req.api_key.strip():
  602. payload["api_key"] = req.api_key.strip()
  603. device.pending_system_payload = json.dumps(payload)
  604. device.pending_command = "apply_system_config"
  605. await db.commit()
  606. logger.info("Queued system config update for device %s", device_id)
  607. return {"status": "queued", "message": "System config update queued"}
  608. VALID_SYSTEM_COMMANDS = {"reboot", "shutdown", "restart_daemon", "restart_browser"}
  609. @router.post("/devices/{device_id}/system/command")
  610. async def queue_system_command(
  611. device_id: str,
  612. req: SystemCommandRequest,
  613. db: AsyncSession = Depends(get_db),
  614. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  615. ):
  616. """Queue a system command (reboot, shutdown, restart_daemon, restart_browser) for the SpoolBuddy device."""
  617. if req.command not in VALID_SYSTEM_COMMANDS:
  618. raise HTTPException(
  619. status_code=400,
  620. detail=f"Invalid command. Must be one of: {', '.join(sorted(VALID_SYSTEM_COMMANDS))}",
  621. )
  622. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  623. device = result.scalar_one_or_none()
  624. if not device:
  625. raise HTTPException(status_code=404, detail="Device not registered")
  626. if not _is_online(device):
  627. raise HTTPException(status_code=409, detail="Device is offline")
  628. device.pending_command = req.command
  629. await db.commit()
  630. logger.info("System command queued for device %s: %s", device_id, req.command)
  631. return {"status": "queued", "command": req.command}
  632. @router.post("/devices/{device_id}/system/command-result")
  633. async def system_command_result(
  634. device_id: str,
  635. req: SystemCommandResultRequest,
  636. db: AsyncSession = Depends(get_db),
  637. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  638. ):
  639. """Receive completion status for queued system command from daemon."""
  640. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  641. device = result.scalar_one_or_none()
  642. if not device:
  643. raise HTTPException(status_code=404, detail="Device not registered")
  644. if not device.pending_command:
  645. logger.info("System command result from %s with no pending command: %s", device_id, req.command)
  646. return {"status": "ok", "message": "No pending command"}
  647. if req.command != device.pending_command:
  648. raise HTTPException(
  649. status_code=409,
  650. detail=f"Command mismatch: pending '{device.pending_command}', got '{req.command}'",
  651. )
  652. if req.command == "apply_system_config":
  653. device.pending_system_payload = None
  654. device.pending_command = None
  655. await db.commit()
  656. logger.info(
  657. "System command result from %s: %s success=%s message=%s",
  658. device_id,
  659. req.command,
  660. req.success,
  661. req.message,
  662. )
  663. return {"status": "ok"}
  664. # --- Diagnostics ---
  665. @router.post("/diagnostics/{device_id}/run")
  666. async def queue_diagnostic(
  667. device_id: str,
  668. diagnostic: str,
  669. db: AsyncSession = Depends(get_db),
  670. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  671. ):
  672. """Queue a hardware diagnostic to run on the SpoolBuddy device.
  673. Args:
  674. device_id: The device ID
  675. diagnostic: 'scale' or 'nfc' to select which diagnostic to run
  676. Returns:
  677. Status message indicating diagnostic was queued
  678. """
  679. if diagnostic not in ("scale", "nfc", "read_tag"):
  680. raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
  681. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  682. device = result.scalar_one_or_none()
  683. if not device:
  684. raise HTTPException(status_code=404, detail="Device not registered")
  685. device.pending_command = f"run_{diagnostic}_diag"
  686. _diagnostic_results.pop((device_id, diagnostic), None)
  687. await db.commit()
  688. logger.info("Diagnostic queued for device %s: %s", device_id, diagnostic)
  689. return {"status": "queued", "diagnostic": diagnostic, "message": f"Diagnostic '{diagnostic}' queued for device"}
  690. @router.get("/diagnostics/{device_id}/result")
  691. async def get_diagnostic_result(
  692. device_id: str,
  693. diagnostic: str,
  694. db: AsyncSession = Depends(get_db),
  695. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  696. ):
  697. """Get the latest diagnostic result for a device.
  698. Args:
  699. device_id: The device ID
  700. diagnostic: 'scale' or 'nfc'
  701. Returns:
  702. Diagnostic result or 404 if not found
  703. """
  704. if diagnostic not in ("scale", "nfc", "read_tag"):
  705. raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
  706. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  707. device = result.scalar_one_or_none()
  708. if not device:
  709. raise HTTPException(status_code=404, detail="Device not registered")
  710. diag_result = _diagnostic_results.get((device_id, diagnostic))
  711. if not diag_result:
  712. raise HTTPException(status_code=404, detail=f"No {diagnostic} diagnostic results available yet")
  713. return diag_result
  714. @router.post("/diagnostics/{device_id}/result")
  715. async def report_diagnostic_result(
  716. device_id: str,
  717. req: DiagnosticResultRequest,
  718. db: AsyncSession = Depends(get_db),
  719. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  720. ):
  721. """Report diagnostic result from SpoolBuddy device."""
  722. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  723. device = result.scalar_one_or_none()
  724. if not device:
  725. raise HTTPException(status_code=404, detail="Device not registered")
  726. if req.diagnostic not in ("nfc", "scale", "read_tag"):
  727. raise HTTPException(status_code=400, detail="Unknown diagnostic. Must be 'scale', 'nfc', or 'read_tag'")
  728. _diagnostic_results[(device_id, req.diagnostic)] = {
  729. "diagnostic": req.diagnostic,
  730. "success": req.success,
  731. "output": req.output,
  732. "exit_code": req.exit_code,
  733. }
  734. device.pending_command = None
  735. await db.commit()
  736. logger.info("Diagnostic result received for device %s: %s (success=%s)", device_id, req.diagnostic, req.success)
  737. return {"status": "ok", "message": "Diagnostic result recorded"}
  738. # --- Update check ---
  739. @router.get("/devices/{device_id}/update-check")
  740. async def check_daemon_update(
  741. device_id: str,
  742. db: AsyncSession = Depends(get_db),
  743. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  744. ):
  745. """Check if the SpoolBuddy daemon needs updating to match the Bambuddy backend version."""
  746. from backend.app.api.routes.updates import is_newer_version
  747. from backend.app.core.config import APP_VERSION
  748. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  749. device = result.scalar_one_or_none()
  750. if not device:
  751. raise HTTPException(status_code=404, detail="Device not registered")
  752. current = device.firmware_version or "0.0.0"
  753. return {
  754. "current_version": current,
  755. "latest_version": APP_VERSION,
  756. "update_available": is_newer_version(APP_VERSION, current),
  757. }
  758. @router.post("/devices/{device_id}/update")
  759. async def trigger_daemon_update(
  760. device_id: str,
  761. req: dict | None = None,
  762. db: AsyncSession = Depends(get_db),
  763. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  764. ):
  765. """Trigger a SpoolBuddy update over SSH.
  766. Bambuddy SSHes into the device, pulls the matching branch, installs deps,
  767. and restarts the daemon. Progress is broadcast via WebSocket.
  768. """
  769. from backend.app.services.spoolbuddy_ssh import perform_ssh_update
  770. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  771. device = result.scalar_one_or_none()
  772. if not device:
  773. raise HTTPException(status_code=404, detail="Device not registered")
  774. if not _is_online(device):
  775. raise HTTPException(status_code=409, detail="Device is offline")
  776. if device.update_status == "updating":
  777. return {"status": "already_updating", "message": "Update already in progress"}
  778. device.update_status = "pending"
  779. device.update_message = "Starting SSH update..."
  780. await db.commit()
  781. logger.info("SpoolBuddy %s: SSH update triggered (ip=%s)", device_id, device.ip_address)
  782. await ws_manager.broadcast(
  783. {
  784. "type": "spoolbuddy_update",
  785. "device_id": device_id,
  786. "update_status": "pending",
  787. }
  788. )
  789. # Run the SSH update in the background
  790. asyncio.create_task(perform_ssh_update(device_id, device.ip_address))
  791. return {"status": "ok", "message": "SSH update started"}
  792. @router.get("/ssh/public-key")
  793. async def get_ssh_public_key(
  794. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  795. ):
  796. """Return the SSH public key for SpoolBuddy pairing."""
  797. from backend.app.services.spoolbuddy_ssh import get_public_key
  798. try:
  799. key = await get_public_key()
  800. return {"public_key": key}
  801. except Exception as e:
  802. raise HTTPException(status_code=500, detail=f"Failed to get SSH key: {e}") from e
  803. @router.post("/devices/{device_id}/update-status")
  804. async def report_update_status(
  805. device_id: str,
  806. req: dict,
  807. db: AsyncSession = Depends(get_db),
  808. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  809. ):
  810. """Daemon reports update progress back to the backend."""
  811. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  812. device = result.scalar_one_or_none()
  813. if not device:
  814. raise HTTPException(status_code=404, detail="Device not registered")
  815. status = req.get("status", "")
  816. message = req.get("message", "")
  817. if status in ("updating", "complete", "error"):
  818. device.update_status = status
  819. device.update_message = message[:255] if message else None
  820. if status == "complete":
  821. device.pending_command = None
  822. await db.commit()
  823. logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, status, message)
  824. await ws_manager.broadcast(
  825. {
  826. "type": "spoolbuddy_update",
  827. "device_id": device_id,
  828. "update_status": status,
  829. "update_message": message,
  830. }
  831. )
  832. return {"status": "ok"}
  833. # --- Background watchdog ---
  834. async def spoolbuddy_watchdog():
  835. """Check for devices that have gone offline (no heartbeat for 30s).
  836. Called periodically from the main app's background task loop.
  837. """
  838. from backend.app.core.database import async_session
  839. async with async_session() as db:
  840. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.last_seen.isnot(None)))
  841. devices = list(result.scalars().all())
  842. threshold = datetime.now(timezone.utc) - timedelta(seconds=OFFLINE_THRESHOLD_SECONDS)
  843. for device in devices:
  844. last_seen = device.last_seen.replace(tzinfo=timezone.utc) if device.last_seen else None
  845. if last_seen and last_seen < threshold:
  846. # Only broadcast once — clear last_seen after marking offline
  847. await ws_manager.broadcast(
  848. {
  849. "type": "spoolbuddy_offline",
  850. "device_id": device.device_id,
  851. }
  852. )
  853. device.last_seen = None
  854. logger.info("SpoolBuddy device offline: %s", device.device_id)
  855. await db.commit()