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,108 @@
|
||||
"""Tests de la domotique, étape 11 : phrase → intention → commande → topics (sans broker)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from voice_server.assistant import Assistant
|
||||
from voice_server.home_control import DeviceCommand, DeviceIntent, HomeController, decide, parse_intent, slug
|
||||
from voice_server.settings import HomeSettings, load_settings
|
||||
|
||||
HOME = HomeSettings(topic_prefix="home",
|
||||
rooms={"salon": "du salon", "cuisine": "de la cuisine", "salle de bain": "de la salle de bain"},
|
||||
boards={"esp32-01": "salon"})
|
||||
ROOMS = list(HOME.rooms)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("Fermez les volets du salon.", DeviceIntent("close", "shutter", "salon")),
|
||||
("fermé la porte", DeviceIntent("close", "door", None)), # faute de Whisper
|
||||
("Peux-tu allumer la lumière de la cuisine ?", DeviceIntent("on", "light", "cuisine")),
|
||||
("Éteins les lampes", DeviceIntent("off", "light", None)),
|
||||
("Baisse les stores de la salle de bain", DeviceIntent("close", "shutter", "salle de bain")),
|
||||
("Quelle heure est-il ?", None),
|
||||
],
|
||||
)
|
||||
def test_parse_intent(text: str, expected: DeviceIntent | None) -> None:
|
||||
assert parse_intent(text, ROOMS) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("intent", "answer", "command"),
|
||||
[
|
||||
(DeviceIntent("close", "shutter", "cuisine"), "Je ferme les volets de la cuisine.",
|
||||
DeviceCommand("close", "shutter", "cuisine")),
|
||||
(DeviceIntent("on", None, None), "J'allume la lumière du salon.", # « allume » : lumière, ici
|
||||
DeviceCommand("on", "light", "salon")),
|
||||
(DeviceIntent("open", None, None), "Que dois-je ouvrir ?", None),
|
||||
(DeviceIntent(None, "door", None), "Que dois-je faire avec la porte ?", None),
|
||||
(DeviceIntent("on", "shutter", None), "Je ne sais pas allumer les volets.", None),
|
||||
],
|
||||
)
|
||||
def test_decide(intent: DeviceIntent, answer: str, command: DeviceCommand | None) -> None:
|
||||
decision = decide(intent, HOME, "esp32-01")
|
||||
assert (decision.answer, decision.command) == (answer, command)
|
||||
|
||||
|
||||
def test_unknown_board_needs_a_room() -> None:
|
||||
"""Une carte sans pièce déclarée ne devine pas : elle demande."""
|
||||
assert decide(DeviceIntent("on", "light", None), HOME, "esp32-99").answer == "Dans quelle pièce ?"
|
||||
|
||||
|
||||
def test_slug() -> None:
|
||||
assert slug("salle de bain") == "salle-de-bain"
|
||||
assert slug("entrée") == "entree"
|
||||
|
||||
|
||||
class FakeLink:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[tuple[str, bytes, int]] = []
|
||||
|
||||
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
|
||||
self.sent.append((f"voice/{device}/{suffix}", payload, qos))
|
||||
|
||||
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None:
|
||||
self.sent.append((topic, payload, qos))
|
||||
|
||||
|
||||
def test_command_for_the_board_room_also_goes_to_the_board() -> None:
|
||||
"""Pièce de la carte : domotique ET carte (LED de démonstration)."""
|
||||
link = FakeLink()
|
||||
HomeController(HOME, link).execute("esp32-01", DeviceCommand("on", "light", "salon"), "Denis", "a1b2c3")
|
||||
|
||||
[(topic, payload, qos), (local_topic, local, local_qos)] = link.sent
|
||||
assert (topic, qos) == ("home/salon/light/set", 1)
|
||||
assert json.loads(payload) == {"action": "on", "by": "Denis", "board": "esp32-01", "session": "a1b2c3"}
|
||||
assert (local_topic, local_qos) == ("voice/esp32-01/control", 1)
|
||||
assert json.loads(local) == {"cmd": "device", "device": "light", "action": "on"}
|
||||
|
||||
|
||||
def test_command_for_another_room_only_goes_to_home() -> None:
|
||||
link = FakeLink()
|
||||
HomeController(HOME, link).execute("esp32-01", DeviceCommand("close", "shutter", "salle de bain"), None, "s")
|
||||
|
||||
assert [topic for topic, _, _ in link.sent] == ["home/salle-de-bain/shutter/set"]
|
||||
assert json.loads(link.sent[0][1])["by"] is None
|
||||
|
||||
|
||||
def test_assistant_prefers_home_commands_then_chat() -> None:
|
||||
"""Domotique d'abord ; le reste suit les règles de conversation."""
|
||||
assistant = Assistant(home=HOME)
|
||||
|
||||
order = assistant.respond("Allume la lumière", "Denis", "esp32-01")
|
||||
chat = assistant.respond("Bonjour", "Denis", "esp32-01")
|
||||
|
||||
assert order.command == DeviceCommand("on", "light", "salon") and order.text == "J'allume la lumière du salon."
|
||||
assert chat.command is None and chat.text.startswith("Bonjour Denis")
|
||||
assert Assistant().respond("Allume la lumière").command is None # sans config : pas de domotique
|
||||
|
||||
|
||||
def test_project_config_rooms_and_boards() -> None:
|
||||
"""Le config.yaml du dépôt déclare la pièce de la carte parmi les pièces connues."""
|
||||
home = load_settings(Path(__file__).resolve().parents[1] / "config.yaml").home
|
||||
assert home.boards["esp32-01"] in home.rooms
|
||||
Reference in New Issue
Block a user