spoolbuddy.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. """SpoolBuddy device management API routes."""
  2. import logging
  3. from datetime import datetime, timedelta, timezone
  4. from fastapi import APIRouter, Depends, HTTPException
  5. from sqlalchemy import select
  6. from sqlalchemy.ext.asyncio import AsyncSession
  7. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  8. from backend.app.core.database import get_db
  9. from backend.app.core.permissions import Permission
  10. from backend.app.core.websocket import ws_manager
  11. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  12. from backend.app.models.user import User
  13. from backend.app.schemas.spoolbuddy import (
  14. CalibrationResponse,
  15. DeviceRegisterRequest,
  16. DeviceResponse,
  17. HeartbeatRequest,
  18. HeartbeatResponse,
  19. ScaleReadingRequest,
  20. SetCalibrationFactorRequest,
  21. SetTareRequest,
  22. TagRemovedRequest,
  23. TagScannedRequest,
  24. UpdateSpoolWeightRequest,
  25. )
  26. from backend.app.services.spool_tag_matcher import get_spool_by_tag
  27. logger = logging.getLogger(__name__)
  28. router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
  29. OFFLINE_THRESHOLD_SECONDS = 30
  30. def _is_online(device: SpoolBuddyDevice) -> bool:
  31. if not device.last_seen:
  32. return False
  33. return (
  34. datetime.now(timezone.utc) - device.last_seen.replace(tzinfo=timezone.utc)
  35. ).total_seconds() < OFFLINE_THRESHOLD_SECONDS
  36. def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
  37. return DeviceResponse(
  38. id=device.id,
  39. device_id=device.device_id,
  40. hostname=device.hostname,
  41. ip_address=device.ip_address,
  42. firmware_version=device.firmware_version,
  43. has_nfc=device.has_nfc,
  44. has_scale=device.has_scale,
  45. tare_offset=device.tare_offset,
  46. calibration_factor=device.calibration_factor,
  47. last_seen=device.last_seen,
  48. pending_command=device.pending_command,
  49. nfc_ok=device.nfc_ok,
  50. scale_ok=device.scale_ok,
  51. uptime_s=device.uptime_s,
  52. online=_is_online(device),
  53. created_at=device.created_at,
  54. updated_at=device.updated_at,
  55. )
  56. # --- Device endpoints ---
  57. @router.post("/devices/register", response_model=DeviceResponse)
  58. async def register_device(
  59. req: DeviceRegisterRequest,
  60. db: AsyncSession = Depends(get_db),
  61. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  62. ):
  63. """Register or re-register a SpoolBuddy device."""
  64. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
  65. device = result.scalar_one_or_none()
  66. now = datetime.now(timezone.utc)
  67. if device:
  68. device.hostname = req.hostname
  69. device.ip_address = req.ip_address
  70. device.firmware_version = req.firmware_version
  71. device.has_nfc = req.has_nfc
  72. device.has_scale = req.has_scale
  73. device.last_seen = now
  74. logger.info("SpoolBuddy device re-registered: %s (%s)", req.device_id, req.hostname)
  75. else:
  76. device = SpoolBuddyDevice(
  77. device_id=req.device_id,
  78. hostname=req.hostname,
  79. ip_address=req.ip_address,
  80. firmware_version=req.firmware_version,
  81. has_nfc=req.has_nfc,
  82. has_scale=req.has_scale,
  83. tare_offset=req.tare_offset,
  84. calibration_factor=req.calibration_factor,
  85. last_seen=now,
  86. )
  87. db.add(device)
  88. logger.info("SpoolBuddy device registered: %s (%s)", req.device_id, req.hostname)
  89. await db.commit()
  90. await db.refresh(device)
  91. await ws_manager.broadcast(
  92. {
  93. "type": "spoolbuddy_online",
  94. "device_id": device.device_id,
  95. "hostname": device.hostname,
  96. }
  97. )
  98. return _device_to_response(device)
  99. @router.get("/devices", response_model=list[DeviceResponse])
  100. async def list_devices(
  101. db: AsyncSession = Depends(get_db),
  102. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  103. ):
  104. """List all registered SpoolBuddy devices."""
  105. result = await db.execute(select(SpoolBuddyDevice).order_by(SpoolBuddyDevice.hostname))
  106. devices = list(result.scalars().all())
  107. return [_device_to_response(d) for d in devices]
  108. @router.post("/devices/{device_id}/heartbeat", response_model=HeartbeatResponse)
  109. async def device_heartbeat(
  110. device_id: str,
  111. req: HeartbeatRequest,
  112. db: AsyncSession = Depends(get_db),
  113. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  114. ):
  115. """Daemon heartbeat — updates status and returns pending commands."""
  116. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  117. device = result.scalar_one_or_none()
  118. if not device:
  119. raise HTTPException(status_code=404, detail="Device not registered")
  120. was_offline = not _is_online(device)
  121. now = datetime.now(timezone.utc)
  122. device.last_seen = now
  123. device.nfc_ok = req.nfc_ok
  124. device.scale_ok = req.scale_ok
  125. device.uptime_s = req.uptime_s
  126. if req.firmware_version:
  127. device.firmware_version = req.firmware_version
  128. if req.ip_address:
  129. device.ip_address = req.ip_address
  130. # Return and clear pending command
  131. pending = device.pending_command
  132. device.pending_command = None
  133. await db.commit()
  134. if was_offline:
  135. await ws_manager.broadcast(
  136. {
  137. "type": "spoolbuddy_online",
  138. "device_id": device.device_id,
  139. "hostname": device.hostname,
  140. }
  141. )
  142. return HeartbeatResponse(
  143. pending_command=pending,
  144. tare_offset=device.tare_offset,
  145. calibration_factor=device.calibration_factor,
  146. )
  147. # --- NFC endpoints ---
  148. @router.post("/nfc/tag-scanned")
  149. async def nfc_tag_scanned(
  150. req: TagScannedRequest,
  151. db: AsyncSession = Depends(get_db),
  152. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  153. ):
  154. """RPi reports NFC tag detected — lookup spool and broadcast."""
  155. spool = await get_spool_by_tag(db, req.tag_uid, req.tray_uuid or "")
  156. if spool:
  157. await ws_manager.broadcast(
  158. {
  159. "type": "spoolbuddy_tag_matched",
  160. "device_id": req.device_id,
  161. "tag_uid": req.tag_uid,
  162. "spool": {
  163. "id": spool.id,
  164. "material": spool.material,
  165. "subtype": spool.subtype,
  166. "color_name": spool.color_name,
  167. "rgba": spool.rgba,
  168. "brand": spool.brand,
  169. "label_weight": spool.label_weight,
  170. "core_weight": spool.core_weight,
  171. "weight_used": spool.weight_used,
  172. },
  173. }
  174. )
  175. logger.info("SpoolBuddy tag matched: %s -> spool %d", req.tag_uid, spool.id)
  176. else:
  177. await ws_manager.broadcast(
  178. {
  179. "type": "spoolbuddy_unknown_tag",
  180. "device_id": req.device_id,
  181. "tag_uid": req.tag_uid,
  182. "sak": req.sak,
  183. "tag_type": req.tag_type,
  184. }
  185. )
  186. logger.info("SpoolBuddy unknown tag: %s", req.tag_uid)
  187. return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None}
  188. @router.post("/nfc/tag-removed")
  189. async def nfc_tag_removed(
  190. req: TagRemovedRequest,
  191. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  192. ):
  193. """RPi reports NFC tag removed — broadcast event."""
  194. await ws_manager.broadcast(
  195. {
  196. "type": "spoolbuddy_tag_removed",
  197. "device_id": req.device_id,
  198. "tag_uid": req.tag_uid,
  199. }
  200. )
  201. return {"status": "ok"}
  202. # --- Scale endpoints ---
  203. @router.post("/scale/reading")
  204. async def scale_reading(
  205. req: ScaleReadingRequest,
  206. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  207. ):
  208. """RPi reports scale weight — broadcast to all clients."""
  209. await ws_manager.broadcast(
  210. {
  211. "type": "spoolbuddy_weight",
  212. "device_id": req.device_id,
  213. "weight_grams": req.weight_grams,
  214. "stable": req.stable,
  215. "raw_adc": req.raw_adc,
  216. }
  217. )
  218. return {"status": "ok"}
  219. @router.post("/scale/update-spool-weight")
  220. async def update_spool_weight(
  221. req: UpdateSpoolWeightRequest,
  222. db: AsyncSession = Depends(get_db),
  223. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  224. ):
  225. """Update spool's used weight from scale reading."""
  226. from backend.app.models.spool import Spool
  227. result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
  228. spool = result.scalar_one_or_none()
  229. if not spool:
  230. raise HTTPException(status_code=404, detail="Spool not found")
  231. # net weight = total on scale minus empty spool core
  232. net_filament = max(0, req.weight_grams - spool.core_weight)
  233. spool.weight_used = max(0, spool.label_weight - net_filament)
  234. await db.commit()
  235. logger.info(
  236. "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
  237. spool.id,
  238. req.weight_grams,
  239. spool.weight_used,
  240. )
  241. return {"status": "ok", "weight_used": spool.weight_used}
  242. # --- Calibration endpoints ---
  243. @router.post("/devices/{device_id}/calibration/tare")
  244. async def tare_scale(
  245. device_id: str,
  246. db: AsyncSession = Depends(get_db),
  247. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  248. ):
  249. """Set pending tare command for the device to pick up."""
  250. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  251. device = result.scalar_one_or_none()
  252. if not device:
  253. raise HTTPException(status_code=404, detail="Device not registered")
  254. device.pending_command = "tare"
  255. await db.commit()
  256. return {"status": "ok", "message": "Tare command queued"}
  257. @router.post("/devices/{device_id}/calibration/set-tare")
  258. async def set_tare_offset(
  259. device_id: str,
  260. req: SetTareRequest,
  261. db: AsyncSession = Depends(get_db),
  262. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  263. ):
  264. """Store tare offset reported by the daemon after executing a tare."""
  265. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  266. device = result.scalar_one_or_none()
  267. if not device:
  268. raise HTTPException(status_code=404, detail="Device not registered")
  269. device.tare_offset = req.tare_offset
  270. await db.commit()
  271. logger.info("SpoolBuddy %s tare offset set to %d", device_id, req.tare_offset)
  272. return CalibrationResponse(
  273. tare_offset=device.tare_offset,
  274. calibration_factor=device.calibration_factor,
  275. )
  276. @router.post("/devices/{device_id}/calibration/set-factor")
  277. async def set_calibration_factor(
  278. device_id: str,
  279. req: SetCalibrationFactorRequest,
  280. db: AsyncSession = Depends(get_db),
  281. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  282. ):
  283. """Calculate and store calibration factor from a known weight."""
  284. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  285. device = result.scalar_one_or_none()
  286. if not device:
  287. raise HTTPException(status_code=404, detail="Device not registered")
  288. tare = req.tare_raw_adc if req.tare_raw_adc is not None else device.tare_offset
  289. raw_delta = req.raw_adc - tare
  290. if raw_delta == 0:
  291. raise HTTPException(status_code=400, detail="Raw ADC value equals tare offset — place weight on scale")
  292. device.calibration_factor = req.known_weight_grams / raw_delta
  293. if req.tare_raw_adc is not None:
  294. device.tare_offset = tare
  295. await db.commit()
  296. logger.info(
  297. "SpoolBuddy %s calibration factor set to %.6f (known=%.1fg, raw=%d, tare=%d)",
  298. device_id,
  299. device.calibration_factor,
  300. req.known_weight_grams,
  301. req.raw_adc,
  302. tare,
  303. )
  304. return CalibrationResponse(
  305. tare_offset=device.tare_offset,
  306. calibration_factor=device.calibration_factor,
  307. )
  308. @router.get("/devices/{device_id}/calibration", response_model=CalibrationResponse)
  309. async def get_calibration(
  310. device_id: str,
  311. db: AsyncSession = Depends(get_db),
  312. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  313. ):
  314. """Get current calibration values for a device."""
  315. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  316. device = result.scalar_one_or_none()
  317. if not device:
  318. raise HTTPException(status_code=404, detail="Device not registered")
  319. return CalibrationResponse(
  320. tare_offset=device.tare_offset,
  321. calibration_factor=device.calibration_factor,
  322. )
  323. # --- Background watchdog ---
  324. async def spoolbuddy_watchdog():
  325. """Check for devices that have gone offline (no heartbeat for 30s).
  326. Called periodically from the main app's background task loop.
  327. """
  328. from backend.app.core.database import async_session
  329. async with async_session() as db:
  330. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.last_seen.isnot(None)))
  331. devices = list(result.scalars().all())
  332. threshold = datetime.now(timezone.utc) - timedelta(seconds=OFFLINE_THRESHOLD_SECONDS)
  333. for device in devices:
  334. last_seen = device.last_seen.replace(tzinfo=timezone.utc) if device.last_seen else None
  335. if last_seen and last_seen < threshold:
  336. # Only broadcast once — clear last_seen after marking offline
  337. await ws_manager.broadcast(
  338. {
  339. "type": "spoolbuddy_offline",
  340. "device_id": device.device_id,
  341. }
  342. )
  343. device.last_seen = None
  344. logger.info("SpoolBuddy device offline: %s", device.device_id)
  345. await db.commit()