"""Voice-session control — the on/off switch behind the Interaction page. The conversation loop is a systemd *user* unit (sanad_agibot.service) that the dashboard already has the rights to drive, because both run as `agi` in the same user session. So "turn speaking on" is: persist the operator's selection, then start/restart that unit; "off" is stopping it. The (gender, language, model) -> (persona, voice) mapping deliberately lives in Sanad's own voice/session_profile.py and is imported from there by path rather than copied here. A copy would drift, and a drifted copy means the dashboard shows one character while the robot speaks as another. Everything in here degrades to a readable status instead of raising: if Sanad is not deployed, or systemd is unavailable, the card still renders and explains what is missing. """ from __future__ import annotations import importlib.util import os import shutil import subprocess import time from pathlib import Path from typing import Any SERVICE = os.environ.get("SANAD_SERVICE_NAME", "sanad_agibot.service") SANAD_DIR = Path(os.environ.get( "SANAD_DIR", Path.home() / "sanad_deploy" / "sanad_package_1" / "vendor" / "Sanad", )) _PROFILE_MODULE_PATH = SANAD_DIR / "voice" / "session_profile.py" # Choices the UI renders. Kept server-side so the page and the robot can never # disagree about what is selectable. OPTIONS: dict[str, list[dict[str, str]]] = { "gender": [ {"value": "female", "label": "Female — Muza / موزة"}, {"value": "male", "label": "Male — Lumi / لومي"}, ], "language": [ {"value": "arabic", "label": "Arabic only (Emirati)"}, {"value": "multi", "label": "Multi-language"}, ], # The second value is still keyed "linksoul" so an already-saved selection # keeps working; the label describes what it actually does now. "model": [ {"value": "gemini", "label": "Gemini Live"}, {"value": "linksoul", "label": "Pipeline — Fatima / Hamdan"}, ], } _profile_mod = None _profile_err: str | None = None def _profile(): """Sanad's session_profile module, loaded from disk on first use.""" global _profile_mod, _profile_err if _profile_mod is not None or _profile_err is not None: return _profile_mod try: spec = importlib.util.spec_from_file_location( "sanad_session_profile", _PROFILE_MODULE_PATH) if spec is None or spec.loader is None: raise ImportError(f"cannot load {_PROFILE_MODULE_PATH}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) _profile_mod = module except Exception as exc: # noqa: BLE001 - reported, never fatal _profile_err = f"{type(exc).__name__}: {exc}" return _profile_mod def _systemctl(*args: str, timeout: float = 20.0) -> tuple[int, str]: if not shutil.which("systemctl"): return 127, "systemctl not available on this host" try: proc = subprocess.run( ["systemctl", "--user", *args], capture_output=True, text=True, timeout=timeout, ) return proc.returncode, (proc.stdout + proc.stderr).strip() except subprocess.TimeoutExpired: return 124, f"systemctl --user {' '.join(args)} timed out" except OSError as exc: return 1, str(exc) _VOICE_PATTERNS = ( r"vendor/Sanad/voice/sanad_voice\.py", r"vendor/Sanad/pipeline/runner\.py", ) def _kill_voice_processes(grace: float = 6.0) -> int: """Stop the running voice process whoever started it. Needed because the process is launched by keepalive_daemon.sh rather than systemd, so stopping the unit is not enough to silence the robot. SIGINT first so it releases ROS audio focus and closes the Gemini session cleanly. """ import signal import subprocess as sp pids: list[str] = [] for pat in _VOICE_PATTERNS: try: out = sp.run(["pgrep", "-f", pat], capture_output=True, text=True, timeout=5) pids += [p for p in out.stdout.split() if p.strip()] except Exception: # noqa: BLE001 pass if not pids: return 0 for p in pids: try: os.kill(int(p), signal.SIGINT) except Exception: # noqa: BLE001 pass time.sleep(grace) for p in pids: try: os.kill(int(p), signal.SIGKILL) except Exception: # noqa: BLE001 pass return len(pids) def service_status() -> dict[str, Any]: code, out = _systemctl("is-active", SERVICE, timeout=10.0) active = out.strip() or ("unknown" if code else "inactive") return { "unit": SERVICE, "state": active, "running": active == "active", } ENV_FILE = Path(os.environ.get("SANAD_ENV_FILE", Path.home() / ".sanad_agibot_env")) _CRED_KEYS = ("LINKSOUL_APP_ID", "LINKSOUL_APP_KEY", "LINKSOUL_APP_SECRET") def _env_file_keys() -> set[str]: """Names assigned in ~/.sanad_agibot_env. Checked here rather than in os.environ because that file is sourced by the launcher, not by the dashboard — the credentials are never visible in this process's own environment, so reading os.environ would always report them missing. Only key names are collected; the values are never read. """ found: set[str] = set() try: for line in ENV_FILE.read_text(encoding="utf-8").splitlines(): line = line.strip() if line.startswith("export "): line = line[len("export "):].lstrip() name, sep, value = line.partition("=") if sep and value.strip().strip("\"'"): found.add(name.strip()) except OSError: pass return found def linksoul_ready() -> dict[str, Any]: """Whether the pipeline brain can actually run right now. It needs edge-tts for the Emirati voice, miniaudio to decode that MP3 (this robot has no ffmpeg and the `agi` account has no sudo), requests for the STT call, and a Gemini key. It does NOT need the LinkSoul cloud SDK or its app credentials - that was the earlier cloud design, which required AgiBot to bind this robot to an application. The pipeline talks to the mic and speaker directly, so it works with no platform registration at all. """ missing_pkgs = [p for p in ("edge_tts", "miniaudio", "requests") if importlib.util.find_spec(p) is None] has_key = ("SANAD_GEMINI_API_KEY" in _env_file_keys() or bool(os.environ.get("SANAD_GEMINI_API_KEY")) or bool(os.environ.get("GEMINI_API_KEY"))) reasons = [] if missing_pkgs: reasons.append("missing Python packages: " + ", ".join(missing_pkgs) + " (pip3 install --user " + " ".join(missing_pkgs) + ")") if not has_key: reasons.append(f"SANAD_GEMINI_API_KEY is not set in {ENV_FILE}") return {"ready": not reasons, "reasons": reasons} def snapshot() -> dict[str, Any]: """Everything the Interaction card needs to render itself.""" mod = _profile() if mod is None: return { "available": False, "error": _profile_err or "session_profile.py not found", "sanad_dir": str(SANAD_DIR), "options": OPTIONS, "service": service_status(), "linksoul": linksoul_ready(), } resolved = mod.resolve() return { "available": True, "error": "", "options": OPTIONS, **mod.catalog(), "profile": {k: resolved[k] for k in ("enabled", "gender", "language", "model")}, "persona": { "file": resolved["persona_file"], "exists": resolved["persona_exists"], "name_en": resolved["name_en"], "name_ar": resolved["name_ar"], }, "voice": { "gemini": resolved["gemini_voice"], "edge": resolved["edge_voice"], }, "greeting": resolved["greeting"], "service": service_status(), "linksoul": linksoul_ready(), } def apply(enabled: bool, gender: str | None, language: str | None, model: str | None) -> dict[str, Any]: """Persist the selection and bring the conversation loop to that state. Returns the new snapshot plus a `greeting` the caller should speak when the session has just been switched on. The greeting is spoken by the dashboard rather than by the persona so it fires exactly once, at the moment Apply is pressed, on whichever model is selected — the LinkSoul path is a passive callback and cannot start talking on its own. """ mod = _profile() if mod is None: return {"ok": False, "error": _profile_err or "session_profile.py not found", "snapshot": snapshot()} # Validate a CANDIDATE before persisting anything. Saving first and # rejecting afterwards left an unlaunchable selection on disk: the dashboard # refused to start it, but start_agibot.sh would happily replay it at the # next boot, exec a runner that exits immediately, and Restart=on-failure # would turn that into a crash loop with the robot mute. current = mod.load() candidate = mod.normalize({ "enabled": enabled, "gender": gender or current["gender"], "language": language or current["language"], "model": model or current["model"], }) resolved = mod.resolve(candidate) if not resolved["persona_exists"]: return { "ok": False, "error": f"Persona file missing: {resolved['persona_file']}", "snapshot": snapshot(), } if enabled and resolved["model"] == "linksoul": ready = linksoul_ready() if not ready["ready"]: return {"ok": False, "error": "LinkSoul is not usable yet — " + "; ".join(ready["reasons"]), "snapshot": snapshot()} saved = mod.save(**{k: candidate[k] for k in ("enabled", "gender", "language", "model")}) resolved = mod.resolve(saved) # The voice process is owned by keepalive_daemon.sh, not by systemd - the # user manager dies with the last login session (no linger, needs root), so # the daemon had to take over. That means `systemctl stop` no longer stops # anything: pressing "Speaking off" left the robot still listening. So act # on the processes directly here, and let the daemon keep enforcing it. _kill_voice_processes() if enabled: _systemctl("reset-failed", SERVICE, timeout=10.0) code, out = _systemctl("restart", SERVICE, timeout=45.0) action = "restarted" else: code, out = _systemctl("stop", SERVICE, timeout=30.0) action = "stopped" # The voice process does not exit on SIGINT within TimeoutStopSec, so # systemd SIGKILLs it and books the unit as failed. That is an artifact # of an operator-requested stop, not a fault, and showing "failed" on # the card after pressing Off would be plainly wrong. if code == 0: _systemctl("reset-failed", SERVICE, timeout=10.0) if code != 0: return {"ok": False, "error": f"systemctl {action} failed: {out}", "snapshot": snapshot()} return { "ok": True, "action": action, "greeting": resolved["greeting"] if enabled else "", "snapshot": snapshot(), }