| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427 |
- #!/usr/bin/env python3
- """SpoolBuddy daemon — reads NFC tags and scale, pushes events to Bambuddy backend."""
- import asyncio
- import logging
- import os
- import socket
- import subprocess
- import sys
- import time
- from pathlib import Path
- from . import __version__
- from .api_client import APIClient
- from .config import Config
- from .display_control import DisplayControl
- from .nfc_reader import NFCReader, NFCState
- from .scale_reader import ScaleReader
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
- datefmt="%H:%M:%S",
- )
- logger = logging.getLogger("spoolbuddy")
- logging.getLogger("daemon.pn5180").setLevel(logging.DEBUG)
- def _spoolbuddy_env_path() -> Path:
- # installer writes this at <install>/spoolbuddy/.env; allow override for custom setups/tests
- override = os.environ.get("SPOOLBUDDY_ENV_FILE", "").strip()
- if override:
- return Path(override)
- return Path(__file__).resolve().parent.parent / ".env"
- def _set_env_value(path: Path, key: str, value: str):
- lines: list[str] = []
- if path.exists():
- lines = path.read_text(encoding="utf-8").splitlines()
- updated = False
- new_lines: list[str] = []
- for line in lines:
- if line.startswith(f"{key}="):
- new_lines.append(f"{key}={value}")
- updated = True
- else:
- new_lines.append(line)
- if not updated:
- new_lines.append(f"{key}={value}")
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
- 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"
- def _deploy_ssh_key(public_key: str) -> None:
- """Write Bambuddy's SSH public key to authorized_keys if not already present."""
- home = Path.home()
- ssh_dir = home / ".ssh"
- auth_keys = ssh_dir / "authorized_keys"
- try:
- ssh_dir.mkdir(mode=0o700, exist_ok=True)
- # Check if key already deployed
- if auth_keys.exists():
- existing = auth_keys.read_text()
- if public_key.strip() in existing:
- return
- # Append key
- with auth_keys.open("a") as f:
- f.write(public_key.strip() + "\n")
- auth_keys.chmod(0o600)
- logger.info("SSH public key deployed to %s", auth_keys)
- except Exception as e:
- logger.warning("Failed to deploy SSH key: %s", e)
- async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
- """Continuous NFC polling loop — runs in asyncio with blocking reads offloaded."""
- display: DisplayControl = shared["display"]
- try:
- while True:
- if shared.get("nfc_scan_paused", False):
- await asyncio.sleep(config.nfc_poll_interval)
- continue
- nfc: NFCReader | None = shared.get("nfc")
- if not nfc or not nfc.ok:
- await asyncio.sleep(config.nfc_poll_interval)
- continue
- event_type, event_data = await asyncio.to_thread(nfc.poll)
- if event_type == "tag_detected":
- display.wake()
- 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"],
- )
- # Check for pending write command
- pending = shared.get("pending_write")
- if pending and nfc.state == NFCState.TAG_PRESENT:
- if nfc.current_sak in (0x00, 0x04):
- logger.info("Executing pending tag write for spool %d", pending["spool_id"])
- success, msg = await asyncio.to_thread(nfc.write_ntag, pending["ndef_data"])
- await api.write_tag_result(
- device_id=config.device_id,
- spool_id=pending["spool_id"],
- tag_uid=nfc.current_uid or "",
- success=success,
- message=msg,
- )
- shared.pop("pending_write", None)
- else:
- # Fail fast when a non-NTAG is presented during write mode.
- # Without this, UI can appear stuck on "waiting for SpoolBuddy".
- sak = nfc.current_sak
- await api.write_tag_result(
- device_id=config.device_id,
- spool_id=pending["spool_id"],
- tag_uid=nfc.current_uid or "",
- success=False,
- message=f"Incompatible tag type (SAK=0x{sak:02X}). Place an NTAG tag to write.",
- )
- logger.warning(
- "Write aborted for spool %d: incompatible tag type SAK=0x%02X",
- pending["spool_id"],
- sak,
- )
- shared.pop("pending_write", None)
- await asyncio.sleep(config.nfc_poll_interval)
- finally:
- nfc: NFCReader | None = shared.get("nfc")
- if nfc:
- nfc.close()
- async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
- """Continuous scale reading loop — reads at 100ms, reports at 1s intervals."""
- scale: ScaleReader = shared["scale"]
- display: DisplayControl = shared["display"]
- if not scale.ok:
- logger.warning("Scale not available, skipping scale polling")
- return
- last_report = 0.0
- last_reported_grams: float | 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
- weight_changed = last_reported_grams is None or abs(grams - last_reported_grams) >= REPORT_THRESHOLD
- if weight_changed:
- display.wake()
- await api.scale_reading(
- device_id=config.device_id,
- weight_grams=grams,
- stable=stable,
- raw_adc=raw_adc,
- )
- last_reported_grams = grams
- last_report = now
- await asyncio.sleep(config.scale_read_interval)
- finally:
- scale.close()
- async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shared: dict):
- """Periodic heartbeat to keep device registered and pick up commands."""
- display: DisplayControl = shared["display"]
- ip = _get_ip()
- while True:
- await asyncio.sleep(config.heartbeat_interval)
- nfc = shared.get("nfc")
- scale = shared.get("scale")
- uptime = int(time.monotonic() - start_time)
- result = await api.heartbeat(
- device_id=config.device_id,
- nfc_ok=nfc.ok if nfc else False,
- scale_ok=scale.ok if scale else False,
- uptime_s=uptime,
- ip_address=ip,
- firmware_version=__version__,
- nfc_reader_type=nfc.reader_type if nfc else None,
- nfc_connection=nfc.connection if nfc else None,
- backend_url=config.backend_url,
- )
- if result:
- cmd = result.get("pending_command")
- if cmd == "tare":
- scale = shared.get("scale")
- if scale and scale.ok:
- new_offset = await asyncio.to_thread(scale.tare)
- logger.info("Tare executed: offset=%d", new_offset)
- await api.update_tare(config.device_id, new_offset)
- config.tare_offset = new_offset
- else:
- logger.warning("Tare command received but scale not available")
- # Skip calibration sync — this heartbeat response predates the tare
- continue
- elif cmd == "apply_system_config":
- payload = result.get("pending_system_payload") or {}
- backend_url = str(payload.get("backend_url", "")).strip()
- api_key_value = payload.get("api_key")
- api_key = str(api_key_value).strip() if api_key_value is not None else ""
- if not backend_url:
- await api.system_command_result(
- config.device_id,
- "apply_system_config",
- False,
- "Missing backend_url payload",
- )
- continue
- try:
- env_path = _spoolbuddy_env_path()
- await asyncio.to_thread(_set_env_value, env_path, "SPOOLBUDDY_BACKEND_URL", backend_url)
- if api_key:
- await asyncio.to_thread(_set_env_value, env_path, "SPOOLBUDDY_API_KEY", api_key)
- await api.system_command_result(
- config.device_id,
- "apply_system_config",
- True,
- f"Updated {env_path}",
- )
- logger.info("Applied system config update")
- except Exception as e:
- logger.exception("Failed to apply system config")
- await api.system_command_result(
- config.device_id,
- "apply_system_config",
- False,
- str(e),
- )
- continue
- elif cmd in ("run_nfc_diag", "run_scale_diag", "run_read_tag_diag"):
- if cmd == "run_scale_diag":
- diagnostic = "scale"
- script_name = "scale_diag.py"
- elif cmd == "run_read_tag_diag":
- diagnostic = "read_tag"
- script_name = "read_tag.py"
- else:
- diagnostic = "nfc"
- script_name = "pn5180_diag.py"
- script_path = Path(__file__).resolve().parent.parent / "scripts" / script_name
- if diagnostic in ("nfc", "read_tag"):
- logger.info("Pausing NFC continuous scan for diagnostic")
- shared["nfc_scan_paused"] = True
- nfc_for_diag = shared.get("nfc")
- if nfc_for_diag:
- await asyncio.to_thread(nfc_for_diag.close)
- shared["nfc"] = None
- logger.info("Running %s diagnostic via %s", diagnostic, script_path)
- try:
- proc = await asyncio.to_thread(
- subprocess.run,
- [sys.executable, str(script_path)],
- capture_output=True,
- text=True,
- timeout=45,
- )
- output = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "")
- await api.diagnostic_result(
- config.device_id,
- diagnostic,
- proc.returncode == 0,
- output,
- proc.returncode,
- )
- except subprocess.TimeoutExpired:
- await api.diagnostic_result(
- config.device_id,
- diagnostic,
- False,
- "Diagnostic timed out after 45 seconds",
- -1,
- )
- except Exception as e:
- await api.diagnostic_result(
- config.device_id,
- diagnostic,
- False,
- f"Diagnostic execution failed: {e}",
- -1,
- )
- finally:
- if diagnostic in ("nfc", "read_tag"):
- logger.info("Reinitializing NFC continuous scan after diagnostic")
- shared["nfc"] = NFCReader()
- shared["nfc_scan_paused"] = False
- continue
- elif cmd == "write_tag":
- write_payload = result.get("pending_write_payload")
- if write_payload:
- shared["pending_write"] = {
- "spool_id": write_payload["spool_id"],
- "ndef_data": bytes.fromhex(write_payload["ndef_data_hex"]),
- }
- logger.info("Write tag command received for spool %d", write_payload["spool_id"])
- 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
- scale = shared.get("scale")
- if scale:
- scale.update_calibration(tare, cal)
- logger.info("Calibration updated from backend: tare=%d, factor=%.6f", tare, cal)
- # Apply display settings from backend
- brightness = result.get("display_brightness")
- blank_timeout = result.get("display_blank_timeout")
- if brightness is not None:
- display.set_brightness(brightness)
- if blank_timeout is not None:
- display.set_blank_timeout(blank_timeout)
- display.tick()
- async def main():
- config = Config.load()
- logger.info(
- "SpoolBuddy daemon v%s starting (device=%s, backend=%s)", __version__, config.device_id, config.backend_url
- )
- api = APIClient(config.backend_url, config.api_key)
- ip = _get_ip()
- start_time = time.monotonic()
- # Initialize hardware before registration so we can report capabilities
- nfc = NFCReader()
- scale = ScaleReader(
- tare_offset=config.tare_offset,
- calibration_factor=config.calibration_factor,
- )
- display = DisplayControl()
- # Register with backend (retries until success)
- reg = await api.register_device(
- device_id=config.device_id,
- hostname=config.hostname,
- ip_address=ip,
- firmware_version=__version__,
- has_nfc=True,
- has_scale=True,
- tare_offset=config.tare_offset,
- calibration_factor=config.calibration_factor,
- nfc_reader_type=nfc.reader_type,
- nfc_connection=nfc.connection,
- backend_url=config.backend_url,
- has_backlight=display.has_backlight,
- )
- # 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)
- scale.update_calibration(config.tare_offset, config.calibration_factor)
- # Auto-deploy Bambuddy's SSH public key for remote updates
- ssh_key = reg.get("ssh_public_key")
- if ssh_key:
- _deploy_ssh_key(ssh_key)
- logger.info("Device registered, starting poll loops")
- shared: dict = {"nfc": nfc, "scale": scale, "display": display, "nfc_scan_paused": False}
- try:
- await asyncio.gather(
- nfc_poll_loop(config, api, shared),
- scale_poll_loop(config, api, shared),
- heartbeat_loop(config, api, start_time, shared),
- )
- except KeyboardInterrupt:
- logger.info("Shutting down")
- finally:
- await api.close()
- if __name__ == "__main__":
- asyncio.run(main())
|