main.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  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 os
  6. import shutil
  7. import socket
  8. import subprocess
  9. import sys
  10. import time
  11. from pathlib import Path
  12. # Add scripts/ to sys.path so hardware drivers (read_tag, scale_diag) are importable
  13. sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
  14. from . import __version__
  15. from .api_client import APIClient
  16. from .config import Config
  17. from .display_control import DisplayControl
  18. from .nfc_reader import NFCReader, NFCState
  19. from .scale_reader import ScaleReader
  20. logging.basicConfig(
  21. level=logging.INFO,
  22. format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
  23. datefmt="%H:%M:%S",
  24. )
  25. logger = logging.getLogger("spoolbuddy")
  26. def _spoolbuddy_env_path() -> Path:
  27. # installer writes this at <install>/spoolbuddy/.env; allow override for custom setups/tests
  28. override = os.environ.get("SPOOLBUDDY_ENV_FILE", "").strip()
  29. if override:
  30. return Path(override)
  31. return Path(__file__).resolve().parent.parent / ".env"
  32. def _set_env_value(path: Path, key: str, value: str):
  33. lines: list[str] = []
  34. if path.exists():
  35. lines = path.read_text(encoding="utf-8").splitlines()
  36. updated = False
  37. new_lines: list[str] = []
  38. for line in lines:
  39. if line.startswith(f"{key}="):
  40. new_lines.append(f"{key}={value}")
  41. updated = True
  42. else:
  43. new_lines.append(line)
  44. if not updated:
  45. new_lines.append(f"{key}={value}")
  46. path.parent.mkdir(parents=True, exist_ok=True)
  47. path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
  48. def _get_ip() -> str:
  49. try:
  50. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  51. s.connect(("8.8.8.8", 80))
  52. ip = s.getsockname()[0]
  53. s.close()
  54. return ip
  55. except Exception:
  56. return "unknown"
  57. def _deploy_ssh_key(public_key: str) -> None:
  58. """Write Bambuddy's SSH public key to authorized_keys if not already present."""
  59. home = Path.home()
  60. ssh_dir = home / ".ssh"
  61. auth_keys = ssh_dir / "authorized_keys"
  62. try:
  63. ssh_dir.mkdir(mode=0o700, exist_ok=True)
  64. # Check if key already deployed
  65. if auth_keys.exists():
  66. existing = auth_keys.read_text()
  67. if public_key.strip() in existing:
  68. return
  69. # Append key
  70. with auth_keys.open("a") as f:
  71. f.write(public_key.strip() + "\n")
  72. auth_keys.chmod(0o600)
  73. logger.info("SSH public key deployed to %s", auth_keys)
  74. except Exception as e:
  75. logger.warning("Failed to deploy SSH key: %s", e)
  76. async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
  77. """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
  78. display: DisplayControl = shared["display"]
  79. try:
  80. while True:
  81. if shared.get("nfc_scan_paused", False):
  82. await asyncio.sleep(config.nfc_poll_interval)
  83. continue
  84. nfc: NFCReader | None = shared.get("nfc")
  85. if not nfc or not nfc.ok:
  86. await asyncio.sleep(config.nfc_poll_interval)
  87. continue
  88. event_type, event_data = await asyncio.to_thread(nfc.poll)
  89. if event_type == "tag_detected":
  90. display.wake()
  91. await api.tag_scanned(
  92. device_id=config.device_id,
  93. tag_uid=event_data["tag_uid"],
  94. tray_uuid=event_data.get("tray_uuid"),
  95. sak=event_data.get("sak"),
  96. tag_type=event_data.get("tag_type"),
  97. )
  98. elif event_type == "tag_removed":
  99. await api.tag_removed(
  100. device_id=config.device_id,
  101. tag_uid=event_data["tag_uid"],
  102. )
  103. # Check for pending write command
  104. pending = shared.get("pending_write")
  105. if pending and nfc.state == NFCState.TAG_PRESENT:
  106. if nfc.current_sak == 0x00:
  107. logger.info("Executing pending tag write for spool %d", pending["spool_id"])
  108. success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"])
  109. await api.write_tag_result(
  110. device_id=config.device_id,
  111. spool_id=pending["spool_id"],
  112. tag_uid=nfc.current_uid or "",
  113. success=success,
  114. message=msg,
  115. )
  116. shared.pop("pending_write", None)
  117. else:
  118. # Fail fast when a non-NTAG is presented during write mode.
  119. # Without this, UI can appear stuck on "waiting for SpoolBuddy".
  120. sak = nfc.current_sak
  121. await api.write_tag_result(
  122. device_id=config.device_id,
  123. spool_id=pending["spool_id"],
  124. tag_uid=nfc.current_uid or "",
  125. success=False,
  126. message=f"Incompatible tag type (SAK=0x{sak:02X}). Place an NTAG tag to write.",
  127. )
  128. logger.warning(
  129. "Write aborted for spool %d: incompatible tag type SAK=0x%02X",
  130. pending["spool_id"],
  131. sak,
  132. )
  133. shared.pop("pending_write", None)
  134. await asyncio.sleep(config.nfc_poll_interval)
  135. finally:
  136. nfc: NFCReader | None = shared.get("nfc")
  137. if nfc:
  138. nfc.close()
  139. async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
  140. """Continuous scale reading loop — reads at 100ms, reports at 1s intervals."""
  141. scale: ScaleReader = shared["scale"]
  142. display: DisplayControl = shared["display"]
  143. if not scale.ok:
  144. logger.warning("Scale not available, skipping scale polling")
  145. return
  146. last_report = 0.0
  147. last_reported_grams: float | None = None
  148. REPORT_THRESHOLD = 2.0 # Only report if weight changed by more than this (grams)
  149. try:
  150. while True:
  151. result = await asyncio.to_thread(scale.read)
  152. if result is not None:
  153. grams, stable, raw_adc = result
  154. now = time.monotonic()
  155. if now - last_report >= config.scale_report_interval:
  156. # Only send when weight changed meaningfully
  157. weight_changed = last_reported_grams is None or abs(grams - last_reported_grams) >= REPORT_THRESHOLD
  158. if weight_changed:
  159. display.wake()
  160. await api.scale_reading(
  161. device_id=config.device_id,
  162. weight_grams=grams,
  163. stable=stable,
  164. raw_adc=raw_adc,
  165. )
  166. last_reported_grams = grams
  167. last_report = now
  168. await asyncio.sleep(config.scale_read_interval)
  169. finally:
  170. scale.close()
  171. async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shared: dict):
  172. """Periodic heartbeat to keep device registered and pick up commands."""
  173. display: DisplayControl = shared["display"]
  174. ip = _get_ip()
  175. while True:
  176. await asyncio.sleep(config.heartbeat_interval)
  177. nfc = shared.get("nfc")
  178. scale = shared.get("scale")
  179. uptime = int(time.monotonic() - start_time)
  180. result = await api.heartbeat(
  181. device_id=config.device_id,
  182. nfc_ok=nfc.ok if nfc else False,
  183. scale_ok=scale.ok if scale else False,
  184. uptime_s=uptime,
  185. ip_address=ip,
  186. firmware_version=__version__,
  187. nfc_reader_type=nfc.reader_type if nfc else None,
  188. nfc_connection=nfc.connection if nfc else None,
  189. backend_url=config.backend_url,
  190. )
  191. if result:
  192. cmd = result.get("pending_command")
  193. if cmd == "tare":
  194. scale = shared.get("scale")
  195. if scale and scale.ok:
  196. new_offset = await asyncio.to_thread(scale.tare)
  197. logger.info("Tare executed: offset=%d", new_offset)
  198. await api.update_tare(config.device_id, new_offset)
  199. config.tare_offset = new_offset
  200. else:
  201. logger.warning("Tare command received but scale not available")
  202. # Skip calibration sync — this heartbeat response predates the tare
  203. continue
  204. elif cmd == "apply_system_config":
  205. payload = result.get("pending_system_payload") or {}
  206. backend_url = str(payload.get("backend_url", "")).strip()
  207. api_key_value = payload.get("api_key")
  208. api_key = str(api_key_value).strip() if api_key_value is not None else ""
  209. if not backend_url:
  210. await api.system_command_result(
  211. config.device_id,
  212. "apply_system_config",
  213. False,
  214. "Missing backend_url payload",
  215. )
  216. continue
  217. try:
  218. env_path = _spoolbuddy_env_path()
  219. await asyncio.to_thread(_set_env_value, env_path, "SPOOLBUDDY_BACKEND_URL", backend_url)
  220. if api_key:
  221. await asyncio.to_thread(_set_env_value, env_path, "SPOOLBUDDY_API_KEY", api_key)
  222. await api.system_command_result(
  223. config.device_id,
  224. "apply_system_config",
  225. True,
  226. f"Updated {env_path}",
  227. )
  228. logger.info("Applied system config update")
  229. except Exception as e:
  230. logger.exception("Failed to apply system config")
  231. await api.system_command_result(
  232. config.device_id,
  233. "apply_system_config",
  234. False,
  235. str(e),
  236. )
  237. continue
  238. elif cmd in ("run_nfc_diag", "run_scale_diag", "run_read_tag_diag"):
  239. if cmd == "run_scale_diag":
  240. diagnostic = "scale"
  241. script_name = "scale_diag.py"
  242. elif cmd == "run_read_tag_diag":
  243. diagnostic = "read_tag"
  244. script_name = "read_tag.py"
  245. else:
  246. diagnostic = "nfc"
  247. script_name = "pn5180_diag.py"
  248. script_path = Path(__file__).resolve().parent.parent / "scripts" / script_name
  249. if diagnostic in ("nfc", "read_tag"):
  250. logger.info("Pausing NFC continuous scan for diagnostic")
  251. shared["nfc_scan_paused"] = True
  252. nfc_for_diag = shared.get("nfc")
  253. if nfc_for_diag:
  254. await asyncio.to_thread(nfc_for_diag.close)
  255. shared["nfc"] = None
  256. logger.info("Running %s diagnostic via %s", diagnostic, script_path)
  257. try:
  258. proc = await asyncio.to_thread(
  259. subprocess.run,
  260. [sys.executable, str(script_path)],
  261. capture_output=True,
  262. text=True,
  263. timeout=45,
  264. )
  265. output = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "")
  266. await api.diagnostic_result(
  267. config.device_id,
  268. diagnostic,
  269. proc.returncode == 0,
  270. output,
  271. proc.returncode,
  272. )
  273. except subprocess.TimeoutExpired:
  274. await api.diagnostic_result(
  275. config.device_id,
  276. diagnostic,
  277. False,
  278. "Diagnostic timed out after 45 seconds",
  279. -1,
  280. )
  281. except Exception as e:
  282. await api.diagnostic_result(
  283. config.device_id,
  284. diagnostic,
  285. False,
  286. f"Diagnostic execution failed: {e}",
  287. -1,
  288. )
  289. finally:
  290. if diagnostic in ("nfc", "read_tag"):
  291. logger.info("Reinitializing NFC continuous scan after diagnostic")
  292. shared["nfc"] = NFCReader()
  293. shared["nfc_scan_paused"] = False
  294. continue
  295. elif cmd == "write_tag":
  296. write_payload = result.get("pending_write_payload")
  297. if write_payload:
  298. shared["pending_write"] = {
  299. "spool_id": write_payload["spool_id"],
  300. "ndef_data": bytes.fromhex(write_payload["ndef_data_hex"]),
  301. }
  302. logger.info("Write tag command received for spool %d", write_payload["spool_id"])
  303. tare = result.get("tare_offset", config.tare_offset)
  304. cal = result.get("calibration_factor", config.calibration_factor)
  305. if tare != config.tare_offset or cal != config.calibration_factor:
  306. config.tare_offset = tare
  307. config.calibration_factor = cal
  308. scale = shared.get("scale")
  309. if scale:
  310. scale.update_calibration(tare, cal)
  311. logger.info("Calibration updated from backend: tare=%d, factor=%.6f", tare, cal)
  312. # Apply display settings from backend
  313. brightness = result.get("display_brightness")
  314. blank_timeout = result.get("display_blank_timeout")
  315. if brightness is not None:
  316. display.set_brightness(brightness)
  317. if blank_timeout is not None:
  318. display.set_blank_timeout(blank_timeout)
  319. display.tick()
  320. async def main():
  321. config = Config.load()
  322. logger.info(
  323. "SpoolBuddy daemon v%s starting (device=%s, backend=%s)", __version__, config.device_id, config.backend_url
  324. )
  325. api = APIClient(config.backend_url, config.api_key)
  326. ip = _get_ip()
  327. start_time = time.monotonic()
  328. # Initialize hardware before registration so we can report capabilities
  329. nfc = NFCReader()
  330. scale = ScaleReader(
  331. tare_offset=config.tare_offset,
  332. calibration_factor=config.calibration_factor,
  333. )
  334. display = DisplayControl()
  335. # Register with backend (retries until success)
  336. reg = await api.register_device(
  337. device_id=config.device_id,
  338. hostname=config.hostname,
  339. ip_address=ip,
  340. firmware_version=__version__,
  341. has_nfc=True,
  342. has_scale=True,
  343. tare_offset=config.tare_offset,
  344. calibration_factor=config.calibration_factor,
  345. nfc_reader_type=nfc.reader_type,
  346. nfc_connection=nfc.connection,
  347. backend_url=config.backend_url,
  348. has_backlight=display.has_backlight,
  349. )
  350. # Use server-side calibration if available
  351. if reg:
  352. config.tare_offset = reg.get("tare_offset", config.tare_offset)
  353. config.calibration_factor = reg.get("calibration_factor", config.calibration_factor)
  354. scale.update_calibration(config.tare_offset, config.calibration_factor)
  355. # Auto-deploy Bambuddy's SSH public key for remote updates
  356. ssh_key = reg.get("ssh_public_key")
  357. if ssh_key:
  358. _deploy_ssh_key(ssh_key)
  359. logger.info("Device registered, starting poll loops")
  360. shared: dict = {"nfc": nfc, "scale": scale, "display": display, "nfc_scan_paused": False}
  361. try:
  362. await asyncio.gather(
  363. nfc_poll_loop(config, api, shared),
  364. scale_poll_loop(config, api, shared),
  365. heartbeat_loop(config, api, start_time, shared),
  366. )
  367. except KeyboardInterrupt:
  368. logger.info("Shutting down")
  369. finally:
  370. await api.close()
  371. if __name__ == "__main__":
  372. asyncio.run(main())