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,186 @@
|
||||
"""Tests des machines Linux, étape 15b : phrases, décisions, droits, Wake-on-LAN (sans réseau)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import pytest
|
||||
|
||||
from voice_server.access_control import AccessPolicy
|
||||
from voice_server.assistant import Assistant
|
||||
from voice_server.command_router import CommandRouter
|
||||
from voice_server.home_control import DeviceCommand, HomeController
|
||||
from voice_server.machine_control import (
|
||||
AgentStatus,
|
||||
MachineCommand,
|
||||
MachineController,
|
||||
MachineIntent,
|
||||
decide_machine,
|
||||
magic_packet,
|
||||
parse_machine_intent,
|
||||
)
|
||||
from voice_server.mqtt_link import MqttLink
|
||||
from voice_server.settings import MachinesSettings, load_settings
|
||||
|
||||
SETTINGS = load_settings(Path(__file__).resolve().parents[1] / "config.yaml")
|
||||
MACHINES = SETTINGS.machines
|
||||
ONLINE = AgentStatus(online=True, actions=("lock", "reboot", "shutdown"))
|
||||
ONLINE_PAYLOAD = json.dumps({"status": "online", "actions": ["lock", "reboot", "shutdown"]}).encode()
|
||||
MAC = "aa:bb:cc:dd:ee:ff"
|
||||
|
||||
|
||||
class FakeLink:
|
||||
def __init__(self) -> None:
|
||||
self.topics: list[tuple[str, dict[str, object]]] = []
|
||||
|
||||
def publish(self, device: str, suffix: str, payload: bytes, qos: int) -> None:
|
||||
self.topics.append((f"voice/{device}/{suffix}", json.loads(payload)))
|
||||
|
||||
def publish_topic(self, topic: str, payload: bytes, qos: int) -> None:
|
||||
self.topics.append((topic, json.loads(payload)))
|
||||
|
||||
|
||||
def with_mac(mac: str = MAC) -> MachinesSettings:
|
||||
"""Machines de config.yaml, avec une adresse MAC : le réveil devient possible."""
|
||||
machine = dataclasses.replace(MACHINES.machines["pc-bureau"], mac=mac)
|
||||
return dataclasses.replace(MACHINES, machines={"pc-bureau": machine})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "action"),
|
||||
[
|
||||
("Éteins le PC du bureau.", "shutdown"),
|
||||
("Éteins le P.C. du bureau.", "shutdown"),
|
||||
("Tu peux redémarrer l'ordinateur du bureau ?", "reboot"),
|
||||
("Verrouille le PC du bureau", "lock"),
|
||||
("Allume le PC du bureau", "wake"),
|
||||
("Le PC du bureau", None),
|
||||
],
|
||||
)
|
||||
def test_phrases_naming_a_machine(text: str, action: str | None) -> None:
|
||||
assert parse_machine_intent(text, MACHINES) == MachineIntent("pc-bureau", action)
|
||||
|
||||
|
||||
def test_room_alone_is_not_a_machine() -> None:
|
||||
"""« bureau » est une pièce : seule « PC du bureau » désigne la machine."""
|
||||
assert parse_machine_intent("Éteins la lumière du bureau", MACHINES) is None
|
||||
|
||||
|
||||
def test_command_carries_its_sentences() -> None:
|
||||
decision = decide_machine(MachineIntent("pc-bureau", "shutdown"), MACHINES, ONLINE)
|
||||
assert decision.command == MachineCommand("shutdown", "pc-bureau", said="J'éteins le PC du bureau.",
|
||||
asked="éteindre le PC du bureau")
|
||||
assert decision.answer == "J'éteins le PC du bureau."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("action", "status", "answer"),
|
||||
[
|
||||
(None, ONLINE, "Que dois-je faire avec le PC du bureau ?"),
|
||||
("shutdown", None, "Le PC du bureau ne répond pas : la machine est éteinte, ou son agent est arrêté."),
|
||||
("shutdown", AgentStatus(False, ()), "Le PC du bureau ne répond pas : la machine est éteinte, "
|
||||
"ou son agent est arrêté."),
|
||||
("lock", AgentStatus(True, ("shutdown",)), "Je ne peux pas verrouiller le PC du bureau : "
|
||||
"son agent ne le permet pas."),
|
||||
("wake", ONLINE, "Le PC du bureau est déjà en marche."),
|
||||
("wake", None, "Je ne sais pas allumer le PC du bureau à distance."), # pas d'adresse MAC
|
||||
],
|
||||
)
|
||||
def test_impossible_requests_are_explained(action: str | None, status: AgentStatus | None, answer: str) -> None:
|
||||
decision = decide_machine(MachineIntent("pc-bureau", action), MACHINES, status)
|
||||
assert decision.command is None and decision.answer == answer
|
||||
|
||||
|
||||
def test_wake_needs_the_machine_off_and_a_mac() -> None:
|
||||
decision = decide_machine(MachineIntent("pc-bureau", "wake"), with_mac(), None)
|
||||
assert decision.command is not None and decision.answer == "J'allume le PC du bureau."
|
||||
|
||||
|
||||
def test_magic_packet() -> None:
|
||||
packet = magic_packet("AA-BB-CC-DD-EE-FF")
|
||||
assert len(packet) == 102 and packet[:6] == b"\xff" * 6
|
||||
assert packet[6:12] == bytes.fromhex("aabbccddeeff") and packet[6:] == packet[6:12] * 16
|
||||
with pytest.raises(ValueError, match="adresse MAC invalide"):
|
||||
magic_packet("aa:bb:cc")
|
||||
|
||||
|
||||
def test_controller_follows_agent_states_and_publishes_commands() -> None:
|
||||
link = FakeLink()
|
||||
controller = MachineController(MACHINES, link)
|
||||
unknown = controller.interpret("Éteins le PC du bureau")
|
||||
assert unknown is not None and unknown.command is None # agent jamais annoncé
|
||||
|
||||
controller.on_state("agent/pc-bureau/state", ONLINE_PAYLOAD)
|
||||
decision = controller.interpret("Éteins le PC du bureau")
|
||||
assert decision is not None and decision.command is not None
|
||||
assert controller.execute("esp32-01", decision.command, "Denis", "w123") == ["agent/pc-bureau/command"]
|
||||
assert link.topics == [("agent/pc-bureau/command",
|
||||
{"action": "shutdown", "id": "w123", "by": "Denis", "board": "esp32-01"})]
|
||||
|
||||
controller.on_state("agent/pc-bureau/state", b'{"status": "offline"}')
|
||||
offline = controller.interpret("Éteins le PC du bureau")
|
||||
assert offline is not None and offline.command is None
|
||||
|
||||
|
||||
def test_wake_sends_the_magic_packet_not_a_message() -> None:
|
||||
sent: list[tuple[bytes, str]] = []
|
||||
link = FakeLink()
|
||||
controller = MachineController(with_mac(), link, send_udp=lambda packet, address: sent.append((packet, address)))
|
||||
command = MachineCommand("wake", "pc-bureau", said="J'allume le PC du bureau.", asked="allumer le PC du bureau")
|
||||
controller.execute("esp32-01", command, "Denis", "w1")
|
||||
assert sent == [(magic_packet(MAC), "255.255.255.255")] and link.topics == []
|
||||
|
||||
|
||||
def test_machine_is_matched_before_home() -> None:
|
||||
"""« Éteins le PC du bureau » ne doit pas éteindre la lumière du bureau."""
|
||||
controller = MachineController(MACHINES, FakeLink())
|
||||
controller.on_state("agent/pc-bureau/state", ONLINE_PAYLOAD)
|
||||
assistant = Assistant(clock=lambda: datetime(2026, 9, 30, 12), home=SETTINGS.home, machines=controller)
|
||||
assert isinstance(assistant.respond("Éteins le PC du bureau", "Denis", "esp32-01").command, MachineCommand)
|
||||
home = assistant.respond("Éteins la lumière du bureau", "Denis", "esp32-01").command
|
||||
assert home == DeviceCommand("off", "light", "bureau")
|
||||
|
||||
|
||||
def test_rights_and_confirmation_for_machines() -> None:
|
||||
"""config.yaml : verrouiller = standard ; éteindre = complet + confirmation."""
|
||||
policy = AccessPolicy(SETTINGS.access, SETTINGS.home, MACHINES)
|
||||
lock = decide_machine(MachineIntent("pc-bureau", "lock"), MACHINES, ONLINE).command
|
||||
shutdown = decide_machine(MachineIntent("pc-bureau", "shutdown"), MACHINES, ONLINE).command
|
||||
assert lock is not None and shutdown is not None
|
||||
|
||||
assert policy.check(lock, "Pierre", 0.8).allowed
|
||||
refused = policy.check(shutdown, "Pierre", 0.8)
|
||||
assert not refused.allowed and refused.answer == ("Désolé Pierre, votre profil ne permet pas "
|
||||
"d'éteindre le PC du bureau.")
|
||||
verdict = policy.check(shutdown, "Denis", 0.8)
|
||||
assert verdict.confirm and verdict.answer == "Confirmez-vous : éteindre le PC du bureau ? Dites oui ou non."
|
||||
policy.ask("esp32-01", shutdown, "Denis")
|
||||
resolution = policy.resolve("esp32-01", "oui", "Denis")
|
||||
assert resolution is not None and resolution.command == shutdown
|
||||
assert resolution.answer == "J'éteins le PC du bureau."
|
||||
|
||||
|
||||
def test_router_sends_each_command_to_its_controller() -> None:
|
||||
link = FakeLink()
|
||||
router = CommandRouter(HomeController(SETTINGS.home, link), MachineController(MACHINES, link))
|
||||
machine = MachineCommand("lock", "pc-bureau", said="", asked="")
|
||||
assert router.execute("esp32-01", machine, None, "s") == ["agent/pc-bureau/command"]
|
||||
assert router.execute("esp32-01", DeviceCommand("on", "light", "cuisine"), None, "s") == \
|
||||
["home/cuisine/light/set"]
|
||||
|
||||
|
||||
def test_mqtt_link_routes_agent_topics() -> None:
|
||||
"""Les états des agents (agent/+/state) arrivent au bon rappel, pas à ceux des cartes."""
|
||||
link = MqttLink(SETTINGS.mqtt)
|
||||
received: list[str] = []
|
||||
link.on("state", lambda device, payload: received.append(f"carte {device}"))
|
||||
link.on_topic("agent/+/state", lambda topic, payload: received.append(topic))
|
||||
for topic in ("agent/pc-bureau/state", "voice/esp32-01/state", "agent/pc-bureau/result"):
|
||||
message = mqtt.MQTTMessage(topic=topic.encode())
|
||||
message.payload = b"{}"
|
||||
link._on_message(link._client, None, message) # pyright: ignore[reportPrivateUsage]
|
||||
assert received == ["agent/pc-bureau/state", "carte esp32-01"]
|
||||
Reference in New Issue
Block a user