main.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  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 socket
  6. import sys
  7. import time
  8. from pathlib import Path
  9. # Add scripts/ to sys.path so hardware drivers (read_tag, scale_diag) are importable
  10. sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
  11. from . import __version__
  12. from .api_client import 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. def _get_ip() -> str:
  24. try:
  25. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  26. s.connect(("8.8.8.8", 80))
  27. ip = s.getsockname()[0]
  28. s.close()
  29. return ip
  30. except Exception:
  31. return "unknown"
  32. def _deploy_ssh_key(public_key: str) -> None:
  33. """Write Bambuddy's SSH public key to authorized_keys if not already present."""
  34. home = Path.home()
  35. ssh_dir = home / ".ssh"
  36. auth_keys = ssh_dir / "authorized_keys"
  37. try:
  38. ssh_dir.mkdir(mode=0o700, exist_ok=True)
  39. # Check if key already deployed
  40. if auth_keys.exists():
  41. existing = auth_keys.read_text()
  42. if public_key.strip() in existing:
  43. return
  44. # Append key
  45. with auth_keys.open("a") as f:
  46. f.write(public_key.strip() + "\n")
  47. auth_keys.chmod(0o600)
  48. logger.info("SSH public key deployed to %s", auth_keys)
  49. except Exception as e:
  50. logger.warning("Failed to deploy SSH key: %s", e)
  51. async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
  52. """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
  53. nfc: NFCReader = shared["nfc"]
  54. display: DisplayControl = shared["display"]
  55. if not nfc.ok:
  56. logger.warning("NFC reader not available, skipping NFC polling")
  57. return
  58. try:
  59. while True:
  60. event_type, event_data = await asyncio.to_thread(nfc.poll)
  61. if event_type == "tag_detected":
  62. display.wake()
  63. await api.tag_scanned(
  64. device_id=config.device_id,
  65. tag_uid=event_data["tag_uid"],
  66. tray_uuid=event_data.get("tray_uuid"),
  67. sak=event_data.get("sak"),
  68. tag_type=event_data.get("tag_type"),
  69. )
  70. elif event_type == "tag_removed":
  71. await api.tag_removed(
  72. device_id=config.device_id,
  73. tag_uid=event_data["tag_uid"],
  74. )
  75. # Check for pending write command
  76. pending = shared.get("pending_write")
  77. if pending and nfc.state == NFCState.TAG_PRESENT and nfc.current_sak == 0x00:
  78. logger.info("Executing pending tag write for spool %d", pending["spool_id"])
  79. success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"])
  80. await api.write_tag_result(
  81. device_id=config.device_id,
  82. spool_id=pending["spool_id"],
  83. tag_uid=nfc.current_uid or "",
  84. success=success,
  85. message=msg,
  86. )
  87. shared.pop("pending_write", None)
  88. await asyncio.sleep(config.nfc_poll_interval)
  89. finally:
  90. nfc.close()
  91. async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
  92. """Continuous scale reading loop — reads at 100ms, reports at 1s intervals."""
  93. scale: ScaleReader = shared["scale"]
  94. display: DisplayControl = shared["display"]
  95. if not scale.ok:
  96. logger.warning("Scale not available, skipping scale polling")
  97. return
  98. last_report = 0.0
  99. last_reported_grams: float | None = None
  100. REPORT_THRESHOLD = 2.0 # Only report if weight changed by more than this (grams)
  101. try:
  102. while True:
  103. result = await asyncio.to_thread(scale.read)
  104. if result is not None:
  105. grams, stable, raw_adc = result
  106. now = time.monotonic()
  107. if now - last_report >= config.scale_report_interval:
  108. # Only send when weight changed meaningfully
  109. weight_changed = last_reported_grams is None or abs(grams - last_reported_grams) >= REPORT_THRESHOLD
  110. if weight_changed:
  111. display.wake()
  112. await api.scale_reading(
  113. device_id=config.device_id,
  114. weight_grams=grams,
  115. stable=stable,
  116. raw_adc=raw_adc,
  117. )
  118. last_reported_grams = grams
  119. last_report = now
  120. await asyncio.sleep(config.scale_read_interval)
  121. finally:
  122. scale.close()
  123. async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shared: dict):
  124. """Periodic heartbeat to keep device registered and pick up commands."""
  125. display: DisplayControl = shared["display"]
  126. ip = _get_ip()
  127. while True:
  128. await asyncio.sleep(config.heartbeat_interval)
  129. nfc = shared.get("nfc")
  130. scale = shared.get("scale")
  131. uptime = int(time.monotonic() - start_time)
  132. result = await api.heartbeat(
  133. device_id=config.device_id,
  134. nfc_ok=nfc.ok if nfc else False,
  135. scale_ok=scale.ok if scale else False,
  136. uptime_s=uptime,
  137. ip_address=ip,
  138. firmware_version=__version__,
  139. nfc_reader_type=nfc.reader_type if nfc else None,
  140. nfc_connection=nfc.connection if nfc else None,
  141. )
  142. if result:
  143. cmd = result.get("pending_command")
  144. if cmd == "tare":
  145. scale = shared.get("scale")
  146. if scale and scale.ok:
  147. new_offset = await asyncio.to_thread(scale.tare)
  148. logger.info("Tare executed: offset=%d", new_offset)
  149. await api.update_tare(config.device_id, new_offset)
  150. config.tare_offset = new_offset
  151. else:
  152. logger.warning("Tare command received but scale not available")
  153. # Skip calibration sync — this heartbeat response predates the tare
  154. continue
  155. elif cmd == "write_tag":
  156. write_payload = result.get("pending_write_payload")
  157. if write_payload:
  158. shared["pending_write"] = {
  159. "spool_id": write_payload["spool_id"],
  160. "ndef_data": bytes.fromhex(write_payload["ndef_data_hex"]),
  161. }
  162. logger.info("Write tag command received for spool %d", write_payload["spool_id"])
  163. tare = result.get("tare_offset", config.tare_offset)
  164. cal = result.get("calibration_factor", config.calibration_factor)
  165. if tare != config.tare_offset or cal != config.calibration_factor:
  166. config.tare_offset = tare
  167. config.calibration_factor = cal
  168. scale = shared.get("scale")
  169. if scale:
  170. scale.update_calibration(tare, cal)
  171. logger.info("Calibration updated from backend: tare=%d, factor=%.6f", tare, cal)
  172. # Apply display settings from backend
  173. brightness = result.get("display_brightness")
  174. blank_timeout = result.get("display_blank_timeout")
  175. if brightness is not None:
  176. display.set_brightness(brightness)
  177. if blank_timeout is not None:
  178. display.set_blank_timeout(blank_timeout)
  179. display.tick()
  180. async def main():
  181. config = Config.load()
  182. logger.info(
  183. "SpoolBuddy daemon v%s starting (device=%s, backend=%s)", __version__, config.device_id, config.backend_url
  184. )
  185. api = APIClient(config.backend_url, config.api_key)
  186. ip = _get_ip()
  187. start_time = time.monotonic()
  188. # Initialize hardware before registration so we can report capabilities
  189. nfc = NFCReader()
  190. scale = ScaleReader(
  191. tare_offset=config.tare_offset,
  192. calibration_factor=config.calibration_factor,
  193. )
  194. display = DisplayControl()
  195. # Register with backend (retries until success)
  196. reg = await api.register_device(
  197. device_id=config.device_id,
  198. hostname=config.hostname,
  199. ip_address=ip,
  200. firmware_version=__version__,
  201. has_nfc=True,
  202. has_scale=True,
  203. tare_offset=config.tare_offset,
  204. calibration_factor=config.calibration_factor,
  205. nfc_reader_type=nfc.reader_type,
  206. nfc_connection=nfc.connection,
  207. has_backlight=display.has_backlight,
  208. )
  209. # Use server-side calibration if available
  210. if reg:
  211. config.tare_offset = reg.get("tare_offset", config.tare_offset)
  212. config.calibration_factor = reg.get("calibration_factor", config.calibration_factor)
  213. scale.update_calibration(config.tare_offset, config.calibration_factor)
  214. # Auto-deploy Bambuddy's SSH public key for remote updates
  215. ssh_key = reg.get("ssh_public_key")
  216. if ssh_key:
  217. _deploy_ssh_key(ssh_key)
  218. logger.info("Device registered, starting poll loops")
  219. shared: dict = {"nfc": nfc, "scale": scale, "display": display}
  220. try:
  221. await asyncio.gather(
  222. nfc_poll_loop(config, api, shared),
  223. scale_poll_loop(config, api, shared),
  224. heartbeat_loop(config, api, start_time, shared),
  225. )
  226. except KeyboardInterrupt:
  227. logger.info("Shutting down")
  228. finally:
  229. await api.close()
  230. if __name__ == "__main__":
  231. asyncio.run(main())