mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 20:22:15 +02:00
+206
-118
@@ -3,16 +3,14 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
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 . import __version__
|
||||
from . import __version__, system_stats
|
||||
from .api_client import APIClient
|
||||
from .config import Config
|
||||
from .display_control import DisplayControl
|
||||
@@ -25,6 +23,36 @@ logging.basicConfig(
|
||||
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:
|
||||
@@ -38,16 +66,45 @@ def _get_ip() -> str:
|
||||
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."""
|
||||
nfc: NFCReader = shared["nfc"]
|
||||
display: DisplayControl = shared["display"]
|
||||
if not nfc.ok:
|
||||
logger.warning("NFC reader not available, skipping NFC polling")
|
||||
return
|
||||
|
||||
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":
|
||||
@@ -67,21 +124,41 @@ async def nfc_poll_loop(config: Config, api: APIClient, shared: dict):
|
||||
|
||||
# Check for pending write command
|
||||
pending = shared.get("pending_write")
|
||||
if pending and nfc.state == NFCState.TAG_PRESENT and nfc.current_sak == 0x00:
|
||||
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)
|
||||
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.close()
|
||||
nfc: NFCReader | None = shared.get("nfc")
|
||||
if nfc:
|
||||
nfc.close()
|
||||
|
||||
|
||||
async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
|
||||
@@ -123,93 +200,6 @@ async def scale_poll_loop(config: Config, api: APIClient, shared: dict):
|
||||
scale.close()
|
||||
|
||||
|
||||
async def _perform_update(config: Config, api: APIClient):
|
||||
"""Pull latest code from git, install deps, then exit for systemd restart."""
|
||||
# Determine repo root (install path) — daemon runs from <repo>/spoolbuddy/
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
await api.report_update_status(config.device_id, "updating", "Fetching latest code...")
|
||||
|
||||
git_path = shutil.which("git") or "/usr/bin/git"
|
||||
git_config = ["-c", f"safe.directory={repo_root}"]
|
||||
|
||||
# git fetch origin main
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
git_path,
|
||||
*git_config,
|
||||
"fetch",
|
||||
"origin",
|
||||
"main",
|
||||
cwd=str(repo_root),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
msg = f"git fetch failed: {stderr.decode()[:200]}"
|
||||
logger.error(msg)
|
||||
await api.report_update_status(config.device_id, "error", msg)
|
||||
return
|
||||
|
||||
await api.report_update_status(config.device_id, "updating", "Applying update...")
|
||||
|
||||
# git reset --hard origin/main
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
git_path,
|
||||
*git_config,
|
||||
"reset",
|
||||
"--hard",
|
||||
"origin/main",
|
||||
cwd=str(repo_root),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
msg = f"git reset failed: {stderr.decode()[:200]}"
|
||||
logger.error(msg)
|
||||
await api.report_update_status(config.device_id, "error", msg)
|
||||
return
|
||||
|
||||
await api.report_update_status(config.device_id, "updating", "Installing dependencies...")
|
||||
|
||||
# pip install daemon deps (use the venv pip)
|
||||
venv_pip = repo_root / "spoolbuddy" / "venv" / "bin" / "pip"
|
||||
pip_packages = ["spidev", "gpiod", "smbus2", "httpx"]
|
||||
|
||||
if venv_pip.exists():
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
str(venv_pip),
|
||||
"install",
|
||||
"--upgrade",
|
||||
*pip_packages,
|
||||
cwd=str(repo_root),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
else:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--upgrade",
|
||||
*pip_packages,
|
||||
cwd=str(repo_root),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
logger.warning("pip install returned non-zero (continuing anyway)")
|
||||
|
||||
await api.report_update_status(config.device_id, "complete", "Update complete, restarting...")
|
||||
logger.info("Update complete, exiting for systemd restart")
|
||||
|
||||
# Exit cleanly — systemd Restart=always will bring us back with the new code
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
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"]
|
||||
@@ -221,6 +211,7 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
|
||||
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,
|
||||
@@ -230,19 +221,13 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
|
||||
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:
|
||||
cmd = result.get("pending_command")
|
||||
if cmd == "update":
|
||||
logger.info("Update command received, starting update...")
|
||||
try:
|
||||
await _perform_update(config, api)
|
||||
except Exception as e:
|
||||
logger.error("Update failed: %s", e)
|
||||
await api.report_update_status(config.device_id, "error", str(e)[:255])
|
||||
continue
|
||||
elif cmd == "tare":
|
||||
if cmd == "tare":
|
||||
scale = shared.get("scale")
|
||||
if scale and scale.ok:
|
||||
new_offset = await asyncio.to_thread(scale.tare)
|
||||
@@ -253,6 +238,103 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
|
||||
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:
|
||||
@@ -313,6 +395,7 @@ async def main():
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -322,9 +405,14 @@ async def main():
|
||||
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}
|
||||
shared: dict = {"nfc": nfc, "scale": scale, "display": display, "nfc_scan_paused": False}
|
||||
try:
|
||||
await asyncio.gather(
|
||||
nfc_poll_loop(config, api, shared),
|
||||
|
||||
Reference in New Issue
Block a user