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,165 @@
|
||||
"""Protocole série partagé entre le PC et l'ESP32 (étapes 1 et 2).
|
||||
|
||||
Même format dans les deux sens, et identique à firmware/src/SerialProtocol.h :
|
||||
|
||||
"VSA1" | type (1 octet) | length (uint16 LE) | payload | checksum (XOR, 1 octet)
|
||||
|
||||
Les lignes de texte de l'ESP32 (commençant par « # ») peuvent s'intercaler entre
|
||||
les trames : le décodeur se resynchronise sur le mot magique.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from typing import Iterator, Protocol
|
||||
|
||||
logger = logging.getLogger("serial_protocol")
|
||||
|
||||
MAGIC = b"VSA1"
|
||||
HEADER_SIZE = 7 # magic (4) + type (1) + length (2)
|
||||
MAX_PAYLOAD = 2048 # garde-fou, identique au firmware : un chunk légitime fait 1600 octets
|
||||
|
||||
|
||||
class FrameType(IntEnum):
|
||||
"""Types de trames."""
|
||||
|
||||
START = 1
|
||||
AUDIO = 2
|
||||
END = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Frame:
|
||||
"""Trame décodée."""
|
||||
|
||||
type: FrameType
|
||||
payload: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioFormat:
|
||||
"""Format audio annoncé par la trame START."""
|
||||
|
||||
sample_rate: int
|
||||
bits: int
|
||||
channels: int
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: bytes) -> "AudioFormat":
|
||||
"""Décode le descripteur de 8 octets de la trame START."""
|
||||
if len(payload) != 8:
|
||||
raise ValueError(f"descripteur START invalide ({len(payload)} octets)")
|
||||
sample_rate = int.from_bytes(payload[0:4], "little")
|
||||
bits = int.from_bytes(payload[4:6], "little")
|
||||
channels = int.from_bytes(payload[6:8], "little")
|
||||
return cls(sample_rate=sample_rate, bits=bits, channels=channels)
|
||||
|
||||
def to_payload(self) -> bytes:
|
||||
"""Encode le descripteur de 8 octets de la trame START."""
|
||||
return (
|
||||
self.sample_rate.to_bytes(4, "little")
|
||||
+ self.bits.to_bytes(2, "little")
|
||||
+ self.channels.to_bytes(2, "little")
|
||||
)
|
||||
|
||||
|
||||
def encode_frame(frame_type: FrameType, payload: bytes = b"") -> bytes:
|
||||
"""Construit une trame complète, prête à écrire sur le port série."""
|
||||
if len(payload) > MAX_PAYLOAD:
|
||||
raise ValueError(f"payload de {len(payload)} octets, maximum {MAX_PAYLOAD}")
|
||||
checksum = 0
|
||||
for byte in payload:
|
||||
checksum ^= byte
|
||||
header = MAGIC + bytes([int(frame_type)]) + len(payload).to_bytes(2, "little")
|
||||
return header + payload + bytes([checksum])
|
||||
|
||||
|
||||
class ByteSource(Protocol):
|
||||
"""Ce dont le décodeur a besoin d'un port série : lire des octets."""
|
||||
|
||||
def read(self, size: int = 1) -> bytes: ...
|
||||
|
||||
|
||||
class TextEcho:
|
||||
"""Affiche ligne par ligne le texte émis par l'ESP32."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pending = bytearray()
|
||||
|
||||
def feed(self, data: bytes) -> None:
|
||||
"""Ajoute des octets de texte ; journalise chaque ligne complète."""
|
||||
self._pending.extend(data)
|
||||
while True:
|
||||
end = self._pending.find(b"\n")
|
||||
if end < 0:
|
||||
break
|
||||
line = bytes(self._pending[:end]).decode("utf-8", errors="replace").strip()
|
||||
del self._pending[: end + 1]
|
||||
if line:
|
||||
logger.info("esp32 | %s", line)
|
||||
|
||||
|
||||
class FrameReader:
|
||||
"""Décode le flux série en trames, en tolérant du texte intercalé."""
|
||||
|
||||
def __init__(self, port: ByteSource) -> None:
|
||||
self._port = port
|
||||
self._buffer = bytearray()
|
||||
self._echo = TextEcho()
|
||||
|
||||
def frames(self) -> Iterator[Frame]:
|
||||
"""Produit les trames au fil de leur arrivée (générateur infini)."""
|
||||
while True:
|
||||
chunk = self._port.read(1024)
|
||||
if chunk:
|
||||
self._buffer.extend(chunk)
|
||||
yield from self._drain()
|
||||
|
||||
def _drain(self) -> Iterator[Frame]:
|
||||
"""Extrait toutes les trames complètes présentes dans le tampon."""
|
||||
while True:
|
||||
index = self._buffer.find(MAGIC)
|
||||
|
||||
if index < 0:
|
||||
# Pas de mot magique : tout est du texte, sauf les derniers octets
|
||||
# qui pourraient être un mot magique coupé en deux lectures.
|
||||
keep = len(MAGIC) - 1
|
||||
if len(self._buffer) > keep:
|
||||
self._echo.feed(bytes(self._buffer[:-keep]))
|
||||
del self._buffer[:-keep]
|
||||
return
|
||||
|
||||
if index > 0:
|
||||
self._echo.feed(bytes(self._buffer[:index]))
|
||||
del self._buffer[:index]
|
||||
|
||||
if len(self._buffer) < HEADER_SIZE:
|
||||
return # en-tête incomplet : on attend la suite
|
||||
|
||||
raw_type = self._buffer[4]
|
||||
length = int.from_bytes(self._buffer[5:7], "little")
|
||||
|
||||
if raw_type not in (int(t) for t in FrameType) or length > MAX_PAYLOAD:
|
||||
# Fausse détection : on avance d'un octet et on cherche à nouveau.
|
||||
del self._buffer[:1]
|
||||
continue
|
||||
|
||||
total = HEADER_SIZE + length + 1 # + checksum
|
||||
if len(self._buffer) < total:
|
||||
return # trame incomplète : on attend la suite
|
||||
|
||||
payload = bytes(self._buffer[HEADER_SIZE : HEADER_SIZE + length])
|
||||
checksum = self._buffer[total - 1]
|
||||
computed = 0
|
||||
for byte in payload:
|
||||
computed ^= byte
|
||||
|
||||
if checksum != computed:
|
||||
logger.warning("checksum invalide, trame ignoree")
|
||||
del self._buffer[:1]
|
||||
continue
|
||||
|
||||
del self._buffer[:total]
|
||||
yield Frame(type=FrameType(raw_type), payload=payload)
|
||||
Reference in New Issue
Block a user