main.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  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 socket
  7. import subprocess
  8. import sys
  9. import time
  10. from pathlib import Path
  11. from . import __version__, system_stats
  12. from .api_client import WRITE_RESULT_PATH, APIClient
  13. from .config import Config
  14. from .display_control import DisplayControl
  15. from .nfc_reader import NFCReader, NFCState
  16. from .scale_reader import ScaleReader
  17. logging.basicConfig(
  18. level=logging.INFO,
  19. format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
  20. datefmt="%H:%M:%S",
  21. )
  22. logger = logging.getLogger("spoolbuddy")
  23. logging.getLogger("daemon.pn5180").setLevel(logging.DEBUG)
  24. def _spoolbuddy_env_path() -> Path:
  25. # installer writes this at <install>/spoolbuddy/.env; allow override for custom setups/tests
  26. override = os.environ.get("SPOOLBUDDY_ENV_FILE", "").strip()
  27. if override:
  28. return Path(override)
  29. return Path(__file__).resolve().parent.parent / ".env"
  30. def _set_env_value(path: Path, key: str, value: str):
  31. lines: list[str] = []
  32. if path.exists():
  33. lines = path.read_text(encoding="utf-8").splitlines()
  34. updated = False
  35. new_lines: list[str] = []
  36. for line in lines:
  37. if line.startswith(f"{key}="):
  38. new_lines.append(f"{key}={value}")
  39. updated = True
  40. else:
  41. new_lines.append(line)
  42. if not updated:
  43. new_lines.append(f"{key}={value}")
  44. path.parent.mkdir(parents=True, exist_ok=True)
  45. path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
  46. def _get_ip() -> str:
  47. try:
  48. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  49. s.connect(("8.8.8.8", 80))
  50. ip = s.getsockname()[0]
  51. s.close()
  52. return ip
  53. except Exception:
  54. return "unknown"
  55. SSH_KEY_TAG = "bambuddy-spoolbuddy"
  56. def _deploy_ssh_key(public_key: str) -> None:
  57. """Sync Bambuddy's SSH public key into authorized_keys.
  58. Replaces any prior key tagged ``bambuddy-spoolbuddy`` so the file always
  59. reflects Bambuddy's *current* keypair. Without this, every Bambuddy key
  60. rotation (data dir wipe, container recreate, etc.) leaves a stale entry
  61. behind and the file grows unbounded.
  62. """
  63. target = public_key.strip()
  64. home = Path.home()
  65. ssh_dir = home / ".ssh"
  66. auth_keys = ssh_dir / "authorized_keys"
  67. try:
  68. ssh_dir.mkdir(mode=0o700, exist_ok=True)
  69. existing_lines: list[str] = []
  70. if auth_keys.exists():
  71. existing_lines = auth_keys.read_text().splitlines()
  72. kept = [line for line in existing_lines if SSH_KEY_TAG not in line]
  73. new_lines = kept + [target]
  74. # Already in sync — current key present and no stale Bambuddy entries.
  75. if existing_lines == new_lines:
  76. return
  77. auth_keys.write_text("\n".join(new_lines) + "\n")
  78. auth_keys.chmod(0o600)
  79. removed = len(existing_lines) - len(kept)
  80. if removed:
  81. logger.info("SSH public key updated in %s (replaced %d stale entries)", auth_keys, removed)
  82. else:
  83. logger.info("SSH public key deployed to %s", auth_keys)
  84. except Exception as e:
  85. logger.warning("Failed to deploy SSH key: %s", e)
  86. async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
  87. """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
  88. display: DisplayControl = shared["display"]
  89. try:
  90. while True:
  91. if shared.get("nfc_scan_paused", False):
  92. await asyncio.sleep(config.nfc_poll_interval)
  93. continue
  94. nfc: NFCReader | None = shared.get("nfc")
  95. if not nfc or not nfc.ok:
  96. await asyncio.sleep(config.nfc_poll_interval)
  97. continue
  98. event_type, event_data = await asyncio.to_thread(nfc.poll)
  99. if event_type == "tag_detected":
  100. display.wake()
  101. await api.tag_scanned(
  102. device_id=config.device_id,
  103. tag_uid=event_data["tag_uid"],
  104. tray_uuid=event_data.get("tray_uuid"),
  105. sak=event_data.get("sak"),
  106. tag_type=event_data.get("tag_type"),
  107. raw_blocks=event_data.get("raw_blocks"),
  108. )
  109. elif event_type == "tag_removed":
  110. await api.tag_removed(
  111. device_id=config.device_id,
  112. tag_uid=event_data["tag_uid"],
  113. )
  114. # Check for pending write command
  115. pending = shared.get("pending_write")
  116. if pending and nfc.state == NFCState.TAG_PRESENT:
  117. if nfc.current_sak in (0x00, 0x04):
  118. logger.info("Executing pending tag write for spool %d", pending["spool_id"])
  119. success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"], pending.get("ndef_v2"))
  120. await api.write_tag_result(
  121. device_id=config.device_id,
  122. spool_id=pending["spool_id"],
  123. tag_uid=nfc.current_uid or "",
  124. success=success,
  125. message=msg,
  126. )
  127. shared.pop("pending_write", None)
  128. if api.has_buffered(WRITE_RESULT_PATH):
  129. # The backend keeps the write command until it has the
  130. # result, so heartbeats keep bringing it back meanwhile
  131. # (#3076). See the write_tag handling in heartbeat_loop.
  132. shared["write_result_unsent"] = pending["spool_id"]
  133. else:
  134. # Fail fast when a non-NTAG is presented during write mode.
  135. # Without this, UI can appear stuck on "waiting for SpoolBuddy".
  136. sak = nfc.current_sak
  137. await api.write_tag_result(
  138. device_id=config.device_id,
  139. spool_id=pending["spool_id"],
  140. tag_uid=nfc.current_uid or "",
  141. success=False,
  142. message=f"Incompatible tag type (SAK=0x{sak:02X}). Place an NTAG tag to write.",
  143. )
  144. logger.warning(
  145. "Write aborted for spool %d: incompatible tag type SAK=0x%02X",
  146. pending["spool_id"],
  147. sak,
  148. )
  149. shared.pop("pending_write", None)
  150. await asyncio.sleep(config.nfc_poll_interval)
  151. finally:
  152. nfc: NFCReader | None = shared.get("nfc")
  153. if nfc:
  154. nfc.close()
  155. async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
  156. """Continuous scale reading loop — reads at 100ms, reports at 1s intervals."""
  157. scale: ScaleReader = shared["scale"]
  158. display: DisplayControl = shared["display"]
  159. if not scale.ok:
  160. logger.warning("Scale not available, skipping scale polling")
  161. return
  162. last_report = 0.0
  163. last_reported_grams: float | None = None
  164. last_wake_grams: float | None = None
  165. REPORT_THRESHOLD = 2.0 # Only report if weight changed by more than this (grams)
  166. WAKE_THRESHOLD = 50.0 # Only wake display on large changes (spool placed/removed, not sensor bounce)
  167. try:
  168. while True:
  169. result = await asyncio.to_thread(scale.read)
  170. if result is not None:
  171. grams, stable, raw_adc = result
  172. now = time.monotonic()
  173. if now - last_report >= config.scale_report_interval:
  174. # Only send when weight changed meaningfully
  175. weight_changed = last_reported_grams is None or abs(grams - last_reported_grams) >= REPORT_THRESHOLD
  176. if weight_changed:
  177. # Wake display only on STABLE large weight changes (spool
  178. # placed/removed). Without the stability gate, a noisy load
  179. # cell that bounces ≥50g around a midpoint repeatedly trips
  180. # the threshold AND advances last_wake_grams to the noisy
  181. # value, so the next bounce back also exceeds the threshold
  182. # — wake fires every few seconds forever and the kiosk
  183. # screen never stays blanked. The `stable` flag is True
  184. # only when readings agree within 2g over a 1s window, so
  185. # gating on it ensures last_wake_grams only advances to
  186. # settled values.
  187. wake_changed = stable and (
  188. last_wake_grams is None or abs(grams - last_wake_grams) >= WAKE_THRESHOLD
  189. )
  190. if wake_changed:
  191. display.wake()
  192. last_wake_grams = grams
  193. await api.scale_reading(
  194. device_id=config.device_id,
  195. weight_grams=grams,
  196. stable=stable,
  197. raw_adc=raw_adc,
  198. )
  199. last_reported_grams = grams
  200. last_report = now
  201. await asyncio.sleep(config.scale_read_interval)
  202. finally:
  203. scale.close()
  204. async def _register(config: Config, api: APIClient, nfc, scale, display, ip: str) -> None:
  205. """Register with the backend (retries until it answers) and take its
  206. calibration. At startup, and again when the device was unregistered."""
  207. reg = await api.register_device(
  208. device_id=config.device_id,
  209. hostname=config.hostname,
  210. ip_address=ip,
  211. firmware_version=__version__,
  212. has_nfc=True,
  213. has_scale=True,
  214. tare_offset=config.tare_offset,
  215. calibration_factor=config.calibration_factor,
  216. nfc_reader_type=nfc.reader_type if nfc else None,
  217. nfc_connection=nfc.connection if nfc else None,
  218. backend_url=config.backend_url,
  219. has_backlight=display.has_backlight,
  220. )
  221. # Use server-side calibration if available
  222. if reg:
  223. config.tare_offset = reg.get("tare_offset", config.tare_offset)
  224. config.calibration_factor = reg.get("calibration_factor", config.calibration_factor)
  225. if scale:
  226. scale.update_calibration(config.tare_offset, config.calibration_factor)
  227. # Auto-deploy Bambuddy's SSH public key for remote updates
  228. ssh_key = reg.get("ssh_public_key")
  229. if ssh_key:
  230. _deploy_ssh_key(ssh_key)
  231. async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shared: dict):
  232. """Periodic heartbeat to keep device registered and pick up commands."""
  233. display: DisplayControl = shared["display"]
  234. ip = _get_ip()
  235. while True:
  236. await asyncio.sleep(config.heartbeat_interval)
  237. nfc = shared.get("nfc")
  238. scale = shared.get("scale")
  239. uptime = int(time.monotonic() - start_time)
  240. stats = await asyncio.to_thread(system_stats.collect)
  241. result = await api.heartbeat(
  242. device_id=config.device_id,
  243. nfc_ok=nfc.ok if nfc else False,
  244. scale_ok=scale.ok if scale else False,
  245. uptime_s=uptime,
  246. ip_address=ip,
  247. firmware_version=__version__,
  248. nfc_reader_type=nfc.reader_type if nfc else None,
  249. nfc_connection=nfc.connection if nfc else None,
  250. backend_url=config.backend_url,
  251. system_stats=stats,
  252. )
  253. if api.unregistered is True:
  254. # An admin removed the device while it kept running: register again
  255. # so it reappears (the route's documented behaviour).
  256. logger.info("Backend no longer knows this device, registering again")
  257. api.unregistered = False
  258. await _register(config, api, nfc, scale, display, ip)
  259. continue
  260. if result:
  261. ssh_key = result.get("ssh_public_key")
  262. if ssh_key:
  263. _deploy_ssh_key(ssh_key)
  264. cmd = result.get("pending_command")
  265. if cmd == "tare":
  266. scale = shared.get("scale")
  267. if scale and scale.ok:
  268. new_offset = await asyncio.to_thread(scale.tare)
  269. logger.info("Tare executed: offset=%d", new_offset)
  270. await api.update_tare(config.device_id, new_offset)
  271. config.tare_offset = new_offset
  272. else:
  273. logger.warning("Tare command received but scale not available")
  274. # Skip calibration sync — this heartbeat response predates the tare
  275. continue
  276. elif cmd == "apply_system_config":
  277. payload = result.get("pending_system_payload") or {}
  278. backend_url = str(payload.get("backend_url", "")).strip()
  279. api_key_value = payload.get("api_key")
  280. api_key = str(api_key_value).strip() if api_key_value is not None else ""
  281. if not backend_url:
  282. await api.system_command_result(
  283. config.device_id,
  284. "apply_system_config",
  285. False,
  286. "Missing backend_url payload",
  287. )
  288. continue
  289. try:
  290. env_path = _spoolbuddy_env_path()
  291. await asyncio.to_thread(_set_env_value, env_path, "SPOOLBUDDY_BACKEND_URL", backend_url)
  292. if api_key:
  293. await asyncio.to_thread(_set_env_value, env_path, "SPOOLBUDDY_API_KEY", api_key)
  294. await api.system_command_result(
  295. config.device_id,
  296. "apply_system_config",
  297. True,
  298. f"Updated {env_path}",
  299. )
  300. logger.info("Applied system config update")
  301. except Exception as e:
  302. logger.exception("Failed to apply system config")
  303. await api.system_command_result(
  304. config.device_id,
  305. "apply_system_config",
  306. False,
  307. str(e),
  308. )
  309. continue
  310. elif cmd in ("run_nfc_diag", "run_scale_diag", "run_read_tag_diag"):
  311. if cmd == "run_scale_diag":
  312. diagnostic = "scale"
  313. script_name = "scale_diag.py"
  314. elif cmd == "run_read_tag_diag":
  315. diagnostic = "read_tag"
  316. script_name = "read_tag.py"
  317. else:
  318. diagnostic = "nfc"
  319. script_name = "pn5180_diag.py"
  320. script_path = Path(__file__).resolve().parent.parent / "scripts" / script_name
  321. if diagnostic in ("nfc", "read_tag"):
  322. logger.info("Pausing NFC continuous scan for diagnostic")
  323. shared["nfc_scan_paused"] = True
  324. nfc_for_diag = shared.get("nfc")
  325. if nfc_for_diag:
  326. await asyncio.to_thread(nfc_for_diag.close)
  327. shared["nfc"] = None
  328. logger.info("Running %s diagnostic via %s", diagnostic, script_path)
  329. try:
  330. proc = await asyncio.to_thread(
  331. subprocess.run,
  332. [sys.executable, str(script_path)],
  333. capture_output=True,
  334. text=True,
  335. timeout=45,
  336. )
  337. output = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "")
  338. await api.diagnostic_result(
  339. config.device_id,
  340. diagnostic,
  341. proc.returncode == 0,
  342. output,
  343. proc.returncode,
  344. )
  345. except subprocess.TimeoutExpired:
  346. await api.diagnostic_result(
  347. config.device_id,
  348. diagnostic,
  349. False,
  350. "Diagnostic timed out after 45 seconds",
  351. -1,
  352. )
  353. except Exception as e:
  354. await api.diagnostic_result(
  355. config.device_id,
  356. diagnostic,
  357. False,
  358. f"Diagnostic execution failed: {e}",
  359. -1,
  360. )
  361. finally:
  362. if diagnostic in ("nfc", "read_tag"):
  363. logger.info("Reinitializing NFC continuous scan after diagnostic")
  364. shared["nfc"] = NFCReader()
  365. shared["nfc_scan_paused"] = False
  366. continue
  367. elif cmd == "write_tag":
  368. write_payload = result.get("pending_write_payload")
  369. unsent = shared.get("write_result_unsent")
  370. if write_payload and unsent is not None and write_payload["spool_id"] == unsent:
  371. # The write was done; its result hadn't reached the backend
  372. # when this heartbeat was answered, so the command is the
  373. # same write again. Running it would write the tag on the
  374. # reader a second time, a different one if it was swapped
  375. # (#3076). Once the result is out, the backend clears the
  376. # command and a new write request arrives as usual.
  377. if not api.has_buffered(WRITE_RESULT_PATH):
  378. shared.pop("write_result_unsent", None)
  379. logger.info(
  380. "Write tag command for spool %d ignored: the result of that write is still on its way",
  381. unsent,
  382. )
  383. elif write_payload:
  384. shared["pending_write"] = {
  385. "spool_id": write_payload["spool_id"],
  386. "ndef_data": bytes.fromhex(write_payload["ndef_data_hex"]),
  387. # OpenTag3D v2 for a tag with room for it (#3500);
  388. # absent from an older Bambuddy.
  389. "ndef_v2": bytes.fromhex(write_payload["ndef_v2_hex"])
  390. if write_payload.get("ndef_v2_hex")
  391. else None,
  392. }
  393. logger.info("Write tag command received for spool %d", write_payload["spool_id"])
  394. elif cmd in ("reboot", "shutdown", "restart_daemon", "restart_browser"):
  395. logger.info("System command received: %s", cmd)
  396. try:
  397. await api.system_command_result(config.device_id, cmd, True, f"Executing {cmd}")
  398. except Exception:
  399. pass # Best effort — we're about to restart/shutdown anyway
  400. if cmd == "reboot":
  401. await asyncio.to_thread(subprocess.run, ["sudo", "reboot"], check=False)
  402. elif cmd == "shutdown":
  403. await asyncio.to_thread(subprocess.run, ["sudo", "shutdown", "-h", "now"], check=False)
  404. elif cmd == "restart_daemon":
  405. await asyncio.to_thread(
  406. subprocess.run, ["sudo", "systemctl", "restart", "spoolbuddy.service"], check=False
  407. )
  408. elif cmd == "restart_browser":
  409. await asyncio.to_thread(
  410. subprocess.run, ["sudo", "systemctl", "restart", "getty@tty1.service"], check=False
  411. )
  412. continue
  413. tare = result.get("tare_offset", config.tare_offset)
  414. cal = result.get("calibration_factor", config.calibration_factor)
  415. if tare != config.tare_offset or cal != config.calibration_factor:
  416. config.tare_offset = tare
  417. config.calibration_factor = cal
  418. scale = shared.get("scale")
  419. if scale:
  420. scale.update_calibration(tare, cal)
  421. logger.info("Calibration updated from backend: tare=%d, factor=%.6f", tare, cal)
  422. # Apply display settings from backend
  423. brightness = result.get("display_brightness")
  424. blank_timeout = result.get("display_blank_timeout")
  425. if brightness is not None:
  426. display.set_brightness(brightness)
  427. if blank_timeout is not None:
  428. display.set_blank_timeout(blank_timeout)
  429. display.tick()
  430. async def main():
  431. config = Config.load()
  432. logger.info(
  433. "SpoolBuddy daemon v%s starting (device=%s, backend=%s)", __version__, config.device_id, config.backend_url
  434. )
  435. api = APIClient(config.backend_url, config.api_key)
  436. ip = _get_ip()
  437. start_time = time.monotonic()
  438. # Initialize hardware before registration so we can report capabilities
  439. nfc = NFCReader()
  440. scale = ScaleReader(
  441. tare_offset=config.tare_offset,
  442. calibration_factor=config.calibration_factor,
  443. )
  444. display = DisplayControl()
  445. # Register with backend (retries until success)
  446. await _register(config, api, nfc, scale, display, ip)
  447. logger.info("Device registered, starting poll loops")
  448. shared: dict = {"nfc": nfc, "scale": scale, "display": display, "nfc_scan_paused": False}
  449. try:
  450. await asyncio.gather(
  451. nfc_poll_loop(config, api, shared),
  452. scale_poll_loop(config, api, shared),
  453. heartbeat_loop(config, api, start_time, shared),
  454. )
  455. except KeyboardInterrupt:
  456. logger.info("Shutting down")
  457. finally:
  458. await api.close()
  459. if __name__ == "__main__":
  460. asyncio.run(main())