main.py 5.8 KB

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