mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 00:11:57 +02:00
d20976fea2
É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>
241 lines
11 KiB
Python
241 lines
11 KiB
Python
"""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]
|