main.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. #!/usr/bin/env python3
  2. """SpoolBuddy daemon — reads NFC tags and scale, pushes events to Bambuddy backend."""
  3. import asyncio
  4. import logging
  5. import shutil
  6. import socket
  7. import sys
  8. import time
  9. from pathlib import Path
  10. # Add scripts/ to sys.path so hardware drivers (read_tag, scale_diag) are importable
  11. sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
  12. from . import __version__
  13. from .api_client import APIClient
  14. from .config import Config
  15. from .display_control import DisplayControl
  16. from .nfc_reader import NFCReader, NFCState
  17. from .scale_reader import ScaleReader
  18. logging.basicConfig(
  19. level=logging.INFO,
  20. format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
  21. datefmt="%H:%M:%S",
  22. )
  23. logger = logging.getLogger("spoolbuddy")
  24. def _get_ip() -> str:
  25. try:
  26. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  27. s.connect(("8.8.8.8", 80))
  28. ip = s.getsockname()[0]
  29. s.close()
  30. return ip
  31. except Exception:
  32. return "unknown"
  33. async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
  34. """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
  35. nfc: NFCReader = shared["nfc"]
  36. display: DisplayControl = shared["display"]
  37. if not nfc.ok:
  38. logger.warning("NFC reader not available, skipping NFC polling")
  39. return
  40. try:
  41. while True:
  42. event_type, event_data = await asyncio.to_thread(nfc.poll)
  43. if event_type == "tag_detected":
  44. display.wake()
  45. await api.tag_scanned(
  46. device_id=config.device_id,
  47. tag_uid=event_data["tag_uid"],
  48. tray_uuid=event_data.get("tray_uuid"),
  49. sak=event_data.get("sak"),
  50. tag_type=event_data.get("tag_type"),
  51. )
  52. elif event_type == "tag_removed":
  53. await api.tag_removed(
  54. device_id=config.device_id,
  55. tag_uid=event_data["tag_uid"],
  56. )
  57. # Check for pending write command
  58. pending = shared.get("pending_write")
  59. if pending and nfc.state == NFCState.TAG_PRESENT and nfc.current_sak == 0x00:
  60. logger.info("Executing pending tag write for spool %d", pending["spool_id"])
  61. success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"])
  62. await api.write_tag_result(
  63. device_id=config.device_id,
  64. spool_id=pending["spool_id"],
  65. tag_uid=nfc.current_uid or "",
  66. success=success,
  67. message=msg,
  68. )
  69. shared.pop("pending_write", None)
  70. await asyncio.sleep(config.nfc_poll_interval)
  71. finally:
  72. nfc.close()
  73. async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
  74. """Continuous scale reading loop — reads at 100ms, reports at 1s intervals."""
  75. scale: ScaleReader = shared["scale"]
  76. display: DisplayControl = shared["display"]
  77. if not scale.ok:
  78. logger.warning("Scale not available, skipping scale polling")
  79. return
  80. last_report = 0.0
  81. last_reported_grams: float | None = None
  82. REPORT_THRESHOLD = 2.0 # Only report if weight changed by more than this (grams)
  83. try:
  84. while True:
  85. result = await asyncio.to_thread(scale.read)
  86. if result is not None:
  87. grams, stable, raw_adc = result
  88. now = time.monotonic()
  89. if now - last_report >= config.scale_report_interval:
  90. # Only send when weight changed meaningfully
  91. weight_changed = last_reported_grams is None or abs(grams - last_reported_grams) >= REPORT_THRESHOLD
  92. if weight_changed:
  93. display.wake()
  94. await api.scale_reading(
  95. device_id=config.device_id,
  96. weight_grams=grams,
  97. stable=stable,
  98. raw_adc=raw_adc,
  99. )
  100. last_reported_grams = grams
  101. last_report = now
  102. await asyncio.sleep(config.scale_read_interval)
  103. finally:
  104. scale.close()
  105. async def _perform_update(config: Config, api: APIClient):
  106. """Pull latest code from git, install deps, then exit for systemd restart."""
  107. # Determine repo root (install path) — daemon runs from <repo>/spoolbuddy/
  108. repo_root = Path(__file__).resolve().parent.parent.parent
  109. await api.report_update_status(config.device_id, "updating", "Fetching latest code...")
  110. git_path = shutil.which("git") or "/usr/bin/git"
  111. git_config = ["-c", f"safe.directory={repo_root}"]
  112. # git fetch origin main
  113. proc = await asyncio.create_subprocess_exec(
  114. git_path,
  115. *git_config,
  116. "fetch",
  117. "origin",
  118. "main",
  119. cwd=str(repo_root),
  120. stdout=asyncio.subprocess.PIPE,
  121. stderr=asyncio.subprocess.PIPE,
  122. )
  123. _, stderr = await proc.communicate()
  124. if proc.returncode != 0:
  125. msg = f"git fetch failed: {stderr.decode()[:200]}"
  126. logger.error(msg)
  127. await api.report_update_status(config.device_id, "error", msg)
  128. return
  129. await api.report_update_status(config.device_id, "updating", "Applying update...")
  130. # git reset --hard origin/main
  131. proc = await asyncio.create_subprocess_exec(
  132. git_path,
  133. *git_config,
  134. "reset",
  135. "--hard",
  136. "origin/main",
  137. cwd=str(repo_root),
  138. stdout=asyncio.subprocess.PIPE,
  139. stderr=asyncio.subprocess.PIPE,
  140. )
  141. _, stderr = await proc.communicate()
  142. if proc.returncode != 0:
  143. msg = f"git reset failed: {stderr.decode()[:200]}"
  144. logger.error(msg)
  145. await api.report_update_status(config.device_id, "error", msg)
  146. return
  147. await api.report_update_status(config.device_id, "updating", "Installing dependencies...")
  148. # pip install daemon deps (use the venv pip)
  149. venv_pip = repo_root / "spoolbuddy" / "venv" / "bin" / "pip"
  150. pip_packages = ["spidev", "gpiod", "smbus2", "httpx"]
  151. if venv_pip.exists():
  152. proc = await asyncio.create_subprocess_exec(
  153. str(venv_pip),
  154. "install",
  155. "--upgrade",
  156. *pip_packages,
  157. cwd=str(repo_root),
  158. stdout=asyncio.subprocess.PIPE,
  159. stderr=asyncio.subprocess.PIPE,
  160. )
  161. else:
  162. proc = await asyncio.create_subprocess_exec(
  163. sys.executable,
  164. "-m",
  165. "pip",
  166. "install",
  167. "--upgrade",
  168. *pip_packages,
  169. cwd=str(repo_root),
  170. stdout=asyncio.subprocess.PIPE,
  171. stderr=asyncio.subprocess.PIPE,
  172. )
  173. await proc.communicate()
  174. if proc.returncode != 0:
  175. logger.warning("pip install returned non-zero (continuing anyway)")
  176. await api.report_update_status(config.device_id, "complete", "Update complete, restarting...")
  177. logger.info("Update complete, exiting for systemd restart")
  178. # Exit cleanly — systemd Restart=always will bring us back with the new code
  179. sys.exit(0)
  180. async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shared: dict):
  181. """Periodic heartbeat to keep device registered and pick up commands."""
  182. display: DisplayControl = shared["display"]
  183. ip = _get_ip()
  184. while True:
  185. await asyncio.sleep(config.heartbeat_interval)
  186. nfc = shared.get("nfc")
  187. scale = shared.get("scale")
  188. uptime = int(time.monotonic() - start_time)
  189. result = await api.heartbeat(
  190. device_id=config.device_id,
  191. nfc_ok=nfc.ok if nfc else False,
  192. scale_ok=scale.ok if scale else False,
  193. uptime_s=uptime,
  194. ip_address=ip,
  195. firmware_version=__version__,
  196. nfc_reader_type=nfc.reader_type if nfc else None,
  197. nfc_connection=nfc.connection if nfc else None,
  198. )
  199. if result:
  200. cmd = result.get("pending_command")
  201. if cmd == "update":
  202. logger.info("Update command received, starting update...")
  203. try:
  204. await _perform_update(config, api)
  205. except Exception as e:
  206. logger.error("Update failed: %s", e)
  207. await api.report_update_status(config.device_id, "error", str(e)[:255])
  208. continue
  209. elif cmd == "tare":
  210. scale = shared.get("scale")
  211. if scale and scale.ok:
  212. new_offset = await asyncio.to_thread(scale.tare)
  213. logger.info("Tare executed: offset=%d", new_offset)
  214. await api.update_tare(config.device_id, new_offset)
  215. config.tare_offset = new_offset
  216. else:
  217. logger.warning("Tare command received but scale not available")
  218. # Skip calibration sync — this heartbeat response predates the tare
  219. continue
  220. elif cmd == "write_tag":
  221. write_payload = result.get("pending_write_payload")
  222. if write_payload:
  223. shared["pending_write"] = {
  224. "spool_id": write_payload["spool_id"],
  225. "ndef_data": bytes.fromhex(write_payload["ndef_data_hex"]),
  226. }
  227. logger.info("Write tag command received for spool %d", write_payload["spool_id"])
  228. tare = result.get("tare_offset", config.tare_offset)
  229. cal = result.get("calibration_factor", config.calibration_factor)
  230. if tare != config.tare_offset or cal != config.calibration_factor:
  231. config.tare_offset = tare
  232. config.calibration_factor = cal
  233. scale = shared.get("scale")
  234. if scale:
  235. scale.update_calibration(tare, cal)
  236. logger.info("Calibration updated from backend: tare=%d, factor=%.6f", tare, cal)
  237. # Apply display settings from backend
  238. brightness = result.get("display_brightness")
  239. blank_timeout = result.get("display_blank_timeout")
  240. if brightness is not None:
  241. display.set_brightness(brightness)
  242. if blank_timeout is not None:
  243. display.set_blank_timeout(blank_timeout)
  244. display.tick()
  245. async def main():
  246. config = Config.load()
  247. logger.info(
  248. "SpoolBuddy daemon v%s starting (device=%s, backend=%s)", __version__, config.device_id, config.backend_url
  249. )
  250. api = APIClient(config.backend_url, config.api_key)
  251. ip = _get_ip()
  252. start_time = time.monotonic()
  253. # Initialize hardware before registration so we can report capabilities
  254. nfc = NFCReader()
  255. scale = ScaleReader(
  256. tare_offset=config.tare_offset,
  257. calibration_factor=config.calibration_factor,
  258. )
  259. display = DisplayControl()
  260. # Register with backend (retries until success)
  261. reg = await api.register_device(
  262. device_id=config.device_id,
  263. hostname=config.hostname,
  264. ip_address=ip,
  265. firmware_version=__version__,
  266. has_nfc=True,
  267. has_scale=True,
  268. tare_offset=config.tare_offset,
  269. calibration_factor=config.calibration_factor,
  270. nfc_reader_type=nfc.reader_type,
  271. nfc_connection=nfc.connection,
  272. has_backlight=display.has_backlight,
  273. )
  274. # Use server-side calibration if available
  275. if reg:
  276. config.tare_offset = reg.get("tare_offset", config.tare_offset)
  277. config.calibration_factor = reg.get("calibration_factor", config.calibration_factor)
  278. scale.update_calibration(config.tare_offset, config.calibration_factor)
  279. logger.info("Device registered, starting poll loops")
  280. shared: dict = {"nfc": nfc, "scale": scale, "display": display}
  281. try:
  282. await asyncio.gather(
  283. nfc_poll_loop(config, api, shared),
  284. scale_poll_loop(config, api, shared),
  285. heartbeat_loop(config, api, start_time, shared),
  286. )
  287. except KeyboardInterrupt:
  288. logger.info("Shutting down")
  289. finally:
  290. await api.close()
  291. if __name__ == "__main__":
  292. asyncio.run(main())