bambuddy/spoolbuddy/daemon/main.py
maziggy 66a3604d9b Draft commit message:
fix(spoolbuddy): gate display wake on stable scale reading

  A noisy load cell that bounced ≥50 g around its midpoint kept the kiosk
  screen permanently lit. The wake threshold check ran against last_wake_grams
  which itself advanced to noisy values, so every bounce back across 50 g
  re-fired display.wake(), keeping the FIFO reader pumping wlopm --on
  faster than swayidle could trigger wlopm --off.

  The fix gates wake on the scale's `stable` flag — only readings that
  have settled within 2 g over a 1 s window count as a real spool
  placement / removal. Unstable noise can't fire wake and can't advance
  last_wake_grams, so the next genuine settled change is still measured
  against the right baseline.

  Three regression tests pin the contract: noisy ±60 g unstable readings
  never wake, a settled >50 g jump wakes, a noise burst between two
  settled readings doesn't poison last_wake_grams.
2026-05-03 09:22:35 +02:00

486 lines
19 KiB
Python

#!/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__, system_stats
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"
SSH_KEY_TAG = "bambuddy-spoolbuddy"
def _deploy_ssh_key(public_key: str) -> None:
"""Sync Bambuddy's SSH public key into authorized_keys.
Replaces any prior key tagged ``bambuddy-spoolbuddy`` so the file always
reflects Bambuddy's *current* keypair. Without this, every Bambuddy key
rotation (data dir wipe, container recreate, etc.) leaves a stale entry
behind and the file grows unbounded.
"""
target = public_key.strip()
home = Path.home()
ssh_dir = home / ".ssh"
auth_keys = ssh_dir / "authorized_keys"
try:
ssh_dir.mkdir(mode=0o700, exist_ok=True)
existing_lines: list[str] = []
if auth_keys.exists():
existing_lines = auth_keys.read_text().splitlines()
kept = [line for line in existing_lines if SSH_KEY_TAG not in line]
new_lines = kept + [target]
# Already in sync — current key present and no stale Bambuddy entries.
if existing_lines == new_lines:
return
auth_keys.write_text("\n".join(new_lines) + "\n")
auth_keys.chmod(0o600)
removed = len(existing_lines) - len(kept)
if removed:
logger.info("SSH public key updated in %s (replaced %d stale entries)", auth_keys, removed)
else:
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
last_wake_grams: float | None = None
REPORT_THRESHOLD = 2.0 # Only report if weight changed by more than this (grams)
WAKE_THRESHOLD = 50.0 # Only wake display on large changes (spool placed/removed, not sensor bounce)
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:
# Wake display only on STABLE large weight changes (spool
# placed/removed). Without the stability gate, a noisy load
# cell that bounces ≥50g around a midpoint repeatedly trips
# the threshold AND advances last_wake_grams to the noisy
# value, so the next bounce back also exceeds the threshold
# — wake fires every few seconds forever and the kiosk
# screen never stays blanked. The `stable` flag is True
# only when readings agree within 2g over a 1s window, so
# gating on it ensures last_wake_grams only advances to
# settled values.
wake_changed = stable and (
last_wake_grams is None or abs(grams - last_wake_grams) >= WAKE_THRESHOLD
)
if wake_changed:
display.wake()
last_wake_grams = grams
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)
stats = await asyncio.to_thread(system_stats.collect)
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,
system_stats=stats,
)
if result:
ssh_key = result.get("ssh_public_key")
if ssh_key:
_deploy_ssh_key(ssh_key)
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"])
elif cmd in ("reboot", "shutdown", "restart_daemon", "restart_browser"):
logger.info("System command received: %s", cmd)
try:
await api.system_command_result(config.device_id, cmd, True, f"Executing {cmd}")
except Exception:
pass # Best effort — we're about to restart/shutdown anyway
if cmd == "reboot":
await asyncio.to_thread(subprocess.run, ["sudo", "reboot"], check=False)
elif cmd == "shutdown":
await asyncio.to_thread(subprocess.run, ["sudo", "shutdown", "-h", "now"], check=False)
elif cmd == "restart_daemon":
await asyncio.to_thread(
subprocess.run, ["sudo", "systemctl", "restart", "spoolbuddy.service"], check=False
)
elif cmd == "restart_browser":
await asyncio.to_thread(
subprocess.run, ["sudo", "systemctl", "restart", "getty@tty1.service"], check=False
)
continue
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())