main.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  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 .api_client import APIClient
  12. from .config import Config
  13. logging.basicConfig(
  14. level=logging.INFO,
  15. format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
  16. datefmt="%H:%M:%S",
  17. )
  18. logger = logging.getLogger("spoolbuddy")
  19. def _get_ip() -> str:
  20. try:
  21. s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  22. s.connect(("8.8.8.8", 80))
  23. ip = s.getsockname()[0]
  24. s.close()
  25. return ip
  26. except Exception:
  27. return "unknown"
  28. async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
  29. """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
  30. from .nfc_reader import NFCReader
  31. nfc = NFCReader()
  32. shared["nfc"] = nfc
  33. if not nfc.ok:
  34. logger.warning("NFC reader not available, skipping NFC polling")
  35. return
  36. try:
  37. while True:
  38. event_type, event_data = await asyncio.to_thread(nfc.poll)
  39. if event_type == "tag_detected":
  40. await api.tag_scanned(
  41. device_id=config.device_id,
  42. tag_uid=event_data["tag_uid"],
  43. tray_uuid=event_data.get("tray_uuid"),
  44. sak=event_data.get("sak"),
  45. tag_type=event_data.get("tag_type"),
  46. )
  47. elif event_type == "tag_removed":
  48. await api.tag_removed(
  49. device_id=config.device_id,
  50. tag_uid=event_data["tag_uid"],
  51. )
  52. await asyncio.sleep(config.nfc_poll_interval)
  53. finally:
  54. nfc.close()
  55. async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
  56. """Continuous scale reading loop — reads at 100ms, reports at 1s intervals."""
  57. from .scale_reader import ScaleReader
  58. scale = ScaleReader(
  59. tare_offset=config.tare_offset,
  60. calibration_factor=config.calibration_factor,
  61. )
  62. shared["scale"] = scale
  63. if not scale.ok:
  64. logger.warning("Scale not available, skipping scale polling")
  65. return
  66. last_report = 0.0
  67. last_reported_grams: float | None = None
  68. REPORT_THRESHOLD = 2.0 # Only report if weight changed by more than this (grams)
  69. try:
  70. while True:
  71. result = await asyncio.to_thread(scale.read)
  72. if result is not None:
  73. grams, stable, raw_adc = result
  74. now = time.monotonic()
  75. if now - last_report >= config.scale_report_interval:
  76. # Only send when weight changed meaningfully
  77. weight_changed = last_reported_grams is None or abs(grams - last_reported_grams) >= REPORT_THRESHOLD
  78. if weight_changed:
  79. await api.scale_reading(
  80. device_id=config.device_id,
  81. weight_grams=grams,
  82. stable=stable,
  83. raw_adc=raw_adc,
  84. )
  85. last_reported_grams = grams
  86. last_report = now
  87. await asyncio.sleep(config.scale_read_interval)
  88. finally:
  89. scale.close()
  90. async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shared: dict):
  91. """Periodic heartbeat to keep device registered and pick up commands."""
  92. ip = _get_ip()
  93. while True:
  94. await asyncio.sleep(config.heartbeat_interval)
  95. nfc = shared.get("nfc")
  96. scale = shared.get("scale")
  97. uptime = int(time.monotonic() - start_time)
  98. result = await api.heartbeat(
  99. device_id=config.device_id,
  100. nfc_ok=nfc.ok if nfc else False,
  101. scale_ok=scale.ok if scale else False,
  102. uptime_s=uptime,
  103. ip_address=ip,
  104. )
  105. if result:
  106. cmd = result.get("pending_command")
  107. if cmd == "tare":
  108. scale = shared.get("scale")
  109. if scale and scale.ok:
  110. new_offset = await asyncio.to_thread(scale.tare)
  111. logger.info("Tare executed: offset=%d", new_offset)
  112. await api.update_tare(config.device_id, new_offset)
  113. config.tare_offset = new_offset
  114. else:
  115. logger.warning("Tare command received but scale not available")
  116. # Skip calibration sync — this heartbeat response predates the tare
  117. continue
  118. tare = result.get("tare_offset", config.tare_offset)
  119. cal = result.get("calibration_factor", config.calibration_factor)
  120. if tare != config.tare_offset or cal != config.calibration_factor:
  121. config.tare_offset = tare
  122. config.calibration_factor = cal
  123. scale = shared.get("scale")
  124. if scale:
  125. scale.update_calibration(tare, cal)
  126. logger.info("Calibration updated from backend: tare=%d, factor=%.6f", tare, cal)
  127. async def main():
  128. config = Config.load()
  129. logger.info("SpoolBuddy daemon starting (device=%s, backend=%s)", config.device_id, config.backend_url)
  130. api = APIClient(config.backend_url, config.api_key)
  131. ip = _get_ip()
  132. start_time = time.monotonic()
  133. # Register with backend (retries until success)
  134. reg = await api.register_device(
  135. device_id=config.device_id,
  136. hostname=config.hostname,
  137. ip_address=ip,
  138. has_nfc=True,
  139. has_scale=True,
  140. tare_offset=config.tare_offset,
  141. calibration_factor=config.calibration_factor,
  142. )
  143. # Use server-side calibration if available
  144. if reg:
  145. config.tare_offset = reg.get("tare_offset", config.tare_offset)
  146. config.calibration_factor = reg.get("calibration_factor", config.calibration_factor)
  147. logger.info("Device registered, starting poll loops")
  148. shared: dict = {}
  149. try:
  150. await asyncio.gather(
  151. nfc_poll_loop(config, api, shared),
  152. scale_poll_loop(config, api, shared),
  153. heartbeat_loop(config, api, start_time, shared),
  154. )
  155. except KeyboardInterrupt:
  156. logger.info("Shutting down")
  157. finally:
  158. await api.close()
  159. if __name__ == "__main__":
  160. asyncio.run(main())