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
+191
View File
@@ -0,0 +1,191 @@
"""Connexion du serveur au broker MQTT (paho-mqtt 2.x)."""
from __future__ import annotations
import json
import logging
import threading
from collections.abc import Callable
from typing import Any
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from paho.mqtt.properties import Properties
from paho.mqtt.reasoncodes import ReasonCode
from voice_server.settings import MqttSettings
logger = logging.getLogger(__name__)
# Rappel : (identifiant de la carte, payload brut)
Handler = Callable[[str, bytes], Any]
# Rappel d'un topic hors voice/ (agents Linux, étape 15b) : (topic complet, payload brut)
TopicHandler = Callable[[str, bytes], Any]
ONLINE = json.dumps({"status": "online"}).encode()
OFFLINE = json.dumps({"status": "offline"}).encode()
REFUSED_CREDENTIALS = (134, 135) # « Bad user name or password », « Not authorized » (MQTT 5 / 3.1.1)
STOP_PUBLISH_TIMEOUT_S = 2.0
def presence_topic(prefix: str) -> str:
"""Topic de présence du serveur, lu par les cartes (étape 15).
Hors du motif <préfixe>/<carte>/<suffixe> auquel le serveur s'abonne
(voice/+/state...) : sinon il se prendrait lui-même pour une carte.
>>> presence_topic("voice")
'voice/server/status'
"""
return f"{prefix}/server/status"
def refusal_hint(reason: ReasonCode, client_id: str) -> str:
"""Explication d'une connexion refusée, pour le journal."""
if reason.value in REFUSED_CREDENTIALS:
return (f"connexion refusee par le broker : {reason}. Verifiez MQTT_USERNAME et MQTT_PASSWORD "
"dans server/.env, et que ce compte existe (make mqtt-user NAME=voice-server, "
"puis make mosquitto-docker)")
return f"connexion refusee par le broker : {reason} (client « {client_id} »)"
def split_topic(topic: str, prefix: str) -> tuple[str, str] | None:
"""Découpe « <préfixe>/<carte>/<suffixe> » en (carte, suffixe), sinon None.
>>> split_topic("voice/esp32-01/audio/in", "voice")
('esp32-01', 'audio/in')
"""
parts = topic.split("/", 2)
if len(parts) != 3 or parts[0] != prefix or not parts[1] or not parts[2]:
return None
return parts[1], parts[2]
class MqttLink:
"""Abonne le serveur aux topics de toutes les cartes et répartit les messages.
Le réseau tourne dans un fil créé par paho (loop_start) : les rappels sont
donc appelés depuis ce fil. Reconnexion automatique, abonnements refaits à
chaque connexion.
Avec presence=True (le vrai serveur seulement, jamais un outil de test),
le lien publie « online » retenu sur voice/server/status et confie au broker
un testament « offline » : si le serveur meurt, les cartes le savent et
coupent leur micro.
"""
def __init__(self, settings: MqttSettings, presence: bool = False) -> None:
"""
Args:
presence: annonce la présence du serveur (étape 15) ; False pour les outils,
dont l'arrêt ne doit pas faire croire aux cartes que le serveur est parti.
"""
self._settings = settings
self._presence = presence_topic(settings.topic_prefix) if presence else None
self._handlers: dict[str, Handler] = {}
self._topic_handlers: dict[str, TopicHandler] = {}
self._connected = threading.Event()
self._client = mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
client_id=settings.client_id,
)
if settings.username:
self._client.username_pw_set(settings.username, settings.password)
self._client.on_connect = self._on_connect
self._client.on_disconnect = self._on_disconnect
self._client.on_message = self._on_message
self._client.reconnect_delay_set(min_delay=1, max_delay=10)
if self._presence is not None:
# Testament : publié PAR LE BROKER si le serveur disparaît sans prévenir
# (plantage, coupure) ; retenu, pour une carte qui se connecte plus tard.
self._client.will_set(self._presence, OFFLINE, qos=1, retain=True)
def on(self, suffix: str, handler: Handler) -> None:
"""Associe un rappel à un suffixe : « event », « audio/in », « state »..."""
self._handlers[suffix] = handler
def on_topic(self, pattern: str, handler: TopicHandler) -> None:
"""Associe un rappel à un filtre complet hors voice/ : « agent/+/state » (étape 15b), QoS 1."""
self._topic_handlers[pattern] = handler
def start(self) -> None:
"""Lance la connexion (asynchrone) et le fil réseau."""
logger.info("connexion au broker %s:%d", self._settings.host, self._settings.port)
self._client.connect_async(self._settings.host, self._settings.port,
keepalive=self._settings.keepalive)
self._client.loop_start()
def wait_connected(self, timeout: float) -> bool:
"""Attend la première connexion au broker ; False si le délai expire."""
return self._connected.wait(timeout)
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
"""Publie vers une carte : <préfixe>/<carte>/<suffixe>. Utilisable depuis tout fil."""
topic = f"{self._settings.topic_prefix}/{device}/{suffix}"
self._client.publish(topic, payload, qos=qos)
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None:
"""Publie sur un topic complet, hors de voice/ : commandes domotiques home/... (étape 11)."""
self._client.publish(topic, payload, qos=qos)
def stop(self) -> None:
"""Déconnexion propre et arrêt du fil réseau."""
if self._presence is not None and self._connected.is_set():
# Une déconnexion propre ne déclenche PAS le testament : on annonce le départ.
info = self._client.publish(self._presence, OFFLINE, qos=1, retain=True)
try:
info.wait_for_publish(STOP_PUBLISH_TIMEOUT_S) # ne lève rien si le délai expire
except (RuntimeError, ValueError):
pass
if not info.is_published():
logger.warning("depart du serveur non annonce aux cartes (broker injoignable)")
self._client.disconnect()
self._client.loop_stop()
# ------------------------------------------------------------ rappels paho
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("%s", refusal_hint(reason_code, self._settings.client_id))
return
logger.info("connecte au broker")
self._connected.set()
if self._presence is not None:
# À chaque connexion : un broker redémarré (sans persistance) a oublié l'état retenu.
client.publish(self._presence, ONLINE, qos=1, retain=True)
logger.info("presence annoncee sur %s", self._presence)
# Abonnement refait à chaque connexion : une reconnexion repart de zéro.
for suffix in self._handlers:
topic = f"{self._settings.topic_prefix}/+/{suffix}"
qos = 0 if suffix.startswith("audio") else 1
client.subscribe(topic, qos=qos)
logger.info("abonne a %s (QoS %d)", topic, qos)
for pattern in self._topic_handlers:
client.subscribe(pattern, qos=1)
logger.info("abonne a %s (QoS 1)", pattern)
def _on_disconnect(self, client: mqtt.Client, userdata: Any,
flags: mqtt.DisconnectFlags, reason_code: ReasonCode,
properties: Properties | None) -> None:
self._connected.clear()
if reason_code.is_failure:
# Cause fréquente en TP : un second programme avec le même identifiant
# client ; le broker n'en garde qu'un et les deux s'éjectent en boucle.
logger.warning("connexion au broker perdue (%s), reconnexion... Si cela se repete, "
"un autre client utilise-t-il l'identifiant « %s » ?",
reason_code, self._settings.client_id)
def _on_message(self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None:
parsed = split_topic(message.topic, self._settings.topic_prefix)
try:
if parsed is not None:
handler = self._handlers.get(parsed[1])
if handler is not None:
handler(parsed[0], message.payload)
return
for pattern, topic_handler in self._topic_handlers.items():
if mqtt.topic_matches_sub(pattern, message.topic):
topic_handler(message.topic, message.payload)
except Exception: # un message fautif ne doit jamais arrêter le serveur
logger.exception("erreur en traitant %s", message.topic)