| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- #!/usr/bin/env python3
- """SpoolBuddy daemon — reads NFC tags and scale, pushes events to Bambuddy backend."""
- import asyncio
- import logging
- import socket
- import sys
- import time
- from pathlib import Path
- # Add scripts/ to sys.path so hardware drivers (read_tag, scale_diag) are importable
- sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
- from .api_client import APIClient
- from .config import Config
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
- datefmt="%H:%M:%S",
- )
- logger = logging.getLogger("spoolbuddy")
- def _get_ip() -> str:
- try:
- s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- s.connect(("8.8.8.8", 80))
- ip = s.getsockname()[0]
- s.close()
- return ip
- except Exception:
- return "unknown"
- async def nfc_poll_loop(config: Config, api: APIClient):
- """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
- from .nfc_reader import NFCReader
- nfc = NFCReader()
- if not nfc.ok:
- logger.warning("NFC reader not available, skipping NFC polling")
- return
- try:
- while True:
- event_type, event_data = await asyncio.to_thread(nfc.poll)
- if event_type == "tag_detected":
- await api.tag_scanned(
- device_id=config.device_id,
- tag_uid=event_data["tag_uid"],
- tray_uuid=event_data.get("tray_uuid"),
- sak=event_data.get("sak"),
- tag_type=event_data.get("tag_type"),
- )
- elif event_type == "tag_removed":
- await api.tag_removed(
- device_id=config.device_id,
- tag_uid=event_data["tag_uid"],
- )
- await asyncio.sleep(config.nfc_poll_interval)
- finally:
- nfc.close()
- async def scale_poll_loop(config: Config, api: APIClient):
- """Continuous scale reading loop — reads at 100ms, reports at 1s intervals."""
- from .scale_reader import ScaleReader
- scale = ScaleReader(
- tare_offset=config.tare_offset,
- calibration_factor=config.calibration_factor,
- )
- if not scale.ok:
- logger.warning("Scale not available, skipping scale polling")
- return
- last_report = 0.0
- last_reported_grams: float | None = None
- last_reported_stable: bool | None = None
- REPORT_THRESHOLD = 2.0 # Only report if weight changed by more than this (grams)
- try:
- while True:
- result = await asyncio.to_thread(scale.read)
- if result is not None:
- grams, stable, raw_adc = result
- now = time.monotonic()
- if now - last_report >= config.scale_report_interval:
- # Only send when weight changed meaningfully or stability flipped
- weight_changed = last_reported_grams is None or abs(grams - last_reported_grams) >= REPORT_THRESHOLD
- stability_changed = last_reported_stable is None or stable != last_reported_stable
- if weight_changed or stability_changed:
- await api.scale_reading(
- device_id=config.device_id,
- weight_grams=grams,
- stable=stable,
- raw_adc=raw_adc,
- )
- last_reported_grams = grams
- last_reported_stable = stable
- last_report = now
- await asyncio.sleep(config.scale_read_interval)
- finally:
- scale.close()
- async def heartbeat_loop(config: Config, api: APIClient, start_time: float):
- """Periodic heartbeat to keep device registered and pick up commands."""
- ip = _get_ip()
- while True:
- await asyncio.sleep(config.heartbeat_interval)
- uptime = int(time.monotonic() - start_time)
- result = await api.heartbeat(
- device_id=config.device_id,
- nfc_ok=True,
- scale_ok=True,
- uptime_s=uptime,
- ip_address=ip,
- )
- if result:
- cmd = result.get("pending_command")
- if cmd == "tare":
- logger.info("Tare command received from backend")
- # Tare is handled by scale_reader — need cross-task communication
- # For now, update calibration from backend response
- tare = result.get("tare_offset", config.tare_offset)
- cal = result.get("calibration_factor", config.calibration_factor)
- if tare != config.tare_offset or cal != config.calibration_factor:
- config.tare_offset = tare
- config.calibration_factor = cal
- logger.info("Calibration updated from backend: tare=%d, factor=%.6f", tare, cal)
- async def main():
- config = Config.load()
- logger.info("SpoolBuddy daemon starting (device=%s, backend=%s)", config.device_id, config.backend_url)
- api = APIClient(config.backend_url, config.api_key)
- ip = _get_ip()
- start_time = time.monotonic()
- # Register with backend (retries until success)
- reg = await api.register_device(
- device_id=config.device_id,
- hostname=config.hostname,
- ip_address=ip,
- has_nfc=True,
- has_scale=True,
- tare_offset=config.tare_offset,
- calibration_factor=config.calibration_factor,
- )
- # Use server-side calibration if available
- if reg:
- config.tare_offset = reg.get("tare_offset", config.tare_offset)
- config.calibration_factor = reg.get("calibration_factor", config.calibration_factor)
- logger.info("Device registered, starting poll loops")
- try:
- await asyncio.gather(
- nfc_poll_loop(config, api),
- scale_poll_loop(config, api),
- heartbeat_loop(config, api, start_time),
- )
- except KeyboardInterrupt:
- logger.info("Shutting down")
- finally:
- await api.close()
- if __name__ == "__main__":
- asyncio.run(main())
|