mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 00:11:57 +02:00
Assistant vocal local ESP32 : étapes 1 à 15, et 15b écrite
État du projet au premier commit : - firmware ESP32 : micro I2S, lecture anti-gigue, MQTT (état retenu, testament), mains libres, domotique de démonstration, chien de garde, présence du serveur ; - serveur Python : sessions, VAD, faster-whisper, pyannote, identification, droits par profil, domotique, mot de réveil, Piper ; - broker Mosquitto : configuration, comptes et ACL (non activés en développement) ; - étape 15b écrite, en attente d'essai : agent MQTT pour machines Linux. Étapes 1 à 15 validées. 203 tests, pyright sans erreur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Agent de l'assistant vocal pour une machine Linux du réseau (étape 15b).
|
||||
|
||||
L'agent se connecte au broker MQTT et attend des NOMS d'action sur
|
||||
agent/<id>/command. Il n'exécute que les actions de SON fichier de configuration
|
||||
(/etc/voice-agent/agent.yaml), chacune étant une commande fixe, sans shell et
|
||||
sans argument venu du réseau : un message forgé ne peut rien lancer d'autre.
|
||||
|
||||
agent/<id>/state {"status":"online","actions":[...]} retenu ; testament « offline »
|
||||
agent/<id>/command {"action":"shutdown","id":"...","by":"Denis"} (QoS 1)
|
||||
agent/<id>/result {"action":"shutdown","id":"...","ok":true,"code":0,"output":"..."}
|
||||
|
||||
Session propre : une commande envoyée pendant que la machine était éteinte n'est
|
||||
jamais exécutée à son retour.
|
||||
|
||||
Autonome : il ne dépend que de paho-mqtt et PyYAML, pas du serveur.
|
||||
|
||||
Exemples :
|
||||
python3 voice_agent.py --config /etc/voice-agent/agent.yaml
|
||||
make agent-run # sur le PC de développement, en mode essai (dry_run)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import yaml
|
||||
from paho.mqtt.enums import CallbackAPIVersion
|
||||
from paho.mqtt.properties import Properties
|
||||
from paho.mqtt.reasoncodes import ReasonCode
|
||||
|
||||
logger = logging.getLogger("voice_agent")
|
||||
|
||||
DEFAULT_TIMEOUT_S = 60.0
|
||||
OUTPUT_MAX_CHARS = 300 # fin de la sortie de la commande, renvoyée au serveur
|
||||
KEEPALIVE_S = 30 # hors ligne annoncé 45 s au plus après une coupure brutale
|
||||
AGENT_ID = re.compile(r"[a-z0-9][a-z0-9-]*")
|
||||
|
||||
# Lance une commande : le même contrat que subprocess.run, remplaçable dans les tests.
|
||||
Runner = Callable[..., "subprocess.CompletedProcess[str]"]
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Fichier de configuration de l'agent invalide."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Action:
|
||||
"""Une action permise : une commande fixe."""
|
||||
|
||||
command: tuple[str, ...] # programme et arguments, SANS shell : pas de ; | $() possibles
|
||||
timeout_s: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentConfig:
|
||||
"""Configuration de l'agent (agent.yaml)."""
|
||||
|
||||
agent_id: str
|
||||
host: str
|
||||
port: int
|
||||
username: str | None
|
||||
password: str | None
|
||||
topic_prefix: str
|
||||
dry_run: bool # essais : journalise la commande sans l'exécuter
|
||||
actions: dict[str, Action]
|
||||
|
||||
def topic(self, suffix: str) -> str:
|
||||
"""agent/<id>/<suffixe>."""
|
||||
return f"{self.topic_prefix}/{self.agent_id}/{suffix}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Result:
|
||||
"""Compte rendu d'une action, publié sur agent/<id>/result."""
|
||||
|
||||
action: str
|
||||
request_id: str
|
||||
ok: bool
|
||||
code: int | None # code de retour ; None si la commande n'a pas pu tourner
|
||||
output: str
|
||||
|
||||
def payload(self) -> bytes:
|
||||
return json.dumps({"action": self.action, "id": self.request_id, "ok": self.ok,
|
||||
"code": self.code, "output": self.output}, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def load_config(path: Path) -> AgentConfig:
|
||||
"""Lit et vérifie agent.yaml ; une erreur dit précisément quoi corriger.
|
||||
|
||||
Raises:
|
||||
ConfigError: fichier absent ou invalide.
|
||||
"""
|
||||
try:
|
||||
data: dict[str, Any] = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
except (OSError, yaml.YAMLError) as error:
|
||||
raise ConfigError(f"{path} : {error}") from error
|
||||
agent_id = str(data.get("id", ""))
|
||||
if not AGENT_ID.fullmatch(agent_id):
|
||||
raise ConfigError(f"{path} : id « {agent_id} » invalide (minuscules, chiffres, tirets : pc-bureau)")
|
||||
broker: dict[str, Any] = data.get("mqtt") or {}
|
||||
if not broker.get("host"):
|
||||
raise ConfigError(f"{path} : mqtt.host manquant (adresse IP du broker)")
|
||||
|
||||
actions: dict[str, Action] = {}
|
||||
for name, spec in dict(data.get("actions") or {}).items():
|
||||
command = spec.get("command") if isinstance(spec, dict) else None
|
||||
if not isinstance(command, list) or not command or not all(isinstance(part, str) and part
|
||||
for part in command):
|
||||
raise ConfigError(f"{path} : action « {name} » : command doit être une liste, "
|
||||
"ex. [systemctl, poweroff] (pas de chaîne shell)")
|
||||
timeout_s = float(spec.get("timeout_s", DEFAULT_TIMEOUT_S))
|
||||
if timeout_s <= 0:
|
||||
raise ConfigError(f"{path} : action « {name} » : timeout_s doit être positif")
|
||||
actions[str(name)] = Action(command=tuple(command), timeout_s=timeout_s)
|
||||
|
||||
return AgentConfig(
|
||||
agent_id=agent_id,
|
||||
host=str(broker["host"]),
|
||||
port=int(broker.get("port", 1883)),
|
||||
username=str(broker.get("username") or "") or None,
|
||||
password=str(broker.get("password") or "") or None,
|
||||
topic_prefix=str(broker.get("topic_prefix", "agent")),
|
||||
dry_run=bool(data.get("dry_run", False)),
|
||||
actions=actions,
|
||||
)
|
||||
|
||||
|
||||
def state_payload(config: AgentConfig, online: bool) -> bytes:
|
||||
"""État retenu : en ligne avec la liste des actions, ou hors ligne."""
|
||||
if not online:
|
||||
return json.dumps({"status": "offline"}).encode()
|
||||
return json.dumps({"status": "online", "actions": sorted(config.actions),
|
||||
"dry_run": config.dry_run}).encode()
|
||||
|
||||
|
||||
def run_action(config: AgentConfig, name: str, request_id: str, runner: Runner = subprocess.run) -> Result:
|
||||
"""Exécute une action de la liste ; tout autre nom est refusé."""
|
||||
action = config.actions.get(name)
|
||||
if action is None:
|
||||
return Result(name, request_id, False, None, f"action inconnue : {name}")
|
||||
shown = " ".join(action.command)
|
||||
if config.dry_run:
|
||||
logger.info("essai : %s (non execute, dry_run)", shown)
|
||||
return Result(name, request_id, True, 0, f"essai : {shown}")
|
||||
try:
|
||||
completed = runner(list(action.command), capture_output=True, text=True,
|
||||
timeout=action.timeout_s, check=False)
|
||||
except FileNotFoundError:
|
||||
return Result(name, request_id, False, None, f"programme introuvable : {action.command[0]}")
|
||||
except subprocess.TimeoutExpired:
|
||||
return Result(name, request_id, False, None, f"arretee apres {action.timeout_s:g} s")
|
||||
output = f"{completed.stdout}{completed.stderr}".strip()[-OUTPUT_MAX_CHARS:]
|
||||
return Result(name, request_id, completed.returncode == 0, completed.returncode, output)
|
||||
|
||||
|
||||
class VoiceAgent:
|
||||
"""Client MQTT de l'agent : reçoit les demandes, les exécute une à une, rend compte.
|
||||
|
||||
Les commandes tournent dans un fil de travail, jamais dans le fil réseau : une
|
||||
sauvegarde d'une heure ne doit pas couper la connexion au broker.
|
||||
"""
|
||||
|
||||
def __init__(self, config: AgentConfig, runner: Runner = subprocess.run) -> None:
|
||||
self._config = config
|
||||
self._runner = runner
|
||||
self._jobs: queue.Queue[tuple[str, str] | None] = queue.Queue()
|
||||
self._connected = threading.Event()
|
||||
# Session propre (clean_session, défaut MQTT 3.1.1) : pas de commande en attente au retour.
|
||||
self._client = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
client_id=f"voice-agent-{config.agent_id}")
|
||||
if config.username:
|
||||
self._client.username_pw_set(config.username, config.password)
|
||||
self._client.will_set(config.topic("state"), state_payload(config, False), qos=1, retain=True)
|
||||
self._client.on_connect = self._on_connect
|
||||
self._client.on_message = self._on_message
|
||||
self._client.reconnect_delay_set(min_delay=1, max_delay=30)
|
||||
self._worker = threading.Thread(target=self._work, name="actions", daemon=True)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Connexion (asynchrone, avec reconnexion) et fil de travail."""
|
||||
config = self._config
|
||||
logger.info("agent %s : broker %s:%d, actions %s%s", config.agent_id, config.host, config.port,
|
||||
", ".join(sorted(config.actions)) or "aucune", " (ESSAI : rien n'est execute)"
|
||||
if config.dry_run else "")
|
||||
if not config.username:
|
||||
logger.warning("pas de compte MQTT : tout appareil du reseau peut demander ces actions "
|
||||
"(comptes et droits du broker, etape 15)")
|
||||
self._worker.start()
|
||||
self._client.connect_async(config.host, config.port, keepalive=KEEPALIVE_S)
|
||||
self._client.loop_start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Annonce le départ (un arrêt propre ne déclenche pas le testament), puis se déconnecte."""
|
||||
if self._connected.is_set():
|
||||
info = self._client.publish(self._config.topic("state"), state_payload(self._config, False),
|
||||
qos=1, retain=True)
|
||||
try:
|
||||
info.wait_for_publish(2.0)
|
||||
except (RuntimeError, ValueError):
|
||||
pass
|
||||
self._client.disconnect()
|
||||
self._client.loop_stop()
|
||||
self._jobs.put(None)
|
||||
|
||||
def _on_connect(self, client: mqtt.Client, userdata: Any, flags: mqtt.ConnectFlags,
|
||||
reason_code: ReasonCode, properties: Properties | None) -> None:
|
||||
if reason_code.is_failure:
|
||||
logger.error("connexion refusee par le broker : %s (compte mqtt.username/password ?)", reason_code)
|
||||
return
|
||||
self._connected.set()
|
||||
client.subscribe(self._config.topic("command"), qos=1)
|
||||
client.publish(self._config.topic("state"), state_payload(self._config, True), qos=1, retain=True)
|
||||
logger.info("connecte : en attente sur %s", self._config.topic("command"))
|
||||
|
||||
def _on_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
|
||||
try:
|
||||
request: dict[str, Any] = json.loads(message.payload)
|
||||
name = str(request["action"])
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, KeyError, TypeError):
|
||||
logger.warning("demande illisible ignoree : %r", message.payload[:80])
|
||||
return
|
||||
logger.info("demande %s par %s (carte %s)", name, request.get("by") or "inconnu", request.get("board"))
|
||||
self._jobs.put((name, str(request.get("id", ""))))
|
||||
|
||||
def _work(self) -> None:
|
||||
while True:
|
||||
job = self._jobs.get()
|
||||
if job is None:
|
||||
return
|
||||
name, request_id = job
|
||||
result = run_action(self._config, name, request_id, self._runner)
|
||||
log = logger.info if result.ok else logger.warning
|
||||
log("%s : %s (code %s)%s", name, "ok" if result.ok else "ECHEC", result.code,
|
||||
f" : {result.output}" if result.output else "")
|
||||
self._client.publish(self._config.topic("result"), result.payload(), qos=1)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Lance l'agent jusqu'à Ctrl-C ou SIGTERM (arrêt du service)."""
|
||||
parser = argparse.ArgumentParser(description="agent de l'assistant vocal pour une machine Linux")
|
||||
parser.add_argument("--config", type=Path, default=Path("/etc/voice-agent/agent.yaml"))
|
||||
args = parser.parse_args(argv)
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s")
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except ConfigError as error:
|
||||
logger.error("%s", error)
|
||||
return 1
|
||||
|
||||
agent = VoiceAgent(config)
|
||||
stop = threading.Event()
|
||||
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
||||
agent.start()
|
||||
try:
|
||||
stop.wait()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
agent.stop()
|
||||
logger.info("arret de l'agent")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user