spoolbuddy.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740
  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. DisplaySettingsRequest,
  18. HeartbeatRequest,
  19. HeartbeatResponse,
  20. ScaleReadingRequest,
  21. SetCalibrationFactorRequest,
  22. SetTareRequest,
  23. TagRemovedRequest,
  24. TagScannedRequest,
  25. UpdateSpoolWeightRequest,
  26. WriteTagRequest,
  27. WriteTagResultRequest,
  28. )
  29. from backend.app.services.spool_tag_matcher import get_spool_by_tag
  30. logger = logging.getLogger(__name__)
  31. router = APIRouter(prefix="/spoolbuddy", tags=["spoolbuddy"])
  32. OFFLINE_THRESHOLD_SECONDS = 30
  33. def _is_online(device: SpoolBuddyDevice) -> bool:
  34. if not device.last_seen:
  35. return False
  36. return (
  37. datetime.now(timezone.utc) - device.last_seen.replace(tzinfo=timezone.utc)
  38. ).total_seconds() < OFFLINE_THRESHOLD_SECONDS
  39. def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
  40. return DeviceResponse(
  41. id=device.id,
  42. device_id=device.device_id,
  43. hostname=device.hostname,
  44. ip_address=device.ip_address,
  45. firmware_version=device.firmware_version,
  46. has_nfc=device.has_nfc,
  47. has_scale=device.has_scale,
  48. tare_offset=device.tare_offset,
  49. calibration_factor=device.calibration_factor,
  50. nfc_reader_type=device.nfc_reader_type,
  51. nfc_connection=device.nfc_connection,
  52. display_brightness=device.display_brightness,
  53. display_blank_timeout=device.display_blank_timeout,
  54. has_backlight=device.has_backlight,
  55. last_calibrated_at=device.last_calibrated_at,
  56. last_seen=device.last_seen,
  57. pending_command=device.pending_command,
  58. nfc_ok=device.nfc_ok,
  59. scale_ok=device.scale_ok,
  60. uptime_s=device.uptime_s,
  61. update_status=device.update_status,
  62. update_message=device.update_message,
  63. online=_is_online(device),
  64. created_at=device.created_at,
  65. updated_at=device.updated_at,
  66. )
  67. # --- Device endpoints ---
  68. @router.post("/devices/register", response_model=DeviceResponse)
  69. async def register_device(
  70. req: DeviceRegisterRequest,
  71. db: AsyncSession = Depends(get_db),
  72. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  73. ):
  74. """Register or re-register a SpoolBuddy device."""
  75. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
  76. device = result.scalar_one_or_none()
  77. now = datetime.now(timezone.utc)
  78. if device:
  79. device.hostname = req.hostname
  80. device.ip_address = req.ip_address
  81. device.firmware_version = req.firmware_version
  82. device.has_nfc = req.has_nfc
  83. device.has_scale = req.has_scale
  84. device.nfc_reader_type = req.nfc_reader_type
  85. device.nfc_connection = req.nfc_connection
  86. device.has_backlight = req.has_backlight
  87. device.last_seen = now
  88. logger.info("SpoolBuddy device re-registered: %s (%s)", req.device_id, req.hostname)
  89. else:
  90. device = SpoolBuddyDevice(
  91. device_id=req.device_id,
  92. hostname=req.hostname,
  93. ip_address=req.ip_address,
  94. firmware_version=req.firmware_version,
  95. has_nfc=req.has_nfc,
  96. has_scale=req.has_scale,
  97. tare_offset=req.tare_offset,
  98. calibration_factor=req.calibration_factor,
  99. nfc_reader_type=req.nfc_reader_type,
  100. nfc_connection=req.nfc_connection,
  101. has_backlight=req.has_backlight,
  102. last_seen=now,
  103. )
  104. db.add(device)
  105. logger.info("SpoolBuddy device registered: %s (%s)", req.device_id, req.hostname)
  106. await db.commit()
  107. await db.refresh(device)
  108. await ws_manager.broadcast(
  109. {
  110. "type": "spoolbuddy_online",
  111. "device_id": device.device_id,
  112. "hostname": device.hostname,
  113. }
  114. )
  115. return _device_to_response(device)
  116. @router.get("/devices", response_model=list[DeviceResponse])
  117. async def list_devices(
  118. db: AsyncSession = Depends(get_db),
  119. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  120. ):
  121. """List all registered SpoolBuddy devices."""
  122. result = await db.execute(select(SpoolBuddyDevice).order_by(SpoolBuddyDevice.hostname))
  123. devices = list(result.scalars().all())
  124. return [_device_to_response(d) for d in devices]
  125. @router.post("/devices/{device_id}/heartbeat", response_model=HeartbeatResponse)
  126. async def device_heartbeat(
  127. device_id: str,
  128. req: HeartbeatRequest,
  129. db: AsyncSession = Depends(get_db),
  130. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  131. ):
  132. """Daemon heartbeat — updates status and returns pending commands."""
  133. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  134. device = result.scalar_one_or_none()
  135. if not device:
  136. raise HTTPException(status_code=404, detail="Device not registered")
  137. was_offline = not _is_online(device)
  138. now = datetime.now(timezone.utc)
  139. device.last_seen = now
  140. device.nfc_ok = req.nfc_ok
  141. device.scale_ok = req.scale_ok
  142. device.uptime_s = req.uptime_s
  143. if req.firmware_version:
  144. device.firmware_version = req.firmware_version
  145. if req.ip_address:
  146. device.ip_address = req.ip_address
  147. if req.nfc_reader_type:
  148. device.nfc_reader_type = req.nfc_reader_type
  149. if req.nfc_connection:
  150. device.nfc_connection = req.nfc_connection
  151. # Return and clear pending command
  152. pending = device.pending_command
  153. pending_write = None
  154. if pending == "write_tag" and device.pending_write_payload:
  155. # Parse the stored JSON payload to include in response
  156. import json
  157. try:
  158. pending_write = json.loads(device.pending_write_payload)
  159. except (json.JSONDecodeError, TypeError):
  160. pending_write = None
  161. # Don't clear write_tag command — it gets cleared by write-result
  162. else:
  163. device.pending_command = None
  164. await db.commit()
  165. if was_offline:
  166. await ws_manager.broadcast(
  167. {
  168. "type": "spoolbuddy_online",
  169. "device_id": device.device_id,
  170. "hostname": device.hostname,
  171. }
  172. )
  173. return HeartbeatResponse(
  174. pending_command=pending,
  175. pending_write_payload=pending_write,
  176. tare_offset=device.tare_offset,
  177. calibration_factor=device.calibration_factor,
  178. display_brightness=device.display_brightness,
  179. display_blank_timeout=device.display_blank_timeout,
  180. )
  181. # --- NFC endpoints ---
  182. @router.post("/nfc/tag-scanned")
  183. async def nfc_tag_scanned(
  184. req: TagScannedRequest,
  185. db: AsyncSession = Depends(get_db),
  186. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  187. ):
  188. """RPi reports NFC tag detected — lookup spool and broadcast."""
  189. spool = await get_spool_by_tag(db, req.tag_uid, req.tray_uuid or "")
  190. if spool:
  191. await ws_manager.broadcast(
  192. {
  193. "type": "spoolbuddy_tag_matched",
  194. "device_id": req.device_id,
  195. "tag_uid": req.tag_uid,
  196. "spool": {
  197. "id": spool.id,
  198. "material": spool.material,
  199. "subtype": spool.subtype,
  200. "color_name": spool.color_name,
  201. "rgba": spool.rgba,
  202. "brand": spool.brand,
  203. "label_weight": spool.label_weight,
  204. "core_weight": spool.core_weight,
  205. "weight_used": spool.weight_used,
  206. },
  207. }
  208. )
  209. logger.info("SpoolBuddy tag matched: %s -> spool %d", req.tag_uid, spool.id)
  210. else:
  211. await ws_manager.broadcast(
  212. {
  213. "type": "spoolbuddy_unknown_tag",
  214. "device_id": req.device_id,
  215. "tag_uid": req.tag_uid,
  216. "sak": req.sak,
  217. "tag_type": req.tag_type,
  218. }
  219. )
  220. logger.info("SpoolBuddy unknown tag: %s", req.tag_uid)
  221. return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None}
  222. @router.post("/nfc/tag-removed")
  223. async def nfc_tag_removed(
  224. req: TagRemovedRequest,
  225. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  226. ):
  227. """RPi reports NFC tag removed — broadcast event."""
  228. await ws_manager.broadcast(
  229. {
  230. "type": "spoolbuddy_tag_removed",
  231. "device_id": req.device_id,
  232. "tag_uid": req.tag_uid,
  233. }
  234. )
  235. return {"status": "ok"}
  236. @router.post("/nfc/write-tag")
  237. async def nfc_write_tag(
  238. req: WriteTagRequest,
  239. db: AsyncSession = Depends(get_db),
  240. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  241. ):
  242. """Queue an NFC tag write command for a SpoolBuddy device."""
  243. import json
  244. from backend.app.models.spool import Spool
  245. from backend.app.services.opentag3d import encode_opentag3d
  246. # Find the spool
  247. result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
  248. spool = result.scalar_one_or_none()
  249. if not spool:
  250. raise HTTPException(status_code=404, detail="Spool not found")
  251. # Find the device
  252. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
  253. device = result.scalar_one_or_none()
  254. if not device:
  255. raise HTTPException(status_code=404, detail="Device not registered")
  256. # Encode OpenTag3D NDEF data
  257. ndef_data = encode_opentag3d(spool)
  258. # Store write payload and set pending command
  259. device.pending_write_payload = json.dumps(
  260. {
  261. "spool_id": spool.id,
  262. "ndef_data_hex": ndef_data.hex(),
  263. }
  264. )
  265. device.pending_command = "write_tag"
  266. await db.commit()
  267. logger.info("Write tag queued for device %s, spool %d (%d bytes)", req.device_id, spool.id, len(ndef_data))
  268. return {"status": "queued"}
  269. @router.post("/nfc/write-result")
  270. async def nfc_write_result(
  271. req: WriteTagResultRequest,
  272. db: AsyncSession = Depends(get_db),
  273. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  274. ):
  275. """Handle NFC tag write result from SpoolBuddy daemon."""
  276. # Find the device and clear pending state
  277. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id))
  278. device = result.scalar_one_or_none()
  279. if not device:
  280. raise HTTPException(status_code=404, detail="Device not registered")
  281. device.pending_command = None
  282. device.pending_write_payload = None
  283. if req.success:
  284. # Link the tag to the spool
  285. from backend.app.models.spool import Spool
  286. result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
  287. spool = result.scalar_one_or_none()
  288. if spool:
  289. spool.tag_uid = req.tag_uid.upper()
  290. spool.tag_type = "ntag"
  291. spool.data_origin = "opentag3d"
  292. spool.encode_time = datetime.now(timezone.utc)
  293. logger.info("Tag written and linked: spool %d -> tag %s", spool.id, req.tag_uid)
  294. await db.commit()
  295. await ws_manager.broadcast(
  296. {
  297. "type": "spoolbuddy_tag_written",
  298. "device_id": req.device_id,
  299. "spool_id": req.spool_id,
  300. "tag_uid": req.tag_uid,
  301. }
  302. )
  303. else:
  304. await db.commit()
  305. await ws_manager.broadcast(
  306. {
  307. "type": "spoolbuddy_tag_write_failed",
  308. "device_id": req.device_id,
  309. "spool_id": req.spool_id,
  310. "message": req.message,
  311. }
  312. )
  313. logger.warning("Tag write failed for device %s: %s", req.device_id, req.message)
  314. return {"status": "ok"}
  315. @router.post("/devices/{device_id}/cancel-write")
  316. async def cancel_write(
  317. device_id: str,
  318. db: AsyncSession = Depends(get_db),
  319. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  320. ):
  321. """Cancel a pending write-tag command."""
  322. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  323. device = result.scalar_one_or_none()
  324. if not device:
  325. raise HTTPException(status_code=404, detail="Device not registered")
  326. if device.pending_command == "write_tag":
  327. device.pending_command = None
  328. device.pending_write_payload = None
  329. await db.commit()
  330. logger.info("Write tag cancelled for device %s", device_id)
  331. return {"status": "ok"}
  332. # --- Scale endpoints ---
  333. @router.post("/scale/reading")
  334. async def scale_reading(
  335. req: ScaleReadingRequest,
  336. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  337. ):
  338. """RPi reports scale weight — broadcast to all clients."""
  339. await ws_manager.broadcast(
  340. {
  341. "type": "spoolbuddy_weight",
  342. "device_id": req.device_id,
  343. "weight_grams": req.weight_grams,
  344. "stable": req.stable,
  345. "raw_adc": req.raw_adc,
  346. }
  347. )
  348. return {"status": "ok"}
  349. @router.post("/scale/update-spool-weight")
  350. async def update_spool_weight(
  351. req: UpdateSpoolWeightRequest,
  352. db: AsyncSession = Depends(get_db),
  353. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  354. ):
  355. """Update spool's used weight from scale reading."""
  356. from backend.app.models.spool import Spool
  357. result = await db.execute(select(Spool).where(Spool.id == req.spool_id))
  358. spool = result.scalar_one_or_none()
  359. if not spool:
  360. raise HTTPException(status_code=404, detail="Spool not found")
  361. # net weight = total on scale minus empty spool core
  362. net_filament = max(0, req.weight_grams - spool.core_weight)
  363. spool.weight_used = max(0, spool.label_weight - net_filament)
  364. spool.last_scale_weight = req.weight_grams
  365. spool.last_weighed_at = datetime.now(timezone.utc)
  366. await db.commit()
  367. logger.info(
  368. "SpoolBuddy updated spool %d weight: %.1fg on scale, %.1fg used",
  369. spool.id,
  370. req.weight_grams,
  371. spool.weight_used,
  372. )
  373. return {"status": "ok", "weight_used": spool.weight_used}
  374. # --- Calibration endpoints ---
  375. @router.post("/devices/{device_id}/calibration/tare")
  376. async def tare_scale(
  377. device_id: str,
  378. db: AsyncSession = Depends(get_db),
  379. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  380. ):
  381. """Set pending tare command for the device to pick up."""
  382. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  383. device = result.scalar_one_or_none()
  384. if not device:
  385. raise HTTPException(status_code=404, detail="Device not registered")
  386. device.pending_command = "tare"
  387. await db.commit()
  388. return {"status": "ok", "message": "Tare command queued"}
  389. @router.post("/devices/{device_id}/calibration/set-tare")
  390. async def set_tare_offset(
  391. device_id: str,
  392. req: SetTareRequest,
  393. db: AsyncSession = Depends(get_db),
  394. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  395. ):
  396. """Store tare offset reported by the daemon after executing a tare."""
  397. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  398. device = result.scalar_one_or_none()
  399. if not device:
  400. raise HTTPException(status_code=404, detail="Device not registered")
  401. device.tare_offset = req.tare_offset
  402. device.last_calibrated_at = datetime.now(timezone.utc)
  403. await db.commit()
  404. logger.info("SpoolBuddy %s tare offset set to %d", device_id, req.tare_offset)
  405. return CalibrationResponse(
  406. tare_offset=device.tare_offset,
  407. calibration_factor=device.calibration_factor,
  408. )
  409. @router.post("/devices/{device_id}/calibration/set-factor")
  410. async def set_calibration_factor(
  411. device_id: str,
  412. req: SetCalibrationFactorRequest,
  413. db: AsyncSession = Depends(get_db),
  414. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  415. ):
  416. """Calculate and store calibration factor from a known weight."""
  417. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  418. device = result.scalar_one_or_none()
  419. if not device:
  420. raise HTTPException(status_code=404, detail="Device not registered")
  421. tare = req.tare_raw_adc if req.tare_raw_adc is not None else device.tare_offset
  422. raw_delta = req.raw_adc - tare
  423. if raw_delta == 0:
  424. raise HTTPException(status_code=400, detail="Raw ADC value equals tare offset — place weight on scale")
  425. device.calibration_factor = req.known_weight_grams / raw_delta
  426. if req.tare_raw_adc is not None:
  427. device.tare_offset = tare
  428. device.last_calibrated_at = datetime.now(timezone.utc)
  429. await db.commit()
  430. logger.info(
  431. "SpoolBuddy %s calibration factor set to %.6f (known=%.1fg, raw=%d, tare=%d)",
  432. device_id,
  433. device.calibration_factor,
  434. req.known_weight_grams,
  435. req.raw_adc,
  436. tare,
  437. )
  438. return CalibrationResponse(
  439. tare_offset=device.tare_offset,
  440. calibration_factor=device.calibration_factor,
  441. )
  442. @router.get("/devices/{device_id}/calibration", response_model=CalibrationResponse)
  443. async def get_calibration(
  444. device_id: str,
  445. db: AsyncSession = Depends(get_db),
  446. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  447. ):
  448. """Get current calibration values for a device."""
  449. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  450. device = result.scalar_one_or_none()
  451. if not device:
  452. raise HTTPException(status_code=404, detail="Device not registered")
  453. return CalibrationResponse(
  454. tare_offset=device.tare_offset,
  455. calibration_factor=device.calibration_factor,
  456. )
  457. # --- Display settings ---
  458. @router.put("/devices/{device_id}/display")
  459. async def update_display_settings(
  460. device_id: str,
  461. req: DisplaySettingsRequest,
  462. db: AsyncSession = Depends(get_db),
  463. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  464. ):
  465. """Update display brightness and screen blank timeout for a device."""
  466. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  467. device = result.scalar_one_or_none()
  468. if not device:
  469. raise HTTPException(status_code=404, detail="Device not registered")
  470. device.display_brightness = req.brightness
  471. device.display_blank_timeout = req.blank_timeout
  472. await db.commit()
  473. logger.info(
  474. "SpoolBuddy %s display updated: brightness=%d%%, blank_timeout=%ds",
  475. device_id,
  476. req.brightness,
  477. req.blank_timeout,
  478. )
  479. return {"status": "ok", "brightness": req.brightness, "blank_timeout": req.blank_timeout}
  480. # --- Update check ---
  481. @router.get("/devices/{device_id}/update-check")
  482. async def check_daemon_update(
  483. device_id: str,
  484. include_beta: bool = False,
  485. db: AsyncSession = Depends(get_db),
  486. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
  487. ):
  488. """Check if a newer daemon version is available on GitHub."""
  489. import httpx
  490. from backend.app.api.routes.updates import is_newer_version, parse_version
  491. from backend.app.core.config import GITHUB_REPO
  492. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  493. device = result.scalar_one_or_none()
  494. if not device:
  495. raise HTTPException(status_code=404, detail="Device not registered")
  496. current = device.firmware_version or "0.0.0"
  497. try:
  498. async with httpx.AsyncClient() as client:
  499. response = await client.get(
  500. f"https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=20",
  501. headers={"Accept": "application/vnd.github.v3+json"},
  502. timeout=10.0,
  503. )
  504. response.raise_for_status()
  505. releases = response.json()
  506. release_data = None
  507. for release in releases:
  508. tag = release.get("tag_name", "")
  509. if include_beta:
  510. release_data = release
  511. break
  512. else:
  513. parsed = parse_version(tag)
  514. if parsed[4] == 0: # is_prerelease == 0
  515. release_data = release
  516. break
  517. if not release_data:
  518. return {
  519. "current_version": current,
  520. "latest_version": None,
  521. "update_available": False,
  522. "release_url": None,
  523. }
  524. latest = release_data.get("tag_name", "").lstrip("v")
  525. return {
  526. "current_version": current,
  527. "latest_version": latest,
  528. "update_available": is_newer_version(latest, current),
  529. "release_url": release_data.get("html_url"),
  530. }
  531. except Exception as e:
  532. logger.warning("Failed to check for daemon updates: %s", e)
  533. return {
  534. "current_version": current,
  535. "latest_version": None,
  536. "update_available": False,
  537. "release_url": None,
  538. "error": str(e),
  539. }
  540. @router.post("/devices/{device_id}/update")
  541. async def trigger_daemon_update(
  542. device_id: str,
  543. db: AsyncSession = Depends(get_db),
  544. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  545. ):
  546. """Trigger a daemon update on the SpoolBuddy device via pending_command."""
  547. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  548. device = result.scalar_one_or_none()
  549. if not device:
  550. raise HTTPException(status_code=404, detail="Device not registered")
  551. if not _is_online(device):
  552. raise HTTPException(status_code=409, detail="Device is offline")
  553. if device.update_status == "updating":
  554. return {"status": "already_updating", "message": "Update already in progress"}
  555. device.pending_command = "update"
  556. device.update_status = "pending"
  557. device.update_message = "Waiting for device to pick up update command..."
  558. await db.commit()
  559. logger.info("SpoolBuddy %s: update command queued", device_id)
  560. await ws_manager.broadcast(
  561. {
  562. "type": "spoolbuddy_update",
  563. "device_id": device_id,
  564. "update_status": "pending",
  565. }
  566. )
  567. return {"status": "ok", "message": "Update command sent to device"}
  568. @router.post("/devices/{device_id}/update-status")
  569. async def report_update_status(
  570. device_id: str,
  571. req: dict,
  572. db: AsyncSession = Depends(get_db),
  573. _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
  574. ):
  575. """Daemon reports update progress back to the backend."""
  576. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
  577. device = result.scalar_one_or_none()
  578. if not device:
  579. raise HTTPException(status_code=404, detail="Device not registered")
  580. status = req.get("status", "")
  581. message = req.get("message", "")
  582. if status in ("updating", "complete", "error"):
  583. device.update_status = status
  584. device.update_message = message[:255] if message else None
  585. if status == "complete":
  586. device.pending_command = None
  587. await db.commit()
  588. logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, status, message)
  589. await ws_manager.broadcast(
  590. {
  591. "type": "spoolbuddy_update",
  592. "device_id": device_id,
  593. "update_status": status,
  594. "update_message": message,
  595. }
  596. )
  597. return {"status": "ok"}
  598. # --- Background watchdog ---
  599. async def spoolbuddy_watchdog():
  600. """Check for devices that have gone offline (no heartbeat for 30s).
  601. Called periodically from the main app's background task loop.
  602. """
  603. from backend.app.core.database import async_session
  604. async with async_session() as db:
  605. result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.last_seen.isnot(None)))
  606. devices = list(result.scalars().all())
  607. threshold = datetime.now(timezone.utc) - timedelta(seconds=OFFLINE_THRESHOLD_SECONDS)
  608. for device in devices:
  609. last_seen = device.last_seen.replace(tzinfo=timezone.utc) if device.last_seen else None
  610. if last_seen and last_seen < threshold:
  611. # Only broadcast once — clear last_seen after marking offline
  612. await ws_manager.broadcast(
  613. {
  614. "type": "spoolbuddy_offline",
  615. "device_id": device.device_id,
  616. }
  617. )
  618. device.last_seen = None
  619. logger.info("SpoolBuddy device offline: %s", device.device_id)
  620. await db.commit()