feat(stt-client): contrôles du service dans le HUD + commandes stt --start/--restart (#35)

Ajoute la gestion du service STT directement depuis l'interface (Réglages →
section « Service ») et deux commandes CLI.

- CLI : `stt --start` (démarre le service) et `stt --restart` (recharge la config :
  modèle ASR, wake word…). Garde-fou si le unit n'est pas installé.
- HUD : boutons Redémarrer / Mettre à jour / Arrêter. Le HUD tournant DANS le
  service, les 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). « Mettre à jour » = `stt --update` puis `stt --restart`.
- bump 0.8.0 → 0.9.0.

Validé : py_compile, --start/--restart dans l'aide, et HUD via Playwright
(3 boutons émettent service-restart/-update/-stop, toast, aucune erreur JS,
rendu conforme).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-20 22:37:04 +02:00 committed by GitHub
parent 9c335d169b
commit 3b6099ffa0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 139 additions and 2 deletions

View file

@ -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`/…).

View file

@ -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"

View file

@ -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:

View file

@ -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 @@
<div class="saved-hint" id="savedHint">Réglages envoyés ✓</div>
</div>
<!-- Service (systemd --user, sur cette machine) -->
<div class="field">
<span class="flabel">Service</span>
<p class="hint">Gérer le service STT sur cette machine (systemd&nbsp;--user).</p>
<div class="svc-actions">
<button class="btn" id="svcRestart" title="Recharge la config : modèle ASR, wake word…">↻ Redémarrer</button>
<button class="btn" id="svcUpdate" title="Met à jour STT puis redémarre">⬆ Mettre à jour</button>
<button class="btn danger" id="svcStop" title="Arrête le service">⏻ Arrêter</button>
</div>
</div>
</div>
</aside>
@ -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
============================================================================ */

View file

@ -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 --<action>` 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).