Add EVS control portal, io_mode switching, and DAC-only speaker path
Some checks failed
Build and Push EVS Bridge Image / docker (push) Has been cancelled

This commit is contained in:
Kai
2026-02-15 13:16:41 +01:00
parent 179440858b
commit 04c59c3b25
13 changed files with 1257 additions and 89 deletions

View File

@@ -9,6 +9,7 @@ It provides:
- Optional Home Assistant webhook callbacks (`connected`, `start`, `stop`, `disconnected`)
- VAD auto-segmentation (`vad_segment`) with pre-roll/post-roll
- Optional STT worker (`vad_segment` -> `transcript`) via MQTT
- Optional 1:1 device pairing (`mic_device -> speaker_device`) for echo routing
## 1) Start the bridge
@@ -47,6 +48,11 @@ In `include/secrets.h`:
- set bridge host
- set WS port/path
- set unique `EVS_DEVICE_ID`
- set runtime IO mode:
- `EVS_DEFAULT_IO_MODE "mic"` for microphone device
- `EVS_DEFAULT_IO_MODE "spk"` for speaker device
- set DAC output pin on speaker device:
- `EVS_SPK_DAC_PIN 25` or `26`
Then upload firmware.
@@ -160,6 +166,7 @@ services:
MQTT_BASE_TOPIC: "evs"
MQTT_TTS_TOPIC: "evs/+/play_pcm16le"
MQTT_STATUS_RETAIN: "true"
DEVICE_PAIR_MAP: '{"esp32-evs-1-mic":"esp32-evs-1-spk"}'
HA_WEBHOOK_URL: ""
SAVE_SESSIONS: "true"
SESSIONS_DIR: "/data/sessions"

View File

@@ -41,6 +41,7 @@ MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", "")
MQTT_BASE_TOPIC = os.getenv("MQTT_BASE_TOPIC", "evs")
MQTT_TTS_TOPIC = os.getenv("MQTT_TTS_TOPIC", f"{MQTT_BASE_TOPIC}/+/play_pcm16le")
MQTT_STATUS_RETAIN = getenv_bool("MQTT_STATUS_RETAIN", True)
DEVICE_PAIR_MAP_JSON = os.getenv("DEVICE_PAIR_MAP", "").strip()
HA_WEBHOOK_URL = os.getenv("HA_WEBHOOK_URL", "").strip()
SAVE_SESSIONS = getenv_bool("SAVE_SESSIONS", True)
@@ -125,6 +126,21 @@ class BridgeState:
state = BridgeState()
DEVICE_PAIR_MAP: Dict[str, str] = {}
if DEVICE_PAIR_MAP_JSON:
try:
raw = json.loads(DEVICE_PAIR_MAP_JSON)
if isinstance(raw, dict):
DEVICE_PAIR_MAP = {str(k): str(v) for k, v in raw.items() if str(k) and str(v)}
log.info("device pair map loaded: %s", DEVICE_PAIR_MAP)
else:
log.warning("DEVICE_PAIR_MAP must be a JSON object")
except Exception:
log.exception("failed to parse DEVICE_PAIR_MAP")
def paired_output_device(device_id: str) -> str:
return DEVICE_PAIR_MAP.get(device_id, device_id)
def build_metrics(device_id: str, session: DeviceSession) -> dict:
@@ -408,14 +424,16 @@ async def handle_text_message(device_id: str, session: DeviceSession, raw: str)
"peak": msg.get("peak", 0),
"avg_abs": msg.get("avg_abs", 0),
"samples": msg.get("samples", 0),
"mic_gain": msg.get("mic_gain", 0),
}
state.publish_status(device_id, payload)
log.info(
"mic_level: device=%s peak=%s avg_abs=%s samples=%s",
"mic_level: device=%s peak=%s avg_abs=%s samples=%s mic_gain=%s",
device_id,
payload["peak"],
payload["avg_abs"],
payload["samples"],
payload["mic_gain"],
)
return
@@ -437,7 +455,10 @@ async def handle_binary_message(device_id: str, session: DeviceSession, data: by
drop = len(session.pcm_bytes) - MAX_SESSION_BYTES
del session.pcm_bytes[:drop]
if ECHO_ENABLED:
await session.ws.send(data)
target_device = paired_output_device(device_id)
ok = await state.send_binary_to_device(target_device, data)
if not ok and target_device != device_id:
log.debug("paired output device not connected: src=%s target=%s", device_id, target_device)
def parse_device_id(path: str) -> str: