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>
162 lines
6.8 KiB
Python
162 lines
6.8 KiB
Python
"""Domotique : de la phrase à la commande MQTT (étape 11).
|
|
|
|
Une commande tient en trois mots : une ACTION (allumer, éteindre, ouvrir,
|
|
fermer), un APPAREIL (lumière, volets, porte) et une PIÈCE (salon, cuisine…).
|
|
L'analyse cherche ces mots dans la phrase transcrite, sans tenir compte des
|
|
accents ni de la conjugaison exacte : Whisper écrit « fermé » pour « fermez ».
|
|
|
|
Sans pièce, c'est celle de la carte qui a entendu la phrase (« ici »).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import unicodedata
|
|
from dataclasses import dataclass
|
|
from typing import Protocol
|
|
|
|
from voice_server.settings import HomeSettings
|
|
from voice_server.text_normalize import normalize
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Mots normalisés (minuscules, sans accents) → identifiant. Identifiants en anglais :
|
|
# ce sont eux qui voyagent dans les topics et le JSON, comme dans le firmware.
|
|
ACTION_WORDS = {
|
|
"on": ("allume", "allumer", "allumez", "allumes", "rallume", "rallumer"),
|
|
"off": ("eteins", "eteint", "eteindre", "eteignez", "coupe", "couper", "coupez"),
|
|
"open": ("ouvre", "ouvrir", "ouvrez", "ouvres", "monte", "monter", "montez", "leve", "lever", "levez",
|
|
"remonte", "remonter"),
|
|
"close": ("ferme", "fermer", "fermez", "fermes", "baisse", "baisser", "baissez", "descends",
|
|
"descendre", "descendez"),
|
|
}
|
|
DEVICE_WORDS = {
|
|
"light": ("lumiere", "lumieres", "lampe", "lampes", "eclairage", "plafonnier"),
|
|
"shutter": ("volet", "volets", "store", "stores"),
|
|
"door": ("porte", "portes"),
|
|
}
|
|
ALLOWED = {"light": ("on", "off"), "shutter": ("open", "close"), "door": ("open", "close")}
|
|
|
|
DEVICE_PHRASE = {"light": "la lumière", "shutter": "les volets", "door": "la porte"}
|
|
FIRST_PERSON = {"on": "J'allume", "off": "J'éteins", "open": "J'ouvre", "close": "Je ferme"}
|
|
INFINITIVE = {"on": "allumer", "off": "éteindre", "open": "ouvrir", "close": "fermer"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeviceIntent:
|
|
"""Ce que la phrase demande ; un champ à None n'a pas été trouvé."""
|
|
|
|
action: str | None # on | off | open | close
|
|
device: str | None # light | shutter | door
|
|
room: str | None # pièce de config.yaml, telle qu'écrite (« salle de bain »)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeviceCommand:
|
|
"""Commande complète et valide, prête à publier."""
|
|
|
|
action: str
|
|
device: str
|
|
room: str
|
|
|
|
|
|
def slug(room: str) -> str:
|
|
"""Pièce utilisable dans un topic : « salle de bain » → « salle-de-bain », « entrée » → « entree »."""
|
|
ascii_room = unicodedata.normalize("NFKD", room).encode("ascii", "ignore").decode()
|
|
return re.sub(r"[^a-z0-9]+", "-", ascii_room.lower()).strip("-")
|
|
|
|
|
|
def _first(words: list[str], vocabulary: dict[str, tuple[str, ...]]) -> str | None:
|
|
"""Premier identifiant dont un mot apparaît dans la phrase (dans l'ordre de la phrase)."""
|
|
for word in words:
|
|
for key, forms in vocabulary.items():
|
|
if word in forms:
|
|
return key
|
|
return None
|
|
|
|
|
|
def parse_intent(text: str, rooms: list[str]) -> DeviceIntent | None:
|
|
"""Action, appareil et pièce de la phrase ; None si elle ne parle pas de domotique."""
|
|
words = normalize(text).split()
|
|
action = _first(words, ACTION_WORDS)
|
|
device = _first(words, DEVICE_WORDS)
|
|
if action is None and device is None:
|
|
return None
|
|
padded = f" {' '.join(words)} "
|
|
# Pièces les plus longues d'abord : « salle de bain » avant un éventuel « bain ».
|
|
room = next((name for name in sorted(rooms, key=len, reverse=True)
|
|
if f" {normalize(name)} " in padded), None)
|
|
return DeviceIntent(action=action, device=device, room=room)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Decision:
|
|
"""Réponse à dire, et commande à exécuter si la demande est complète."""
|
|
|
|
answer: str
|
|
command: DeviceCommand | None
|
|
|
|
|
|
def announce(command: DeviceCommand, home: HomeSettings) -> str:
|
|
"""Ce que dit l'assistant en exécutant : « Je ferme les volets du salon. »"""
|
|
return f"{FIRST_PERSON[command.action]} {DEVICE_PHRASE[command.device]} {home.rooms[command.room]}."
|
|
|
|
|
|
def request(command: DeviceCommand, home: HomeSettings) -> str:
|
|
"""La demande à l'infinitif : « ouvrir la porte du salon »."""
|
|
return f"{INFINITIVE[command.action]} {DEVICE_PHRASE[command.device]} {home.rooms[command.room]}"
|
|
|
|
|
|
def decide(intent: DeviceIntent, home: HomeSettings, board: str) -> Decision:
|
|
"""Complète l'intention (appareil implicite, pièce de la carte) ou dit ce qui manque."""
|
|
action, device = intent.action, intent.device
|
|
if device is None and action in ("on", "off"):
|
|
device = "light" # « allume », « éteins » : c'est de la lumière qu'on parle
|
|
if action is None:
|
|
return Decision(f"Que dois-je faire avec {DEVICE_PHRASE[device or 'light']} ?", None)
|
|
if device is None:
|
|
return Decision(f"Que dois-je {INFINITIVE[action]} ?", None)
|
|
if action not in ALLOWED[device]:
|
|
return Decision(f"Je ne sais pas {INFINITIVE[action]} {DEVICE_PHRASE[device]}.", None)
|
|
room = intent.room or home.boards.get(board)
|
|
if room is None or room not in home.rooms:
|
|
return Decision("Dans quelle pièce ?", None)
|
|
command = DeviceCommand(action=action, device=device, room=room)
|
|
return Decision(announce(command, home), command)
|
|
|
|
|
|
class TopicPublisher(Protocol):
|
|
"""Ce que la domotique attend du lien MQTT."""
|
|
|
|
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None: ...
|
|
|
|
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None: ...
|
|
|
|
|
|
class HomeController:
|
|
"""Publie les commandes : pour la domotique, et pour la carte si c'est sa pièce."""
|
|
|
|
def __init__(self, home: HomeSettings, link: TopicPublisher) -> None:
|
|
self._home = home
|
|
self._link = link
|
|
|
|
def execute(self, board: str, command: DeviceCommand, speaker: str | None, session: str) -> list[str]:
|
|
"""Publie la commande ; rend les topics utilisés (journal, tests).
|
|
|
|
home/<pièce>/<appareil>/set, QoS 1, non retenu : une commande rejouée à un
|
|
abonné qui arrive plus tard ferait bouger un volet sans que personne l'ait demandé.
|
|
"""
|
|
topic = f"{self._home.topic_prefix}/{slug(command.room)}/{command.device}/set"
|
|
payload = {"action": command.action, "by": speaker, "board": board, "session": session}
|
|
self._link.publish_topic(topic, json.dumps(payload, ensure_ascii=False).encode("utf-8"), 1)
|
|
topics = [topic]
|
|
if self._home.boards.get(board) == command.room:
|
|
local = {"cmd": "device", "device": command.device, "action": command.action}
|
|
self._link.publish(board, "control", json.dumps(local).encode(), 1)
|
|
topics.append(f"{board}/control")
|
|
logger.info("%s : commande %s %s %s -> %s", board, command.action, command.device,
|
|
command.room, ", ".join(topics))
|
|
return topics
|