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:
Denis Defolie
2026-09-30 17:40:55 +02:00
commit d20976fea2
108 changed files with 15022 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
"""Machines Linux du réseau : de la phrase à l'agent de la machine (étape 15b).
Chaque machine fait tourner un AGENT (agent/voice_agent.py) : un petit client MQTT
qui n'exécute que les actions de SA liste, définie sur la machine elle-même. Le
serveur n'envoie qu'un NOM d'action (« shutdown »), jamais une commande shell :
même un message forgé ne peut rien faire d'autre que ce que la machine a permis.
agent/<machine>/state agent → tous {"status":"online","actions":[...]}, retenu + testament
agent/<machine>/command serveur → agent {"action":"shutdown","id":...,"by":"Denis"}
agent/<machine>/result agent → serveur {"action":...,"ok":true,"code":0,"output":"..."}
« Allume le PC » est à part : éteinte, la machine n'a plus d'agent. Le serveur
envoie alors un paquet Wake-on-LAN (réveil par le réseau) à son adresse MAC.
"""
from __future__ import annotations
import json
import logging
import re
import socket
import threading
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from voice_server.home_control import DeviceCommand, TopicPublisher
from voice_server.settings import MachinesSettings
from voice_server.text_normalize import normalize
logger = logging.getLogger(__name__)
WAKE = "wake" # action du serveur lui-même (Wake-on-LAN), pas de l'agent
WOL_PORT = 9 # port « discard » : l'usage pour le paquet magique
MACHINE_DEVICE = "machine" # clé des droits : access.rules.machine.<action>
@dataclass(frozen=True)
class MachineIntent:
"""Ce que la phrase demande à une machine ; action None : pas trouvée."""
machine: str # identifiant de l'agent (pc-bureau)
action: str | None # shutdown, reboot, wake...
@dataclass(frozen=True)
class MachineCommand:
"""Commande complète pour une machine, avec ses phrases (fixées à la décision)."""
action: str
machine: str
said: str # à l'exécution : « J'éteins le PC du bureau. »
asked: str # refus, confirmation : « éteindre le PC du bureau »
device: str = MACHINE_DEVICE # pour les droits, comme DeviceCommand.device
# Toute commande que l'assistant peut produire : domotique (étape 11) ou machine (15b).
Command = DeviceCommand | MachineCommand
@dataclass(frozen=True)
class MachineDecision:
"""Réponse à dire, et commande à exécuter si la demande est possible."""
answer: str
command: MachineCommand | None
@dataclass(frozen=True)
class AgentStatus:
"""Dernier état publié par un agent (retenu par le broker)."""
online: bool
actions: tuple[str, ...] # actions que la machine accepte
def agent_status(payload: bytes) -> AgentStatus | None:
"""{"status":"online","actions":[...]} → AgentStatus ; None si illisible."""
try:
state: dict[str, Any] = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
return None
if not isinstance(state, dict): # pyright: ignore[reportUnnecessaryIsInstance]
return None
actions = state.get("actions") or []
return AgentStatus(online=state.get("status") == "online",
actions=tuple(str(action) for action in actions))
def capitalized(phrase: str) -> str:
"""« le PC du bureau » → « Le PC du bureau » (en début de phrase)."""
return phrase[:1].upper() + phrase[1:]
def parse_machine_intent(text: str, settings: MachinesSettings) -> MachineIntent | None:
"""Machine et action de la phrase ; None si elle ne nomme aucune machine."""
words = normalize(text).split()
padded = f" {' '.join(words)} "
# Noms les plus longs d'abord : « ordinateur du bureau » avant un éventuel « ordinateur ».
names = sorted(((name, machine) for machine, spec in settings.machines.items() for name in spec.names),
key=lambda item: len(item[0]), reverse=True)
machine = next((machine for name, machine in names if f" {name} " in padded), None)
if machine is None:
return None
action = next((action for word in words for action, spec in settings.actions.items()
if word in spec.words), None)
return MachineIntent(machine=machine, action=action)
def decide_machine(intent: MachineIntent, settings: MachinesSettings,
status: AgentStatus | None) -> MachineDecision:
"""Vérifie que la demande est possible, d'après l'état annoncé par l'agent."""
machine = settings.machines[intent.machine]
name = machine.name
if intent.action is None:
return MachineDecision(f"Que dois-je faire avec {name} ?", None)
spec = settings.actions[intent.action]
online = status is not None and status.online
if intent.action == WAKE:
if online:
return MachineDecision(f"{capitalized(name)} est déjà en marche.", None)
if not machine.mac:
return MachineDecision(f"Je ne sais pas {spec.verb} {name} à distance.", None)
elif not online:
# Tournures sans genre : le nom peut être « la tour du garage ».
return MachineDecision(f"{capitalized(name)} ne répond pas : la machine est éteinte, "
"ou son agent est arrêté.", None)
elif status is not None and intent.action not in status.actions:
return MachineDecision(f"Je ne peux pas {spec.verb} {name} : son agent ne le permet pas.", None)
command = MachineCommand(action=intent.action, machine=intent.machine,
said=f"{spec.does} {name}.", asked=f"{spec.verb} {name}")
return MachineDecision(command.said, command)
def magic_packet(mac: str) -> bytes:
"""Paquet magique Wake-on-LAN : 6 octets 0xFF, puis 16 fois l'adresse MAC (102 octets).
Raises:
ValueError: adresse MAC mal écrite.
"""
if not re.fullmatch(r"([0-9a-fA-F]{2}[:-]?){5}[0-9a-fA-F]{2}", mac.strip()):
raise ValueError(f"adresse MAC invalide : « {mac} » (attendu : aa:bb:cc:dd:ee:ff)")
return b"\xff" * 6 + bytes.fromhex(re.sub(r"[:-]", "", mac.strip())) * 16
def send_broadcast(packet: bytes, address: str) -> None:
"""Envoie un datagramme UDP en diffusion sur le réseau local."""
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.sendto(packet, (address, WOL_PORT))
class MachineController:
"""Suit l'état des agents, interprète les phrases qui nomment une machine, publie les commandes.
on_state et on_result sont appelés depuis le fil réseau ; interpret et execute
depuis le fil de travail : un verrou protège les états.
"""
def __init__(self, settings: MachinesSettings, link: TopicPublisher,
send_udp: Callable[[bytes, str], None] = send_broadcast) -> None:
"""
Args:
send_udp: envoi du paquet Wake-on-LAN, remplaçable dans les tests.
"""
self._settings = settings
self._link = link
self._send_udp = send_udp
self._lock = threading.Lock()
self._states: dict[str, AgentStatus] = {}
# ------------------------------------------------------------ messages des agents
def on_state(self, topic: str, payload: bytes) -> None:
"""État d'un agent (agent/<machine>/state), retenu : reçu dès l'abonnement."""
machine = self._machine_of(topic)
status = agent_status(payload)
if machine is None or status is None:
return
with self._lock:
previous = self._states.get(machine)
self._states[machine] = status
if previous == status:
return
if machine not in self._settings.machines:
logger.warning("agent %s : absent de config.yaml (machines.list), on ne peut pas le nommer", machine)
elif status.online:
unknown = [action for action in status.actions if action not in self._settings.actions]
logger.info("agent %s : en ligne, actions %s%s", machine, ", ".join(status.actions) or "aucune",
f" (sans mots dans config.yaml : {', '.join(unknown)})" if unknown else "")
else:
logger.warning("agent %s : hors ligne", machine)
def on_result(self, topic: str, payload: bytes) -> None:
"""Compte rendu d'exécution (agent/<machine>/result) : pour le journal."""
machine = self._machine_of(topic)
try:
result: dict[str, Any] = json.loads(payload)
except (json.JSONDecodeError, UnicodeDecodeError):
return
log = logger.info if result.get("ok") else logger.warning
log("agent %s : %s %s (code %s)%s", machine, result.get("action"),
"executee" if result.get("ok") else "ECHOUEE", result.get("code"),
f" : {result['output']}" if result.get("output") else "")
def status(self, machine: str) -> AgentStatus | None:
"""Dernier état connu de l'agent ; None s'il ne s'est jamais annoncé."""
with self._lock:
return self._states.get(machine)
# ------------------------------------------------------------ phrases et commandes
def interpret(self, text: str) -> MachineDecision | None:
"""Réponse à une phrase qui nomme une machine ; None si elle n'en nomme aucune."""
intent = parse_machine_intent(text, self._settings)
if intent is None:
return None
return decide_machine(intent, self._settings, self.status(intent.machine))
def execute(self, board: str, command: MachineCommand, speaker: str | None, session: str) -> list[str]:
"""Réveil par le réseau, ou commande à l'agent ; rend les destinations (journal, tests)."""
if command.action == WAKE:
mac = self._settings.machines[command.machine].mac
self._send_udp(magic_packet(mac), self._settings.broadcast)
logger.info("%s : Wake-on-LAN %s (%s) -> %s:%d", board, command.machine, mac,
self._settings.broadcast, WOL_PORT)
return [f"wol:{mac}"]
# QoS 1, non retenu, et l'agent se connecte en session propre : une commande
# n'est jamais rejouée plus tard (un « éteins » qui attendrait le prochain démarrage...).
topic = f"{self._settings.topic_prefix}/{command.machine}/command"
payload = {"action": command.action, "id": session, "by": speaker, "board": board}
self._link.publish_topic(topic, json.dumps(payload, ensure_ascii=False).encode("utf-8"), 1)
logger.info("%s : commande %s %s -> %s", board, command.action, command.machine, topic)
return [topic]
def _machine_of(self, topic: str) -> str | None:
parts = topic.split("/")
if len(parts) != 3 or parts[0] != self._settings.topic_prefix or not parts[1]:
return None
return parts[1]