From ec88de1b3c2e8e14885f18d64f665bf909e7460e Mon Sep 17 00:00:00 2001 From: Denis Defolie Date: Tue, 6 Oct 2026 18:16:06 +0200 Subject: [PATCH] correction agent --- CONTEXT.md | 108 ++++++++++++++++++++------ Makefile | 4 +- README-EN.md | 4 +- README.md | 4 +- agent/Makefile | 2 +- agent/agent.yaml.example | 2 +- agent/voice_agent.py | 6 +- firmware/include/secrets.h.example | 2 +- server/config.yaml | 17 ++-- server/tests/test_access_control.py | 23 ++++++ server/tests/test_machine_control.py | 4 + server/tests/test_voice_pipeline.py | 33 +++++++- server/voice_server/access_control.py | 31 +++++++- server/voice_server/settings.py | 2 + server/voice_server/voice_pipeline.py | 12 ++- 15 files changed, 207 insertions(+), 47 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 862e3df..f8d0b7c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -61,6 +61,8 @@ profil (étape 14) et l'authentification MQTT (étape 15). **Placement décidé | Périphériques I2S | I2S0 = micro (RX), I2S1 = ampli (TX) | duplex séparé, pas de conflit d'horloges | | Broches | voir `firmware/include/config.h` | aucune broche de strapping (0, 2, 5, 12, 15) sur l'audio | | Broker de dev : conteneur `mosquitto_services-mosquitto-1`, compose `~/srv/docker-compose.mosquitto.yml`, config montée `~/srv/mosquitto/config/mosquitto.conf` ; **IP du PC = 192.168.1.104** (et non .0.104) | 2026-09-29 | constaté sur la machine | +| **Broker = Raspberry Pi 5, 192.168.1.200**, comptes exigés (anonyme refusé) ; un seul réglage : `mqtt.host` de `server/config.yaml`, relu par le Makefile (`MQTT_HOST`) | 2026-10-06 | décision utilisateur (« broker sur pi5 ») ; le conteneur local du PC de dev (192.168.1.104, anonyme) tourne encore mais n'est plus utilisé | +| Carte : Wi-Fi et broker en **NVS**, réglés par le portail `VoiceAssist-Config` (http://192.168.4.1, bouton PTT tenu 3 s au démarrage) ; `secrets.h` n'est plus qu'un repli si la NVS est vide (`WifiConfig`, ajouté par l'utilisateur) | 2026-10-06 | changer de broker = portail, pas recompilation | | MQTT : état JSON **retenu** + testament `{"status":"offline"}` retenu QoS 1, keepalive 10 s | 2026-09-29 | `offline` en 15 s max après coupure ; serveur informé même s'il démarre après l'ESP32 | | Wi-Fi : `setSleep(false)` | 2026-09-29 | le modem endormi ajoute 100 ms+ de latence : incompatible avec le flux audio | | Partitions `huge_app.csv` (3 Mo d'application, pas d'OTA) | 2026-09-29 | Wi-Fi + MQTT = 74 % de la partition par défaut | @@ -129,6 +131,7 @@ profil (étape 14) et l'authentification MQTT (étape 15). **Placement décidé | Niveau complet seulement si **score ≥ 0,6** (`full_min_score`), sinon rétrogradé en standard | 2026-09-30 | la voix n'est pas une preuve : une identification de justesse ne doit pas tout ouvrir | | Règles par appareil/action ; **action absente = niveau complet** | 2026-09-30 | on ne permet jamais par oubli | | Actions sensibles (`confirm: [door.open]`) : question, puis « oui »/« non » **de la même personne**, sur la même carte, en 15 s ; toute autre phrase abandonne la demande | 2026-09-30 | garde-fou pour la porte et pour les agents Linux (15b) | +| Confirmation : acceptée si la voix est reconnue comme le demandeur, **ou** si elle est sous le seuil mais que le demandeur reste le **profil le plus proche** avec `access.confirm_min_score` (**0,25**) ; voix reconnue comme quelqu'un d'autre : refusée | 2026-10-06 | mesuré : 6 « oui » seuls de Denis (~0,7 s de parole) à 0,29-0,47, 5 sous le seuil 0,45 ; autres profils face à Denis ≤ 0,12 (profils entiers, surtout voix de synthèse : à revoir avec de vraies personnes) | | Droits appliqués au **dernier locuteur** de la session | 2026-09-30 | c'est lui qui attend la réponse (cohérent avec l'étape 10) | | Broker : `allow_anonymous false` + `password_file` + `acl_file` ; comptes `voice-server` (voice/# et home/# en lecture-écriture) et **un par carte, nom = DEVICE_ID** (`pattern ... voice/%u/...`) | 2026-09-30 | micro inaudible et commandes impossibles pour le reste du réseau ; une carte ne peut ni écouter ni usurper une autre (vérifié sur broker jetable) | | Outils `make mqtt-*` : identifiants du serveur relus dans `server/.env` (`-include`), recettes en `@` | 2026-09-30 | pas de compte supplémentaire ; mot de passe jamais affiché par make ; mot de passe hexadécimal (`#`, `$` cassent make) | @@ -144,7 +147,7 @@ profil (étape 14) et l'authentification MQTT (étape 15). **Placement décidé | « Allume » = **Wake-on-LAN** envoyé par le serveur (paquet magique UDP port 9, `broadcast` configurable), si agent hors ligne et MAC connue | 2026-09-30 | machine éteinte = pas d'agent | | Phrase qui **nomme une machine** traitée avant la domotique ; noms de plusieurs mots (« pc du bureau ») | 2026-09-30 | « éteins le PC du bureau » ≠ « éteins la lumière du bureau » (bureau est une pièce) | | Droits : `access.rules.machine` (wake/lock standard, shutdown/reboot complet), `confirm: [machine.shutdown, machine.reboot]` ; `MachineCommand` porte ses phrases (`said`, `asked`) | 2026-09-30 | réutilise étape 14 sans changer `DeviceCommand` ; `CommandRouter` aiguille vers Home/MachineController | -| Service systemd **root** (`/opt/voice-agent`, `/etc/voice-agent/agent.yaml` 0600), `Restart=always` ; `agent/` autonome (`make -C agent install`) | 2026-09-30 | poweroff/reboot exigent root ; il suffit de copier le dossier sur la machine | +| Service systemd **root** (`/opt/voice-agent`, `/etc/voice-assistant/agent.yaml` 0600), `Restart=always` ; `agent/` autonome (`make -C agent install`) | 2026-09-30 | poweroff/reboot exigent root ; il suffit de copier le dossier sur la machine | | `dry_run` dans agent.yaml ; `make agent-run` crée `agent/agent.yaml` en mode essai | 2026-09-30 | tester « éteins le PC » sur le PC de développement sans l'éteindre | | **TLS non activé** (option de l'étape 15) ; **numéros de séquence** reportés à l'étape 16 (§3.4 du prompt : « phase 16 ») | 2026-09-30 | comptes + ACL ferment l'accès au micro ; TLS = certificats + ~40 Ko de RAM ESP32, à décider par l'utilisateur | | `make run` = **mode assistant** ; `make run-echo` = étape 5 | 2026-09-29 | une file + un fil de travail : les sessions sont traitées une à une, hors du fil réseau | @@ -212,8 +215,9 @@ mosquitto/passwd comptes hachés (make mqtt-user), NON version server/tests/test_mqtt_link.py présence, refus du broker, journal fichier [étape 15] server/voice_server/machine_control.py MachineController, phrases, Wake-on-LAN [étape 15b] server/voice_server/command_router.py CommandRouter : domotique / machines [étape 15b] +firmware/src/WifiConfig.* Wi-Fi + broker en NVS, portail AP/HTTP (utilisateur) [2026-10-06] agent/voice_agent.py agent Linux autonome (liste blanche, dry_run) [étape 15b] -agent/agent.yaml.example modèle de /etc/voice-agent/agent.yaml [étape 15b] +agent/agent.yaml.example modèle de /etc/voice-assistant/agent.yaml [étape 15b] agent/voice-agent.service, agent/Makefile service systemd, make -C agent install [étape 15b] server/tests/test_machine_control.py, test_voice_agent.py [étape 15b] server/ squelette Python (modules à partir de l'étape 3) @@ -230,29 +234,58 @@ TLS (option de l'étape 15, non activée). 1. Lire ce fichier, puis `README.md`. 2. `make help` liste toutes les cibles. 3. Vérifier l'étape en cours dans le tableau du §2, et **ne rien faire au-delà**. -4. Contrôle rapide de l'environnement : `make test` (98 tests), `make lint`, `make fw-check`. +4. Contrôle rapide de l'environnement : `make test` (203 tests), `make lint`, `make fw-check`. -### État au 2026-09-29 au soir (fin de session) +### État au 2026-09-30 au soir (fin de session) -- (état du 2026-09-29 au soir, dépassé : l'étape 9 a été validée le 2026-09-30, voir le journal.) -- Étape 9 alors écrite et testée hors ligne. Étapes 1 à 8 validées. -- Tout était vert à l'arrêt : 98 tests, pyright 0 erreur, `make fw-check` SUCCESS (RAM 36,7 %, - Flash 31,3 %), `pip check` propre. Aucun serveur lancé. -- `server/.venv` contient les extras `speech` et `diarization` (torch CPU) ; `server/models/` - contient whisper `small`, la voix Piper et le pipeline pyannote (accès HF réglé, `HF_TOKEN` dans - `server/.env` — **secret de l'utilisateur, ne jamais le modifier ni l'afficher**). -- **Aucun profil vocal** : `server/profiles/` n'existe pas encore (mes essais étaient dans le scratchpad). -- Firmware inchangé depuis l'étape 5 (déjà sur la carte). Broker : conteneur Docker de l'utilisateur, 192.168.1.104. +- **Étapes 1 à 15 validées.** Étape **15b écrite**, vérifiée hors carte (broker jetable), **pas encore + essayée par l'utilisateur**. Ne pas commencer l'étape 16 avant sa validation explicite. +- Vérifié à l'arrêt : 203 tests, pyright 0 erreur (tools/ et agent/ inclus), `make fw-check` + SUCCESS 0 avertissement (RAM 36,7 %, Flash 31,5 %). Git : `main`, 1 commit, arbre propre. + Aucun serveur, agent ni conteneur de test lancé (`voice-acl-test`, `voice-agent-test` supprimés). +- Broker : **Raspberry Pi 5, 192.168.1.200, comptes exigés** (depuis le 2026-10-06). Le serveur s'y + connecte (`voice-server`, `server/.env`). Le conteneur local (192.168.1.104, anonyme) n'est plus utilisé. +- Firmware : la version **étape 13** est sur la carte (essais mains libres réussis) ; téléversement de + la version **étape 15** (chien de garde, présence serveur) **non confirmé**. Les deux fonctionnent + avec le serveur actuel, qui publie sa présence. +- `server/.venv` : extras `speech`, `diarization` (torch CPU), `wakeword` ; `server/models/` complet ; + `HF_TOKEN` dans `server/.env` — **secret de l'utilisateur, ne jamais le modifier ni l'afficher**. + Ne jamais toucher `firmware/include/secrets.h` (utiliser `make fw-check`). +- 6 profils vocaux : Denis (complet), Gilles, Jessica, Pierre, Siwis, Tom (standard). Phrases de + test au téléphone : `recordings/voix-test/` (`make say`, `WAKE="Hey Mycroft"` = voix anglaise). +- Mot de réveil retenu par l'utilisateur : **« Hey Mycroft »** (prononcé à l'anglaise). -### Reprise de l'étape 9 (actions de l'utilisateur) +### Reprise de l'étape 15b (actions de l'utilisateur) -1. `make run`, puis 5 questions variées de 1 à 4 s, seul. -2. Dans un autre terminal, sans arrêter le serveur : `make enroll NAME=Denis`. -3. Nouvelles questions → le journal doit afficher `SPEAKER_00 = Denis (0.7x)`. -4. Une autre voix (personne ou haut-parleur) → `Inconnu (…, proche de Denis)`. -5. `make diarize-demo` → votre voix reconnue, Piper « Inconnu ». -6. Analyser le journal collé, puis demander la validation de l'étape 9 avant l'étape 10 - (association locuteur + texte : `{"speaker": "Denis", "text": "..."}`). +1. Terminal 1 : `make agent-run` (crée `agent/agent.yaml` en **mode essai**, `dry_run: true` ; vérifier + `id: pc-bureau`, `mqtt.host: 192.168.1.200` et le compte MQTT `pc-bureau`). +2. Terminal 2 : `make run` → `agent pc-bureau : en ligne, actions lock, reboot, shutdown`. +3. « Hey Mycroft, verrouille le PC du bureau » → « Je verrouille le PC du bureau. » ; agent : + `essai : loginctl lock-sessions (non execute, dry_run)`. +4. « Hey Mycroft, éteins le PC du bureau » → « Confirmez-vous… ? » → « Hey Mycroft, oui » → agent : + `essai : systemctl poweroff`. +5. « Hey Mycroft, allume le PC du bureau » → « …est déjà en marche. » +6. Ctrl-C sur l'agent → serveur : `agent pc-bureau : hors ligne` ; « éteins le PC du bureau » → + « …ne répond pas ». +7. Analyser les journaux collés, puis demander la validation de l'étape 15b avant l'étape 16. + +### Points ouverts (ni oubliés, ni décidés) + +- `machines.actions.*.words` : **un mot par entrée** (comparaison mot à mot) ; « lance la sauvegarde » + ne peut pas être reconnu tel quel. Proposé à l'utilisateur (2026-09-30) : accepter des expressions + de plusieurs mots. Pas de réponse encore. La négation n'est pas gérée (« n'éteins pas » = éteindre ; + la confirmation limite le risque). +- Test instable (rare, ~2 échecs sur 15 lancements de la suite, jamais seul) : + `test_diarization.py::test_embeddings_exist_for_a_short_question` (synthèse Piper aléatoire, existait + avant le 2026-10-06). Cause exacte non recherchée. +- « le PC » seul n'est pas un nom de machine (`names` : « pc du bureau »…) : à ajouter si l'utilisateur + le veut, ambigu avec plusieurs machines. +- Seuils du mot de réveil : réglage reporté (outil prêt : « mot presque reconnu »). +- Seuil d'identification 0,45 à recalibrer avec de vraies personnes. +- Whisper : hallucination sur 0,8 s de parole (« Je vous remercie de votre soutien… ») ; un « Ouvre » + perdu une fois en tête de commande. +- Étape 16 : diarisation en parallèle de Whisper, `start_ms` ~214 ms (fenêtre TCP lwIP ?), numéros de + séquence (§3.4 du prompt) ; TLS (option de l'étape 15) si l'utilisateur le demande. --- @@ -269,7 +302,8 @@ Reste à valider **avec le matériel** : câblage, niveau du micro, intelligibil ## 7. Git -Dépôt initialisé le 2026-09-30 (branche `main`, aucun dépôt distant). Identité **locale au dépôt** : +Dépôt initialisé le 2026-09-30 (branche `main`). Dépôt distant ajouté par l'utilisateur : +`origin` = https://github.com/deunix-educ/voice-assistant.git (commits et push : par l'utilisateur, ou à sa demande). Identité **locale au dépôt** : Denis Defolie . Commits et push seulement à la demande de l'utilisateur. Vérifié avant le premier commit : `secrets.h`, `.env`, `mosquitto/passwd`, `agent/agent.yaml`, profils vocaux, enregistrements (wav/mp3), modèles, journaux et environnements virtuels sont ignorés. @@ -766,3 +800,33 @@ profils vocaux, enregistrements (wav/mp3), modèles, journaux et environnements « ne répond pas ». 202 tests, pyright 0 erreur (agent/ inclus). En attente de l'essai réel. - **2026-09-30** — **Git initialisé** à la demande de l'utilisateur, avant l'essai de l'étape 15b : premier commit (108 fichiers) sur `main` ; contrôle des fichiers sensibles fait (§7). +- **2026-10-06** — Reprise sur le PC de dev. Entre-temps, 3 commits de l'utilisateur (`WifiConfig` : + portail de configuration et NVS ; README agent ; agent installé sous `/etc/voice-assistant`) et un + dépôt distant GitHub. Symptôme : « `make run` ne donne plus de réponse ». Cause : `mqtt.host` passé à + 192.168.1.200 (Pi 5, comptes exigés), mais **la carte n'est sur aucun broker** : absente de .200 + (aucun `voice/esp32-01/state`), `offline` sur .104. Serveur sain (connecté, agent `pc-bureau` vu en + ligne, `dry_run`). À 15:50, serveur éjecté toutes les 2 s : un autre client `voice-server` sur .200 + (second serveur, arrêté depuis). Décision : broker sur le Pi 5. Fait : `MQTT_HOST` du Makefile relu + dans `config.yaml`, modèles (`agent.yaml.example`, `secrets.h.example`, README) en .200, restes de + `/etc/voice-agent` alignés sur `/etc/voice-assistant` (dont le défaut `--config` de l'agent). + **Reste à faire par l'utilisateur** : compte `esp32-01` sur le Pi, puis portail de la carte + (broker 192.168.1.200, utilisateur `esp32-01`, mot de passe). Étape 15b toujours en attente d'essai. +- **2026-10-06** — Carte reconnectée, chaîne complète rétablie sur le Pi 5. Réseau réel : point d'accès + **`RPI5-AP`** du Pi (NetworkManager, réseau **10.42.0.0/24**, Pi = **10.42.0.1** ; le Pi est aussi + 192.168.1.200 en Ethernet, même broker). Carte : broker `10.42.0.1`, IP 10.42.0.73, signal −53 dBm. + Trois causes successives, lues dans le moniteur série (**921600 bauds**) : (1) session envoyée en + binaire sur le port série = MQTT non connecté (repli de l'étape 4) ; (2) `Reason: 211` : l'AP était + en **WPA1**, le core ESP32 3.x exige **WPA2** par défaut (`_minSecurity`) → AP passé en + `proto rsn`, CCMP, **canal 6** (était 13) ; (3) `Reason: 15 4WAY_HANDSHAKE_TIMEOUT` : mot de passe + Wi-Fi enregistré dans la carte faux, prouvé par `AP-STA-POSSIBLE-PSK-MISMATCH` dans le journal + `wpa_supplicant` du Pi → ressaisi dans le portail. Piège du portail `WifiConfig` : champs de mot de + passe toujours vides, un champ laissé vide efface la valeur enregistrée (correction proposée à + l'utilisateur, pas de réponse). Étape 15b toujours en attente d'essai et de validation. +- **2026-10-06** — Premiers essais réels de l'étape 15b (agent `pc-bureau` en `dry_run`, broker du Pi) : + « verrouille le PC du bureau » exécuté deux fois (agent : `essai : loginctl lock-sessions`). + (1) Mots anglais non reconnus (« Locke », « Reboot », « Shut down » → « Que dois-je faire… ») : + ajoutés à `machines.actions.*.words` (lock/locke/locker/loque, reboot/reboote/rebooter, + shutdown/shut). (2) Confirmation refusée : « OUI » de Denis à 0,35 = « voix inconnue » → règle du + profil le plus proche (`confirm_min_score: 0.25`, `AccessPolicy.resolve(..., closest, score)`, le + pipeline transmet `Identification.closest`). 214 tests, pyright 0 erreur. Serveur à relancer + (config.yaml lu au démarrage). Étape 15b : essais en cours, pas encore validée. diff --git a/Makefile b/Makefile index afee460..0ed9883 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,9 @@ SPEAKER ?= SAY_OUT ?= recordings/voix-test/phrase.wav WAKE ?= WAKE_VOICE ?= en_GB-alan-medium -MQTT_HOST ?= 192.168.1.104 +# Adresse du broker : UN seul réglage, mqtt.host de server/config.yaml, relu ici pour les +# cibles mqtt-* (surcharge ponctuelle : make mqtt-watch MQTT_HOST=...). +MQTT_HOST ?= $(shell awk '/^mqtt:/{m=1;next} m && $$1=="host:"{print $$2; exit}' server/config.yaml) MSG ?= {"cmd":"ping"} MOSQ_DIR ?= $(HOME)/srv/mosquitto MOSQ_COMPOSE ?= $(HOME)/srv/docker-compose.mosquitto.yml diff --git a/README-EN.md b/README-EN.md index c82da86..8856d5b 100644 --- a/README-EN.md +++ b/README-EN.md @@ -202,7 +202,7 @@ exposed. # 1. Copy the agent/ directory from the Raspberry Pi scp -r /home/rpi5/voice-assistant/agent/ user@desktop-pc:~/voice-agent-install/ -# 2. Install the service (creates /opt/voice-agent/, /etc/voice-agent/, systemd unit) +# 2. Install the service (creates /opt/voice-agent/, /etc/voice-assistant/, systemd unit) cd ~/voice-agent-install sudo make install @@ -214,7 +214,7 @@ make mosquitto-docker # or mosquitto-config — reloads the broker # 4. Configure the agent on the target machine sudo nano /etc/voice-assistant/agent.yaml # id: pc-bureau ← name used in MQTT topics -# mqtt.host: 192.168.1.104 ← broker IP address +# mqtt.host: 192.168.1.200 ← broker IP address # mqtt.username: pc-bureau ← same value as id (empty if anonymous broker) # mqtt.password: ← generated in step 3 (empty if anonymous broker) # dry_run: false ← true to test without executing anything diff --git a/README.md b/README.md index 8bb4ab5..6d17b21 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ configuration — aucun shell exposé. # 1. Copier le répertoire agent/ (depuis le Raspberry Pi) scp -r /home/rpi5/voice-assistant/agent/ user@pc-bureau:~/voice-agent/ -# 2. Installer le service (crée /opt/voice-agent/, /etc/voice-agent/, unité systemd) +# 2. Installer le service (crée /opt/voice-agent/, /etc/voice-assistant/, unité systemd) cd ~/voice-agent sudo make install @@ -215,7 +215,7 @@ make mosquitto-docker # ou mosquitto-config — recharge le broker # 4. Configurer l'agent sur la machine cible sudo nano /etc/voice-assistant/agent.yaml # id: pc-bureau ← nom dans les topics MQTT -# mqtt.host: 192.168.1.104 ← adresse IP du broker +# mqtt.host: 192.168.1.200 ← adresse IP du broker # mqtt.username: pc-bureau ← même valeur que id (vide si broker anonyme) # mqtt.password: ← généré à l'étape 3 (vide si broker anonyme) # dry_run: false ← true pour tester sans rien exécuter diff --git a/agent/Makefile b/agent/Makefile index 5172203..e3b21ca 100644 --- a/agent/Makefile +++ b/agent/Makefile @@ -40,4 +40,4 @@ uninstall: ## Retire le service et le programme (garde la configuration) sudo rm -rf $(PREFIX) log: ## Journal de l'agent installé (Ctrl-C pour quitter) - journalctl -u voice-agent -f + sudo journalctl -u voice-agent -f diff --git a/agent/agent.yaml.example b/agent/agent.yaml.example index 134ecc2..5d5f7b6 100644 --- a/agent/agent.yaml.example +++ b/agent/agent.yaml.example @@ -12,7 +12,7 @@ id: pc-bureau mqtt: - host: 192.168.1.104 # adresse IP du broker + host: 192.168.1.200 # adresse IP du broker (Raspberry Pi 5) port: 1883 username: "" # compte MQTT (étape 15), nom = id ; vide : broker anonyme password: "" diff --git a/agent/voice_agent.py b/agent/voice_agent.py index c801235..d99c0f1 100644 --- a/agent/voice_agent.py +++ b/agent/voice_agent.py @@ -3,7 +3,7 @@ L'agent se connecte au broker MQTT et attend des NOMS d'action sur agent//command. Il n'exécute que les actions de SON fichier de configuration -(/etc/voice-agent/agent.yaml), chacune étant une commande fixe, sans shell et +(/etc/voice-assistant/agent.yaml), chacune étant une commande fixe, sans shell et sans argument venu du réseau : un message forgé ne peut rien lancer d'autre. agent//state {"status":"online","actions":[...]} retenu ; testament « offline » @@ -16,7 +16,7 @@ jamais exécutée à son retour. Autonome : il ne dépend que de paho-mqtt et PyYAML, pas du serveur. Exemples : - python3 voice_agent.py --config /etc/voice-agent/agent.yaml + python3 voice_agent.py --config /etc/voice-assistant/agent.yaml make agent-run # sur le PC de développement, en mode essai (dry_run) """ @@ -252,7 +252,7 @@ class VoiceAgent: def main(argv: list[str]) -> int: """Lance l'agent jusqu'à Ctrl-C ou SIGTERM (arrêt du service).""" parser = argparse.ArgumentParser(description="agent de l'assistant vocal pour une machine Linux") - parser.add_argument("--config", type=Path, default=Path("/etc/voice-agent/agent.yaml")) + parser.add_argument("--config", type=Path, default=Path("/etc/voice-assistant/agent.yaml")) args = parser.parse_args(argv) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s") try: diff --git a/firmware/include/secrets.h.example b/firmware/include/secrets.h.example index bee8318..8db5e8f 100644 --- a/firmware/include/secrets.h.example +++ b/firmware/include/secrets.h.example @@ -22,7 +22,7 @@ static constexpr const char* WIFI_PASSWORD = "mon-mot-de-passe"; /// Adresse IP du broker Mosquitto (Raspberry Pi ou PC). Une adresse IP plutôt /// qu'un nom : pas de résolution DNS, donc pas d'attente supplémentaire. /// Sur Linux : `ip -4 addr` sur la machine du broker. -static constexpr const char* MQTT_HOST = "192.168.1.104"; +static constexpr const char* MQTT_HOST = "192.168.1.200"; static constexpr uint16_t MQTT_PORT = 1883; /// Compte MQTT de la carte (étape 15), créé par « make mqtt-user NAME=esp32-01 ». diff --git a/server/config.yaml b/server/config.yaml index 8a16856..3aac876 100644 --- a/server/config.yaml +++ b/server/config.yaml @@ -11,7 +11,7 @@ audio: # étape 4 : broker MQTT. Le serveur écoute TOUTES les cartes : voice/+/... mqtt: - host: localhost # le broker tourne sur la même machine que le serveur + host: 10.42.0.1 # broker du Raspberry Pi 5 (comptes exigés : server/.env) ; relu par le Makefile port: 1883 keepalive: 30 client_id: voice-server # unique sur le broker @@ -119,9 +119,14 @@ access: machine: {wake: standard, lock: standard, shutdown: complet, reboot: complet} # étape 15b confirm: [door.open, machine.shutdown, machine.reboot] # « Confirmez-vous ? » puis « oui » confirm_timeout_s: 15 + # Un « oui » seul dure ~0,7 s : trop court pour atteindre speaker.similarity_threshold + # (mesuré : Denis 0,29 à 0,47 ; les autres profils face à Denis : 0,12 au plus). + # La confirmation est donc acceptée si le demandeur reste le profil le PLUS PROCHE, + # avec au moins ce score. Une voix reconnue comme quelqu'un d'autre est refusée. + confirm_min_score: 0.25 # étape 15b : machines Linux du réseau. Chacune fait tourner un agent (agent/voice_agent.py) -# qui n'exécute QUE les actions de SA liste (/etc/voice-agent/agent.yaml) ; ici, on dit +# qui n'exécute QUE les actions de SA liste (/etc/voice-assistant/agent.yaml) ; ici, on dit # seulement comment on les appelle. « allume » = Wake-on-LAN, envoyé par le serveur # (la machine éteinte n'a pas d'agent). Nouvelle action : l'ajouter à l'agent, ici # (mots et phrases), puis dans access.rules.machine (sinon : niveau complet exigé). @@ -129,10 +134,12 @@ machines: topic_prefix: agent # agent//state (retenu), command, result broadcast: 255.255.255.255 # Wake-on-LAN : diffusion sur le réseau local actions: # mots sans accents ; « does » et « verb » : phrases dites + # Un mot par entrée, tel que Whisper l'écrit : « lock » devient souvent « Locke », + # « shutdown » devient « Shut down » (d'où « shut »). wake: {words: [allume, allumer, allumez, demarre, demarrer, reveille, reveiller], does: "J'allume", verb: allumer} - shutdown: {words: [eteins, eteint, eteindre, eteignez, arrete, arreter, arretez], does: "J'éteins", verb: éteindre} - reboot: {words: [redemarre, redemarrer, redemarrez, relance, relancer], does: "Je redémarre", verb: redémarrer} - lock: {words: [verrouille, verrouiller, verrouillez], does: "Je verrouille", verb: verrouiller} + shutdown: {words: [eteins, eteint, eteindre, eteignez, arrete, arreter, arretez, shutdown, shut], does: "J'éteins", verb: éteindre} + reboot: {words: [redemarre, redemarrer, redemarrez, relance, relancer, reboot, reboote, rebooter], does: "Je redémarre", verb: redémarrer} + lock: {words: [verrouille, verrouiller, verrouillez, lock, locke, locker, loque], does: "Je verrouille", verb: verrouiller} list: # identifiant de l'agent → comment on l'appelle pc-bureau: # Plusieurs mots : « bureau » seul est une pièce. « p c » : Whisper écrit parfois « P.C. ». diff --git a/server/tests/test_access_control.py b/server/tests/test_access_control.py index 2be467d..48f48f7 100644 --- a/server/tests/test_access_control.py +++ b/server/tests/test_access_control.py @@ -105,6 +105,29 @@ def test_someone_else_cannot_confirm(policy: AccessPolicy) -> None: assert resolution.answer.startswith("Seul Denis") +def test_short_yes_is_accepted_when_the_requester_stays_the_closest_voice(policy: AccessPolicy) -> None: + """Essai réel du 2026-10-06 : « OUI » de Denis à 0,35, sous le seuil d'identification.""" + policy.ask("esp32-01", DOOR_OPEN, "Denis") + resolution = policy.resolve("esp32-01", "OUI", None, closest="Denis", score=0.35) + assert resolution is not None and resolution.command == DOOR_OPEN + + +@pytest.mark.parametrize( + ("speaker", "closest", "score"), + [ + (None, "Denis", 0.12), # trop loin de Denis : n'importe qui + (None, "Jessica", 0.40), # plus proche de quelqu'un d'autre + (None, None, None), # pas d'empreinte du tout + ("Jessica", "Jessica", 0.8), # reconnue comme quelqu'un d'autre + ], +) +def test_short_yes_from_someone_else_is_refused(policy: AccessPolicy, speaker: str | None, + closest: str | None, score: float | None) -> None: + policy.ask("esp32-01", DOOR_OPEN, "Denis") + resolution = policy.resolve("esp32-01", "oui", speaker, closest=closest, score=score) + assert resolution is not None and resolution.command is None + + def test_confirmation_expires(policy: AccessPolicy, clock: Clock) -> None: policy.ask("esp32-01", DOOR_OPEN, "Denis") clock.now += 16 diff --git a/server/tests/test_machine_control.py b/server/tests/test_machine_control.py index a0c9dd0..43dfa0a 100644 --- a/server/tests/test_machine_control.py +++ b/server/tests/test_machine_control.py @@ -57,6 +57,10 @@ def with_mac(mac: str = MAC) -> MachinesSettings: ("Éteins le P.C. du bureau.", "shutdown"), ("Tu peux redémarrer l'ordinateur du bureau ?", "reboot"), ("Verrouille le PC du bureau", "lock"), + # Mots anglais, tels que Whisper les a écrits à l'essai réel du 2026-10-06. + ("Locke le PC du bureau", "lock"), + ("Reboot le PC du bureau", "reboot"), + ("Shut down le PC du bureau", "shutdown"), ("Allume le PC du bureau", "wake"), ("Le PC du bureau", None), ], diff --git a/server/tests/test_voice_pipeline.py b/server/tests/test_voice_pipeline.py index b5de5cf..545ec1b 100644 --- a/server/tests/test_voice_pipeline.py +++ b/server/tests/test_voice_pipeline.py @@ -131,7 +131,7 @@ def session(duration_s: float = 1.5, peak_dbfs: float = -12.0) -> SessionResult: def pipeline(stt: FakeStt, tts: FakeTts, player: FakePlayer, vad: FakeVad | None = None, - diarizer: FakeDiarizer | None = None, identifier: FakeIdentifier | None = None, + diarizer: FakeDiarizer | None = None, identifier: FakeIdentifier | ShortYesIdentifier | None = None, publisher: FakePublisher | None = None, assistant: EchoAssistant | None = None, controller: FakeController | None = None, policy: AccessPolicy | None = None) -> VoicePipeline: ticks = iter([10.0, 11.2]) # début, puis réponse prête 1,2 s plus tard @@ -324,3 +324,34 @@ def test_sensitive_command_waits_for_yes() -> None: controller=controller, policy=policy).handle(session()) assert second is not None and second.command == door assert [call[1] for call in controller.calls] == [door] + + +class ShortYesIdentifier: + """Un « oui » de 0,7 s : sous le seuil, mais Denis reste le profil le plus proche (ou non).""" + + def __init__(self, closest: str, score: float) -> None: + self._verdict = Identification(name=None, score=score, closest=closest) + + def identify(self, embedding: np.ndarray) -> Identification: + return self._verdict + + +@pytest.mark.parametrize(("closest", "score", "executed"), [("Denis", 0.35, True), ("Jessica", 0.35, False), + ("Denis", 0.10, False)]) +def test_short_yes_confirms_only_from_the_closest_voice(closest: str, score: float, executed: bool) -> None: + """Essai réel du 2026-10-06 : « OUI » de Denis à 0,35 refusé comme « voix inconnue ».""" + events: list[str] = [] + controller = FakeController(events) + policy = AccessPolicy(ACCESS, HOME) + door = DeviceCommand("open", "door", "salon") + pipeline(FakeStt("ouvre la porte", words=DENIS_WORDS), FakeTts(), FakePlayer(events), + diarizer=FakeDiarizer(), identifier=FakeIdentifier(), assistant=EchoAssistant(door), + controller=controller, policy=policy).handle(session()) + + second = pipeline(FakeStt("oui", words=(Word(" oui", 0.0, 0.1),)), FakeTts(), FakePlayer(events), + diarizer=FakeDiarizer(), identifier=ShortYesIdentifier(closest, score), + assistant=EchoAssistant(), controller=controller, policy=policy).handle(session()) + + assert second is not None + assert [call[1] for call in controller.calls] == ([door] if executed else []) + assert second.answer == ("J'ouvre la porte du salon." if executed else "Seul Denis peut confirmer. J'annule.") diff --git a/server/voice_server/access_control.py b/server/voice_server/access_control.py index 9f4c979..f1c0760 100644 --- a/server/voice_server/access_control.py +++ b/server/voice_server/access_control.py @@ -142,8 +142,15 @@ class AccessPolicy: if speaker is not None: self._pending[board] = _Pending(command, speaker, self._clock() + self._access.confirm_timeout_s) - def resolve(self, board: str, text: str, speaker: str | None) -> Resolution | None: - """Réponse à une confirmation en attente ; None : pas de confirmation, requête ordinaire.""" + def resolve(self, board: str, text: str, speaker: str | None, closest: str | None = None, + score: float | None = None) -> Resolution | None: + """Réponse à une confirmation en attente ; None : pas de confirmation, requête ordinaire. + + Args: + speaker: personne reconnue, None si la voix n'atteint pas le seuil d'identification. + closest: profil le plus proche, même sous le seuil. + score: similarité avec ce profil. + """ pending = self._pending.pop(board, None) if pending is None or self._clock() > pending.expires: return None @@ -152,14 +159,30 @@ class AccessPolicy: said_no = any(word in NO for word in words) if not (said_yes or said_no): return None # autre chose : la demande en attente est abandonnée, on traite la nouvelle - if speaker != pending.speaker: + if speaker != pending.speaker and not self._probably(pending.speaker, speaker, closest, score): logger.warning("%s : confirmation de %s refusee, donnee par %s", board, pending.speaker, - speaker or "une voix inconnue") + speaker or f"une voix inconnue (proche de {closest}, {self._shown(score)})") return Resolution(f"Seul {pending.speaker} peut confirmer. J'annule.", None) + if speaker != pending.speaker: + logger.info("%s : confirmation de %s acceptee a %s : voix trop courte pour le seuil, mais " + "profil le plus proche", board, pending.speaker, self._shown(score)) if said_no: return Resolution("D'accord, j'annule.", None) return Resolution(self._announce(pending.command), pending.command) + def _probably(self, expected: str, speaker: str | None, closest: str | None, score: float | None) -> bool: + """Voix non reconnue, mais la personne attendue reste la plus proche : assez pour un « oui ». + + Un « oui » dure moins d'une seconde, trop peu pour une empreinte nette. La demande, + elle, a été reconnue au seuil normal quelques secondes plus tôt, sur la même carte. + """ + return (speaker is None and closest == expected and score is not None + and score >= self._access.confirm_min_score) + + @staticmethod + def _shown(score: float | None) -> str: + return "?" if score is None else f"{score:.2f}" + def _request(self, command: Command) -> str: """« ouvrir la porte du salon », « éteindre le PC du bureau ».""" return command.asked if isinstance(command, MachineCommand) else request(command, self._home) diff --git a/server/voice_server/settings.py b/server/voice_server/settings.py index 4758377..b785543 100644 --- a/server/voice_server/settings.py +++ b/server/voice_server/settings.py @@ -136,6 +136,7 @@ class AccessSettings: rules: dict[str, dict[str, str]] # appareil → action → niveau minimal confirm: tuple[str, ...] # « door.open » : demande « oui » avant d'exécuter confirm_timeout_s: float + confirm_min_score: float = 0.25 # « oui » trop court pour le seuil : accepté si le demandeur reste le plus proche @dataclass(frozen=True) @@ -333,6 +334,7 @@ def load_settings(path: Path) -> Settings: for device, actions in dict(access["rules"]).items()}, confirm=tuple(str(item) for item in access.get("confirm") or ()), confirm_timeout_s=float(access["confirm_timeout_s"]), + confirm_min_score=float(access.get("confirm_min_score", 0.25)), ), machines=_machines(data), recordings_dir=(base / str(storage["recordings_dir"])).resolve(), diff --git a/server/voice_server/voice_pipeline.py b/server/voice_server/voice_pipeline.py index b87002e..73d5869 100644 --- a/server/voice_server/voice_pipeline.py +++ b/server/voice_server/voice_pipeline.py @@ -57,7 +57,8 @@ class Policy(Protocol): def ask(self, board: str, command: Command, speaker: str | None) -> None: ... - def resolve(self, board: str, text: str, speaker: str | None) -> Resolution | None: ... + def resolve(self, board: str, text: str, speaker: str | None, closest: str | None = None, + score: float | None = None) -> Resolution | None: ... class Transcriber(Protocol): @@ -191,7 +192,9 @@ class VoicePipeline: last = utterances[-1] if utterances else None speaker = last.speaker if last is not None and last.speaker != UNKNOWN else None score = last.score if last is not None else None - reply, refused = self._decide(result.device, transcript.text, speaker, score) + verdict = verdicts.get(last.label) if last is not None else None + closest = verdict.closest if verdict is not None else None + reply, refused = self._decide(result.device, transcript.text, speaker, score, closest) answer = reply.text if reply.command is not None and self._controller is not None: # La commande part avant la réponse parlée : la lumière s'allume pendant que la carte le dit. @@ -209,13 +212,14 @@ class VoicePipeline: stt_s=transcript.elapsed_s, tts_s=speech.elapsed_s, ready_s=ready_s, answer_s=speech.audio_s) - def _decide(self, board: str, text: str, speaker: str | None, score: float | None) -> tuple[Reply, bool]: + def _decide(self, board: str, text: str, speaker: str | None, score: float | None, + closest: str | None = None) -> tuple[Reply, bool]: """Réponse et commande autorisée ; True si une commande a été refusée ou mise en attente.""" if not text: return Reply(NOTHING_HEARD), False if self._policy is not None: # Une confirmation attendue passe avant tout : « oui » n'est pas une requête. - resolution = self._policy.resolve(board, text, speaker) + resolution = self._policy.resolve(board, text, speaker, closest, score) if resolution is not None: return Reply(resolution.answer, resolution.command), resolution.command is None reply = self._assistant.respond(text, speaker, board)