main.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  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. REPORT_THRESHOLD = 2.0 # Only report if weight changed by more than this (grams)
  67. try:
  68. while True:
  69. result = await asyncio.to_thread(scale.read)
  70. if result is not None:
  71. grams, stable, raw_adc = result
  72. now = time.monotonic()
  73. if now - last_report >= config.scale_report_interval:
  74. # Only send when weight changed meaningfully
  75. weight_changed = last_reported_grams is None or abs(grams - last_reported_grams) >= REPORT_THRESHOLD
  76. if weight_changed:
  77. await api.scale_reading(
  78. device_id=config.device_id,
  79. weight_grams=grams,
  80. stable=stable,
  81. raw_adc=raw_adc,
  82. )
  83. last_reported_grams = grams
  84. last_report = now
  85. await asyncio.sleep(config.scale_read_interval)
  86. finally:
  87. scale.close()
  88. async def heartbeat_loop(config: Config, api: APIClient, start_time: float):
  89. """Periodic heartbeat to keep device registered and pick up commands."""
  90. ip = _get_ip()
  91. while True:
  92. await asyncio.sleep(config.heartbeat_interval)
  93. uptime = int(time.monotonic() - start_time)
  94. result = await api.heartbeat(
  95. device_id=config.device_id,
  96. nfc_ok=True,
  97. scale_ok=True,
  98. uptime_s=uptime,
  99. ip_address=ip,
  100. )
  101. if result:
  102. cmd = result.get("pending_command")
  103. if cmd == "tare":
  104. logger.info("Tare command received from backend")
  105. # Tare is handled by scale_reader — need cross-task communication
  106. # For now, update calibration from backend response
  107. tare = result.get("tare_offset", config.tare_offset)
  108. cal = result.get("calibration_factor", config.calibration_factor)
  109. if tare != config.tare_offset or cal != config.calibration_factor:
  110. config.tare_offset = tare
  111. config.calibration_factor = cal
  112. logger.info("Calibration updated from backend: tare=%d, factor=%.6f", tare, cal)
  113. async def main():
  114. config = Config.load()
  115. logger.info("SpoolBuddy daemon starting (device=%s, backend=%s)", config.device_id, config.backend_url)
  116. api = APIClient(config.backend_url, config.api_key)
  117. ip = _get_ip()
  118. start_time = time.monotonic()
  119. # Register with backend (retries until success)
  120. reg = await api.register_device(
  121. device_id=config.device_id,
  122. hostname=config.hostname,
  123. ip_address=ip,
  124. has_nfc=True,
  125. has_scale=True,
  126. tare_offset=config.tare_offset,
  127. calibration_factor=config.calibration_factor,
  128. )
  129. # Use server-side calibration if available
  130. if reg:
  131. config.tare_offset = reg.get("tare_offset", config.tare_offset)
  132. config.calibration_factor = reg.get("calibration_factor", config.calibration_factor)
  133. logger.info("Device registered, starting poll loops")
  134. try:
  135. await asyncio.gather(
  136. nfc_poll_loop(config, api),
  137. scale_poll_loop(config, api),
  138. heartbeat_loop(config, api, start_time),
  139. )
  140. except KeyboardInterrupt:
  141. logger.info("Shutting down")
  142. finally:
  143. await api.close()
  144. if __name__ == "__main__":
  145. asyncio.run(main())