main.py 5.1 KB

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