diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 3056e0a..0d73f3b 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -181,12 +181,19 @@ Lancer le HUD + voix automatiquement à l'ouverture de session (écran « Jarvis ```bash stt --install-service # écrit ~/.config/systemd/user/stt.service, enable + start stt --uninstall-service # retire le service +stt --start # démarre le service +stt --restart # redémarre — recharge la config (modèle ASR, wake word…) stt --stop # éteint l'instance en cours (service systemd OU process lancé à la main) ``` Le unit est lié à `graphical-session.target` (démarre avec la session graphique, s'arrête avec). Suivi : `systemctl --user status|restart stt`, `journalctl --user -u stt -f`. +Ces actions sont aussi dispo **depuis le HUD** (Réglages → section « Service ») : **Redémarrer**, +**Mettre à jour** (`stt --update` puis `--restart`), **Arrêter**. Le HUD tournant *dans* le service, +ces actions sont lancées en **process détaché** (`start_new_session`) → le redémarrage va à son terme +même quand le process courant est tué (systemd possède le job). + `stt --stop` arrête proprement : si le service `stt.service` (systemd --user) est actif il fait `systemctl --user stop`, sinon il envoie un `SIGTERM` aux process `stt` vocaux trouvés via `/proc` (en s'excluant lui-même et les sous-commandes utilitaires `--update`/`--version`/…). diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index 5f7c218..50dc0c1 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt" -version = "0.8.0" +version = "0.9.0" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" requires-python = ">=3.11" readme = "README.md" diff --git a/stt/client/stt/cli.py b/stt/client/stt/cli.py index c0b130a..77124de 100644 --- a/stt/client/stt/cli.py +++ b/stt/client/stt/cli.py @@ -109,6 +109,47 @@ def _cmd_uninstall_service() -> int: return 0 +def _service_installed() -> bool: + """Le unit systemd --user stt.service est-il installé ?""" + import subprocess + + return subprocess.run( + ["systemctl", "--user", "cat", SERVICE_NAME], + capture_output=True, text=True, + ).returncode == 0 + + +def _cmd_start() -> int: + """Démarre le service STT (systemd --user).""" + import subprocess + + if not _service_installed(): + print("Service non installé — lance d'abord : stt --install-service") + print("(ou simplement `stt` pour un lancement direct, sans service)") + return 1 + subprocess.run(["systemctl", "--user", "start", SERVICE_NAME], check=False) + state = subprocess.run( + ["systemctl", "--user", "is-active", SERVICE_NAME], + capture_output=True, text=True, + ).stdout.strip() + print(f"✓ Service STT démarré (état : {state}).") + return 0 + + +def _cmd_restart() -> int: + """Redémarre le service STT — recharge la config (modèle ASR, wake word…).""" + import subprocess + + if not _service_installed(): + print("Service non installé — lance d'abord : stt --install-service") + return 1 + # systemd possède le job : même si ce process est tué (cas restart depuis le + # HUD, qui tourne DANS le service), le redémarrage va à son terme. + subprocess.run(["systemctl", "--user", "restart", SERVICE_NAME], check=False) + print("✓ Service STT redémarré.") + return 0 + + def _running_stt_pids() -> list[int]: """PID des instances `stt` (service voix/HUD) en cours, hors la commande courante. @@ -117,7 +158,7 @@ def _running_stt_pids() -> list[int]: """ me = os.getpid() utilitaires = { - "--stop", "--update", "--version", "--setup", "--text", + "--stop", "--start", "--restart", "--update", "--version", "--setup", "--text", "--install-service", "--uninstall-service", "--install-desktop", "--uninstall-desktop", } @@ -397,6 +438,10 @@ def main(argv: list[str] | None = None) -> int: p.add_argument("--no-tts", action="store_true", help="Réponses texte seulement (voix)") p.add_argument("--window", choices=["app", "kiosk", "none"], help="Ouverture du HUD : app (fenêtre, type Discord) | kiosk (plein écran) | none") + p.add_argument("--start", action="store_true", + help="Démarre le service STT (systemd --user)") + p.add_argument("--restart", action="store_true", + help="Redémarre le service STT (recharge la config : ASR, wake word…)") p.add_argument("--stop", action="store_true", help="Éteint le service STT en cours (systemd --user ou process vocal)") p.add_argument("--install-service", action="store_true", @@ -418,6 +463,10 @@ def main(argv: list[str] | None = None) -> int: return _cmd_setup() if args.update: return _cmd_update() + if args.start: + return _cmd_start() + if args.restart: + return _cmd_restart() if args.stop: return _cmd_stop() if args.install_service: diff --git a/stt/client/stt/hud/index.html b/stt/client/stt/hud/index.html index 9c827a4..f9eec5c 100644 --- a/stt/client/stt/hud/index.html +++ b/stt/client/stt/hud/index.html @@ -683,6 +683,11 @@ white-space: nowrap; } + /* Actions de service (redémarrer / mettre à jour / arrêter) */ + .svc-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } + .svc-actions .btn { width: auto; font-size: 14px; padding: 11px 10px; } + .svc-actions .btn#svcStop { grid-column: 1 / -1; } /* Arrêter sur toute la largeur */ + /* Toast — confirmation éphémère (clic / voix) */ .toast { position: fixed; @@ -911,6 +916,17 @@
Réglages envoyés ✓
+ +
+ Service +

Gérer le service STT sur cette machine (systemd --user).

+
+ + + +
+
+ @@ -1363,6 +1379,18 @@ el('resetConv').addEventListener('click', () => { flashSaved(); }); +/* ----- Actions de service (backend → `stt --restart|--stop|--update`) ----- + Après restart/stop le WS tombe (le process meurt) : le HUD passe « déconnecté » + puis se reconnecte tout seul quand le service revient. */ +function serviceAction(action, label) { + showToast(label); + if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) { console.log('[DÉMO] service →', action); return; } + ws.send(JSON.stringify({ type: 'control', action })); +} +el('svcRestart').addEventListener('click', () => serviceAction('service-restart', '↻ Redémarrage du service…')); +el('svcUpdate').addEventListener('click', () => serviceAction('service-update', '⬆ Mise à jour en cours…')); +el('svcStop').addEventListener('click', () => serviceAction('service-stop', '⏻ Arrêt du service…')); + /* ============================================================================ MODE DÉMO — boucle d'événements simulée ============================================================================ */ diff --git a/stt/client/stt/ui/app.py b/stt/client/stt/ui/app.py index ea58225..8ebb4db 100644 --- a/stt/client/stt/ui/app.py +++ b/stt/client/stt/ui/app.py @@ -102,6 +102,36 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: loop.run_in_executor(None, _open_url_external, svc.url) emit({"type": "toast", "text": f"{svc.icon} Ouverture de {svc.name}"}) + # Binaire `stt` résolu une fois (PATH du service --user ≠ shell interactif) + import shutil as _shutil + import sys as _sys + + stt_bin = _shutil.which("stt") or _sys.argv[0] + + def run_service_action(action: str) -> None: + """Pilote le service depuis le HUD : restart / stop / update. + + Lance `stt --` en processus DÉTACHÉ : pour restart/stop, le process + courant (le HUD = le service) meurt, mais systemd possède le job et le mène à + terme. Pour update : `stt --update` puis `stt --restart` (la maj précède le + redémarrage, sinon on se couperait l'herbe sous le pied). + """ + labels = { + "service-restart": "↻ Redémarrage du service…", + "service-stop": "⏻ Arrêt du service…", + "service-update": "⬆ Mise à jour en cours (peut prendre un moment)…", + } + if action not in labels: + return + emit({"type": "toast", "text": labels[action]}) + if action == "service-update": + argv = ["sh", "-c", f'"{stt_bin}" --update && "{stt_bin}" --restart'] + elif action == "service-stop": + argv = [stt_bin, "--stop"] + else: + argv = [stt_bin, "--restart"] + loop.run_in_executor(None, _spawn_detached, argv) + async def handler(ws): clients.add(ws) try: @@ -129,6 +159,10 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: elif mtype == "control" and msg.get("action") == "open-service": # indépendant du moteur vocal → cliquable même sans micro open_service(str(msg.get("service") or "")) + elif mtype == "control" and msg.get("action") in ( + "service-restart", "service-stop", "service-update" + ): + run_service_action(str(msg.get("action"))) elif mtype == "control" and engine is not None: action = msg.get("action") if action == "stop": @@ -229,6 +263,25 @@ def _open_browser(url: str, mode: str) -> None: webbrowser.open(url) +def _spawn_detached(argv: list[str]) -> None: + """Lance une commande détachée (nouvelle session) et muette — best-effort. + + `start_new_session=True` : la commande survit à la mort du HUD (cas restart/stop + déclenché depuis l'interface, où le process courant est lui-même arrêté). + """ + import subprocess + + try: + subprocess.Popen( + argv, + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: # noqa: BLE001 — action best-effort, jamais fatale + pass + + def _open_url_external(url: str) -> None: """Ouvre `url` dans le navigateur HABITUEL de l'utilisateur (onglet normal).