main.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  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):
  29. """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
  30. from .nfc_reader import NFCReader
  31. nfc = NFCReader()
  32. if not nfc.ok:
  33. logger.warning("NFC reader not available, skipping NFC polling")
  34. return
  35. try:
  36. while True:
  37. event_type, event_data = await asyncio.to_thread(nfc.poll)
  38. if event_type == "tag_detected":
  39. await api.tag_scanned(
  40. device_id=config.device_id,
  41. tag_uid=event_data["tag_uid"],
  42. tray_uuid=event_data.get("tray_uuid"),
  43. sak=event_data.get("sak"),
  44. tag_type=event_data.get("tag_type"),
  45. )
  46. elif event_type == "tag_removed":
  47. await api.tag_removed(
  48. device_id=config.device_id,
  49. tag_uid=event_data["tag_uid"],
  50. )
  51. await asyncio.sleep(config.nfc_poll_interval)
  52. finally:
  53. nfc.close()
  54. async def scale_poll_loop(config: Config, api: APIClient):
  55. """Continuous scale reading loop — reads at 100ms, reports at 1s intervals."""
  56. from .scale_reader import ScaleReader
  57. scale = ScaleReader(
  58. tare_offset=config.tare_offset,
  59. calibration_factor=config.calibration_factor,
  60. )
  61. if not scale.ok:
  62. logger.warning("Scale not available, skipping scale polling")
  63. return
  64. last_report = 0.0
  65. last_reported_grams: float | None = None
  66. last_reported_stable: bool | None = None
  67. REPORT_THRESHOLD = 2.0 # Only report if weight changed by more than this (grams)
  68. try:
  69. while True:
  70. result = await asyncio.to_thread(scale.read)
  71. if result is not None:
  72. grams, stable, raw_adc = result
  73. now = time.monotonic()
  74. if now - last_report >= config.scale_report_interval:
  75. # Only send when weight changed meaningfully or stability flipped
  76. weight_changed = last_reported_grams is None or abs(grams - last_reported_grams) >= REPORT_THRESHOLD
  77. stability_changed = last_reported_stable is None or stable != last_reported_stable
  78. if weight_changed or stability_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_reported_stable = stable
  87. last_report = now
  88. await asyncio.sleep(config.scale_read_interval)
  89. finally:
  90. scale.close()
  91. async def heartbeat_loop(config: Config, api: APIClient, start_time: float):
  92. """Periodic heartbeat to keep device registered and pick up commands."""
  93. ip = _get_ip()
  94. while True:
  95. await asyncio.sleep(config.heartbeat_interval)
  96. uptime = int(time.monotonic() - start_time)
  97. result = await api.heartbeat(
  98. device_id=config.device_id,
  99. nfc_ok=True,
  100. scale_ok=True,
  101. uptime_s=uptime,
  102. ip_address=ip,
  103. )
  104. if result:
  105. cmd = result.get("pending_command")
  106. if cmd == "tare":
  107. logger.info("Tare command received from backend")
  108. # Tare is handled by scale_reader — need cross-task communication
  109. # For now, update calibration from backend response
  110. tare = result.get("tare_offset", config.tare_offset)
  111. cal = result.get("calibration_factor", config.calibration_factor)
  112. if tare != config.tare_offset or cal != config.calibration_factor:
  113. config.tare_offset = tare
  114. config.calibration_factor = cal
  115. logger.info("Calibration updated from backend: tare=%d, factor=%.6f", tare, cal)
  116. async def main():
  117. config = Config.load()
  118. logger.info("SpoolBuddy daemon starting (device=%s, backend=%s)", config.device_id, config.backend_url)
  119. api = APIClient(config.backend_url, config.api_key)
  120. ip = _get_ip()
  121. start_time = time.monotonic()
  122. # Register with backend (retries until success)
  123. reg = await api.register_device(
  124. device_id=config.device_id,
  125. hostname=config.hostname,
  126. ip_address=ip,
  127. has_nfc=True,
  128. has_scale=True,
  129. tare_offset=config.tare_offset,
  130. calibration_factor=config.calibration_factor,
  131. )
  132. # Use server-side calibration if available
  133. if reg:
  134. config.tare_offset = reg.get("tare_offset", config.tare_offset)
  135. config.calibration_factor = reg.get("calibration_factor", config.calibration_factor)
  136. logger.info("Device registered, starting poll loops")
  137. try:
  138. await asyncio.gather(
  139. nfc_poll_loop(config, api),
  140. scale_poll_loop(config, api),
  141. heartbeat_loop(config, api, start_time),
  142. )
  143. except KeyboardInterrupt:
  144. logger.info("Shutting down")
  145. finally:
  146. await api.close()
  147. if __name__ == "__main__":
  148. asyncio.run(main())