Initial EVS client + bridge setup
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.pio
|
||||||
|
.vscode/.browse.c_cpp.db*
|
||||||
|
.vscode/c_cpp_properties.json
|
||||||
|
.vscode/launch.json
|
||||||
|
.vscode/ipch
|
||||||
|
include/secrets.h
|
||||||
|
bridge/.env
|
||||||
|
bridge/data/
|
||||||
10
.vscode/extensions.json
vendored
Normal file
10
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||||
|
// for the documentation about the extensions.json format
|
||||||
|
"recommendations": [
|
||||||
|
"platformio.platformio-ide"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"ms-vscode.cpptools-extension-pack"
|
||||||
|
]
|
||||||
|
}
|
||||||
22
bridge/.env.example
Normal file
22
bridge/.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
WS_HOST=0.0.0.0
|
||||||
|
WS_PORT=8765
|
||||||
|
WS_PATH=/audio
|
||||||
|
ECHO_ENABLED=true
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
MQTT_ENABLED=true
|
||||||
|
MQTT_HOST=homeassistant.local
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USER=
|
||||||
|
MQTT_PASSWORD=
|
||||||
|
MQTT_BASE_TOPIC=evs
|
||||||
|
MQTT_TTS_TOPIC=evs/+/play_pcm16le
|
||||||
|
MQTT_STATUS_RETAIN=true
|
||||||
|
|
||||||
|
# Optional webhook in Home Assistant:
|
||||||
|
# HA settings -> Automations & Scenes -> Webhooks
|
||||||
|
HA_WEBHOOK_URL=
|
||||||
|
|
||||||
|
SAVE_SESSIONS=true
|
||||||
|
SESSIONS_DIR=/data/sessions
|
||||||
|
PCM_SAMPLE_RATE=16000
|
||||||
10
bridge/Dockerfile
Normal file
10
bridge/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
CMD ["python", "app.py"]
|
||||||
73
bridge/README.md
Normal file
73
bridge/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# EVS Bridge (Home Assistant + MQTT + WebSocket)
|
||||||
|
|
||||||
|
This service is the audio bridge between your ESP32 client and your Home Assistant stack.
|
||||||
|
|
||||||
|
It provides:
|
||||||
|
- WebSocket endpoint for raw PCM audio (`/audio`)
|
||||||
|
- MQTT status/events (`evs/<device_id>/status`)
|
||||||
|
- MQTT playback input (`evs/<device_id>/play_pcm16le`)
|
||||||
|
- Optional Home Assistant webhook callbacks (`connected`, `start`, `stop`, `disconnected`)
|
||||||
|
|
||||||
|
## 1) Start the bridge
|
||||||
|
|
||||||
|
1. Copy env template:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
2. Edit `.env`:
|
||||||
|
- `MQTT_HOST`, `MQTT_USER`, `MQTT_PASSWORD`
|
||||||
|
- `HA_WEBHOOK_URL` (optional)
|
||||||
|
3. Start:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) Configure ESP32
|
||||||
|
|
||||||
|
In `src/main.cpp`:
|
||||||
|
- no environment-specific values should be edited directly
|
||||||
|
|
||||||
|
In `include/secrets.h`:
|
||||||
|
- copy from `include/secrets.example.h`
|
||||||
|
- set WiFi credentials
|
||||||
|
- set bridge host
|
||||||
|
- set WS port/path
|
||||||
|
- set unique `EVS_DEVICE_ID`
|
||||||
|
|
||||||
|
Then upload firmware.
|
||||||
|
|
||||||
|
## 3) Test flow
|
||||||
|
|
||||||
|
1. Flash ESP32
|
||||||
|
2. Open serial monitor
|
||||||
|
3. Send `s` (stream mode)
|
||||||
|
4. In bridge logs, you should see the device connection
|
||||||
|
5. If `ECHO_ENABLED=true`, incoming audio is returned to ESP32 speaker
|
||||||
|
|
||||||
|
## 4) MQTT topics
|
||||||
|
|
||||||
|
- Status/events published by bridge:
|
||||||
|
- `evs/<device_id>/status` (JSON)
|
||||||
|
- Playback input to device:
|
||||||
|
- `evs/<device_id>/play_pcm16le`
|
||||||
|
- payload options:
|
||||||
|
- raw binary PCM16LE
|
||||||
|
- JSON `{ "pcm16le_b64": "<base64>" }`
|
||||||
|
|
||||||
|
## 5) Home Assistant integration
|
||||||
|
|
||||||
|
Use webhook for event hooks:
|
||||||
|
- Configure `HA_WEBHOOK_URL` in `.env`
|
||||||
|
- Bridge sends JSON with event and metadata on:
|
||||||
|
- `connected`
|
||||||
|
- `start`
|
||||||
|
- `stop`
|
||||||
|
- `disconnected`
|
||||||
|
|
||||||
|
You can build automations on these events (for STT/TTS pipelines or Node-RED handoff).
|
||||||
|
|
||||||
|
## 6) Notes
|
||||||
|
|
||||||
|
- Audio format: PCM16LE, mono, 16 kHz
|
||||||
|
- `SAVE_SESSIONS=true` stores `.wav` files in `bridge/data/sessions`
|
||||||
|
- MQTT is recommended for control/events, WebSocket for streaming audio
|
||||||
291
bridge/app.py
Normal file
291
bridge/app.py
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import websockets
|
||||||
|
from websockets.server import WebSocketServerProtocol
|
||||||
|
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=os.getenv("LOG_LEVEL", "INFO"),
|
||||||
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("evs-bridge")
|
||||||
|
|
||||||
|
|
||||||
|
def getenv_bool(name: str, default: bool) -> bool:
|
||||||
|
val = os.getenv(name)
|
||||||
|
if val is None:
|
||||||
|
return default
|
||||||
|
return val.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
WS_HOST = os.getenv("WS_HOST", "0.0.0.0")
|
||||||
|
WS_PORT = int(os.getenv("WS_PORT", "8765"))
|
||||||
|
WS_PATH = os.getenv("WS_PATH", "/audio")
|
||||||
|
ECHO_ENABLED = getenv_bool("ECHO_ENABLED", True)
|
||||||
|
|
||||||
|
MQTT_ENABLED = getenv_bool("MQTT_ENABLED", True)
|
||||||
|
MQTT_HOST = os.getenv("MQTT_HOST", "localhost")
|
||||||
|
MQTT_PORT = int(os.getenv("MQTT_PORT", "1883"))
|
||||||
|
MQTT_USER = os.getenv("MQTT_USER", "")
|
||||||
|
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)
|
||||||
|
|
||||||
|
HA_WEBHOOK_URL = os.getenv("HA_WEBHOOK_URL", "").strip()
|
||||||
|
SAVE_SESSIONS = getenv_bool("SAVE_SESSIONS", True)
|
||||||
|
SESSIONS_DIR = Path(os.getenv("SESSIONS_DIR", "/data/sessions"))
|
||||||
|
PCM_SAMPLE_RATE = int(os.getenv("PCM_SAMPLE_RATE", "16000"))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceSession:
|
||||||
|
device_id: str
|
||||||
|
ws: WebSocketServerProtocol
|
||||||
|
connected_at: float = field(default_factory=time.time)
|
||||||
|
ptt_active: bool = False
|
||||||
|
pcm_bytes: bytearray = field(default_factory=bytearray)
|
||||||
|
last_rx_ts: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeState:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
self.devices: Dict[str, DeviceSession] = {}
|
||||||
|
self.mqtt_client: Optional[mqtt.Client] = None
|
||||||
|
|
||||||
|
def publish_status(self, device_id: str, payload: dict) -> None:
|
||||||
|
if not self.mqtt_client:
|
||||||
|
return
|
||||||
|
topic = f"{MQTT_BASE_TOPIC}/{device_id}/status"
|
||||||
|
try:
|
||||||
|
self.mqtt_client.publish(topic, json.dumps(payload), qos=0, retain=MQTT_STATUS_RETAIN)
|
||||||
|
except Exception:
|
||||||
|
log.exception("mqtt publish failed")
|
||||||
|
|
||||||
|
async def send_binary_to_device(self, device_id: str, pcm_data: bytes) -> bool:
|
||||||
|
session = self.devices.get(device_id)
|
||||||
|
if not session:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
await session.ws.send(pcm_data)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
log.exception("ws send to device failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
state = BridgeState()
|
||||||
|
|
||||||
|
|
||||||
|
def build_metrics(device_id: str, session: DeviceSession) -> dict:
|
||||||
|
samples = len(session.pcm_bytes) // 2
|
||||||
|
seconds = samples / float(PCM_SAMPLE_RATE)
|
||||||
|
return {
|
||||||
|
"device_id": device_id,
|
||||||
|
"ptt_active": session.ptt_active,
|
||||||
|
"rx_bytes": len(session.pcm_bytes),
|
||||||
|
"duration_s": round(seconds, 3),
|
||||||
|
"last_rx_ts": session.last_rx_ts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def call_ha_webhook(event: str, payload: dict) -> None:
|
||||||
|
if not HA_WEBHOOK_URL:
|
||||||
|
return
|
||||||
|
data = {"event": event, **payload}
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as client:
|
||||||
|
async with client.post(HA_WEBHOOK_URL, json=data, timeout=10) as resp:
|
||||||
|
if resp.status >= 400:
|
||||||
|
log.warning("ha webhook error status=%s", resp.status)
|
||||||
|
except Exception:
|
||||||
|
log.exception("ha webhook call failed")
|
||||||
|
|
||||||
|
|
||||||
|
def save_session_wav(device_id: str, pcm: bytes) -> Optional[str]:
|
||||||
|
if not SAVE_SESSIONS or not pcm:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts = int(time.time())
|
||||||
|
path = SESSIONS_DIR / f"{device_id}_{ts}.wav"
|
||||||
|
import wave
|
||||||
|
|
||||||
|
with wave.open(str(path), "wb") as wf:
|
||||||
|
wf.setnchannels(1)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(PCM_SAMPLE_RATE)
|
||||||
|
wf.writeframes(pcm)
|
||||||
|
return str(path)
|
||||||
|
except Exception:
|
||||||
|
log.exception("failed to save wav")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_text_message(device_id: str, session: DeviceSession, raw: str) -> None:
|
||||||
|
try:
|
||||||
|
msg = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
log.warning("invalid text frame from %s: %s", device_id, raw[:80])
|
||||||
|
return
|
||||||
|
|
||||||
|
msg_type = msg.get("type")
|
||||||
|
if msg_type == "start":
|
||||||
|
session.ptt_active = True
|
||||||
|
session.pcm_bytes.clear()
|
||||||
|
payload = {"type": "start", "ts": time.time(), "device_id": device_id}
|
||||||
|
state.publish_status(device_id, payload)
|
||||||
|
await call_ha_webhook("start", payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
if msg_type == "stop":
|
||||||
|
session.ptt_active = False
|
||||||
|
metrics = build_metrics(device_id, session)
|
||||||
|
wav_path = save_session_wav(device_id, bytes(session.pcm_bytes))
|
||||||
|
payload = {"type": "stop", "ts": time.time(), "device_id": device_id, **metrics}
|
||||||
|
if wav_path:
|
||||||
|
payload["wav_path"] = wav_path
|
||||||
|
state.publish_status(device_id, payload)
|
||||||
|
await call_ha_webhook("stop", payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
if msg_type == "ping":
|
||||||
|
await session.ws.send(json.dumps({"type": "pong", "ts": time.time()}))
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info("text msg from %s: %s", device_id, msg)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_binary_message(device_id: str, session: DeviceSession, data: bytes) -> None:
|
||||||
|
session.last_rx_ts = time.time()
|
||||||
|
if session.ptt_active:
|
||||||
|
session.pcm_bytes.extend(data)
|
||||||
|
if ECHO_ENABLED:
|
||||||
|
await session.ws.send(data)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_device_id(path: str) -> str:
|
||||||
|
# expected:
|
||||||
|
# /audio
|
||||||
|
# /audio?device_id=esp32-kitchen
|
||||||
|
if "?" not in path:
|
||||||
|
return "esp32-unknown"
|
||||||
|
try:
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
q = parse_qs(urlsplit(path).query)
|
||||||
|
return q.get("device_id", ["esp32-unknown"])[0]
|
||||||
|
except Exception:
|
||||||
|
return "esp32-unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def ws_handler(ws: WebSocketServerProtocol, path: str) -> None:
|
||||||
|
if not path.startswith(WS_PATH):
|
||||||
|
await ws.close(code=1008, reason="Invalid path")
|
||||||
|
return
|
||||||
|
|
||||||
|
device_id = parse_device_id(path)
|
||||||
|
session = DeviceSession(device_id=device_id, ws=ws)
|
||||||
|
state.devices[device_id] = session
|
||||||
|
state.publish_status(device_id, {"type": "connected", "ts": time.time(), "device_id": device_id})
|
||||||
|
await call_ha_webhook("connected", {"device_id": device_id, "ts": time.time()})
|
||||||
|
log.info("device connected: %s", device_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for message in ws:
|
||||||
|
if isinstance(message, bytes):
|
||||||
|
await handle_binary_message(device_id, session, message)
|
||||||
|
else:
|
||||||
|
await handle_text_message(device_id, session, message)
|
||||||
|
except websockets.ConnectionClosed:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if state.devices.get(device_id) is session:
|
||||||
|
del state.devices[device_id]
|
||||||
|
state.publish_status(device_id, {"type": "disconnected", "ts": time.time(), "device_id": device_id})
|
||||||
|
await call_ha_webhook("disconnected", {"device_id": device_id, "ts": time.time()})
|
||||||
|
log.info("device disconnected: %s", device_id)
|
||||||
|
|
||||||
|
|
||||||
|
def on_mqtt_connect(client: mqtt.Client, _userdata, _flags, reason_code, _properties=None):
|
||||||
|
if reason_code == 0:
|
||||||
|
log.info("mqtt connected")
|
||||||
|
client.subscribe(MQTT_TTS_TOPIC, qos=0)
|
||||||
|
else:
|
||||||
|
log.error("mqtt connect failed reason=%s", reason_code)
|
||||||
|
|
||||||
|
|
||||||
|
def on_mqtt_message(_client: mqtt.Client, _userdata, msg: mqtt.MQTTMessage):
|
||||||
|
# topic: evs/<device_id>/play_pcm16le
|
||||||
|
try:
|
||||||
|
parts = msg.topic.split("/")
|
||||||
|
if len(parts) < 3:
|
||||||
|
return
|
||||||
|
device_id = parts[1]
|
||||||
|
|
||||||
|
# payload options:
|
||||||
|
# 1) raw binary PCM16LE
|
||||||
|
# 2) json {"pcm16le_b64":"..."}
|
||||||
|
payload = msg.payload
|
||||||
|
if payload.startswith(b"{"):
|
||||||
|
doc = json.loads(payload.decode("utf-8"))
|
||||||
|
b64 = doc.get("pcm16le_b64", "")
|
||||||
|
if not b64:
|
||||||
|
return
|
||||||
|
pcm = base64.b64decode(b64)
|
||||||
|
else:
|
||||||
|
pcm = bytes(payload)
|
||||||
|
|
||||||
|
if not state.loop:
|
||||||
|
return
|
||||||
|
fut = asyncio.run_coroutine_threadsafe(state.send_binary_to_device(device_id, pcm), state.loop)
|
||||||
|
_ = fut.result(timeout=2)
|
||||||
|
except Exception:
|
||||||
|
log.exception("mqtt message handling failed")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_mqtt(loop: asyncio.AbstractEventLoop) -> Optional[mqtt.Client]:
|
||||||
|
if not MQTT_ENABLED:
|
||||||
|
log.info("mqtt disabled")
|
||||||
|
return None
|
||||||
|
|
||||||
|
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="evs-bridge")
|
||||||
|
if MQTT_USER:
|
||||||
|
client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
|
||||||
|
client.on_connect = on_mqtt_connect
|
||||||
|
client.on_message = on_mqtt_message
|
||||||
|
client.connect(MQTT_HOST, MQTT_PORT, keepalive=30)
|
||||||
|
client.loop_start()
|
||||||
|
log.info("mqtt connecting to %s:%s", MQTT_HOST, MQTT_PORT)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
state.loop = asyncio.get_running_loop()
|
||||||
|
state.mqtt_client = setup_mqtt(state.loop)
|
||||||
|
ws_server = await websockets.serve(ws_handler, WS_HOST, WS_PORT, max_size=2**22)
|
||||||
|
log.info("ws listening on ws://%s:%s%s", WS_HOST, WS_PORT, WS_PATH)
|
||||||
|
try:
|
||||||
|
await ws_server.wait_closed()
|
||||||
|
finally:
|
||||||
|
if state.mqtt_client:
|
||||||
|
state.mqtt_client.loop_stop()
|
||||||
|
state.mqtt_client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
11
bridge/docker-compose.yml
Normal file
11
bridge/docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
evs-bridge:
|
||||||
|
build: .
|
||||||
|
container_name: evs-bridge
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "${WS_PORT:-8765}:${WS_PORT:-8765}"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
3
bridge/requirements.txt
Normal file
3
bridge/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
websockets==12.0
|
||||||
|
paho-mqtt==2.1.0
|
||||||
|
aiohttp==3.10.11
|
||||||
37
include/README
Normal file
37
include/README
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
This directory is intended for project header files.
|
||||||
|
|
||||||
|
A header file is a file containing C declarations and macro definitions
|
||||||
|
to be shared between several project source files. You request the use of a
|
||||||
|
header file in your project source file (C, C++, etc) located in `src` folder
|
||||||
|
by including it, with the C preprocessing directive `#include'.
|
||||||
|
|
||||||
|
```src/main.c
|
||||||
|
|
||||||
|
#include "header.h"
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Including a header file produces the same results as copying the header file
|
||||||
|
into each source file that needs it. Such copying would be time-consuming
|
||||||
|
and error-prone. With a header file, the related declarations appear
|
||||||
|
in only one place. If they need to be changed, they can be changed in one
|
||||||
|
place, and programs that include the header file will automatically use the
|
||||||
|
new version when next recompiled. The header file eliminates the labor of
|
||||||
|
finding and changing all the copies as well as the risk that a failure to
|
||||||
|
find one copy will result in inconsistencies within a program.
|
||||||
|
|
||||||
|
In C, the convention is to give header files names that end with `.h'.
|
||||||
|
|
||||||
|
Read more about using header files in official GCC documentation:
|
||||||
|
|
||||||
|
* Include Syntax
|
||||||
|
* Include Operation
|
||||||
|
* Once-Only Headers
|
||||||
|
* Computed Includes
|
||||||
|
|
||||||
|
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
|
||||||
21
include/secrets.example.h
Normal file
21
include/secrets.example.h
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#ifndef EVS_SECRETS_H
|
||||||
|
#define EVS_SECRETS_H
|
||||||
|
|
||||||
|
// Copy to: include/secrets.h
|
||||||
|
// Keep include/secrets.h out of version control.
|
||||||
|
|
||||||
|
static const char* WIFI_SSID = "REPLACE_WIFI_SSID";
|
||||||
|
static const char* WIFI_PASSWORD = "REPLACE_WIFI_PASSWORD";
|
||||||
|
|
||||||
|
// EVS bridge endpoint
|
||||||
|
static const char* EVS_BRIDGE_HOST = "REPLACE_BRIDGE_IP_OR_HOST";
|
||||||
|
static constexpr uint16_t EVS_WS_PORT = 8765;
|
||||||
|
static const char* EVS_WS_PATH = "/audio";
|
||||||
|
|
||||||
|
// Unique device name per ESP32
|
||||||
|
static const char* EVS_DEVICE_ID = "esp32-room-name";
|
||||||
|
|
||||||
|
// Connectivity behavior
|
||||||
|
static constexpr uint32_t EVS_RECONNECT_MS = 5000;
|
||||||
|
|
||||||
|
#endif // EVS_SECRETS_H
|
||||||
46
lib/README
Normal file
46
lib/README
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
|
||||||
|
This directory is intended for project specific (private) libraries.
|
||||||
|
PlatformIO will compile them to static libraries and link into the executable file.
|
||||||
|
|
||||||
|
The source code of each library should be placed in a separate directory
|
||||||
|
("lib/your_library_name/[Code]").
|
||||||
|
|
||||||
|
For example, see the structure of the following example libraries `Foo` and `Bar`:
|
||||||
|
|
||||||
|
|--lib
|
||||||
|
| |
|
||||||
|
| |--Bar
|
||||||
|
| | |--docs
|
||||||
|
| | |--examples
|
||||||
|
| | |--src
|
||||||
|
| | |- Bar.c
|
||||||
|
| | |- Bar.h
|
||||||
|
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
|
||||||
|
| |
|
||||||
|
| |--Foo
|
||||||
|
| | |- Foo.c
|
||||||
|
| | |- Foo.h
|
||||||
|
| |
|
||||||
|
| |- README --> THIS FILE
|
||||||
|
|
|
||||||
|
|- platformio.ini
|
||||||
|
|--src
|
||||||
|
|- main.c
|
||||||
|
|
||||||
|
Example contents of `src/main.c` using Foo and Bar:
|
||||||
|
```
|
||||||
|
#include <Foo.h>
|
||||||
|
#include <Bar.h>
|
||||||
|
|
||||||
|
int main (void)
|
||||||
|
{
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
The PlatformIO Library Dependency Finder will find automatically dependent
|
||||||
|
libraries by scanning project source files.
|
||||||
|
|
||||||
|
More information about PlatformIO Library Dependency Finder
|
||||||
|
- https://docs.platformio.org/page/librarymanager/ldf.html
|
||||||
19
platformio.ini
Normal file
19
platformio.ini
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
; PlatformIO Project Configuration File
|
||||||
|
|
||||||
|
[env]
|
||||||
|
board = esp32dev
|
||||||
|
framework = arduino
|
||||||
|
monitor_speed = 115200
|
||||||
|
lib_deps =
|
||||||
|
gilmaimon/ArduinoWebsockets @ ^0.5.4
|
||||||
|
|
||||||
|
; Stable baseline (your current toolchain family)
|
||||||
|
[env:esp32dev_core2]
|
||||||
|
platform = espressif32@6.12.0
|
||||||
|
|
||||||
|
; Newer Arduino-ESP32 Core (3.x) for API/behavior comparison
|
||||||
|
; Build with: pio run -e esp32dev_core3
|
||||||
|
[env:esp32dev_core3]
|
||||||
|
platform = espressif32@6.12.0
|
||||||
|
platform_packages =
|
||||||
|
framework-arduinoespressif32@https://github.com/espressif/arduino-esp32.git#3.0.7
|
||||||
327
src/main.cpp
Normal file
327
src/main.cpp
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
#include <Arduino.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <ArduinoWebsockets.h>
|
||||||
|
#include "driver/i2s.h"
|
||||||
|
#include "secrets.h"
|
||||||
|
using namespace websockets;
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Project config
|
||||||
|
// ---------------------------
|
||||||
|
static constexpr i2s_port_t MIC_I2S_PORT = I2S_NUM_0;
|
||||||
|
static constexpr uint32_t MIC_SAMPLE_RATE = 16000;
|
||||||
|
static constexpr i2s_bits_per_sample_t MIC_BITS = I2S_BITS_PER_SAMPLE_32BIT;
|
||||||
|
static constexpr size_t MIC_FRAME_SAMPLES = 256;
|
||||||
|
|
||||||
|
// INMP441 -> ESP32
|
||||||
|
static constexpr int PIN_I2S_WS = 25;
|
||||||
|
static constexpr int PIN_I2S_SCK = 26;
|
||||||
|
static constexpr int PIN_I2S_SD = 33;
|
||||||
|
|
||||||
|
// ESP32 PWM -> PAM8403 IN (L+ or R+)
|
||||||
|
static constexpr int PIN_AUDIO_OUT = 27;
|
||||||
|
static constexpr int PWM_CHANNEL = 0;
|
||||||
|
static constexpr uint32_t PWM_FREQ = 22050;
|
||||||
|
static constexpr uint8_t PWM_RES_BITS = 8;
|
||||||
|
static constexpr uint32_t SPEAKER_SAMPLE_RATE = 16000;
|
||||||
|
|
||||||
|
// WiFi / WebSocket
|
||||||
|
|
||||||
|
enum class DeviceMode : uint8_t {
|
||||||
|
Idle,
|
||||||
|
StreamToServer, // Placeholder: ship PCM to remote STT/LLM/TTS service
|
||||||
|
LocalLoopback, // Debug mode: mic directly to speaker
|
||||||
|
};
|
||||||
|
|
||||||
|
static DeviceMode g_mode = DeviceMode::Idle;
|
||||||
|
static int32_t g_micBuffer[MIC_FRAME_SAMPLES];
|
||||||
|
static WebsocketsClient g_ws;
|
||||||
|
static bool g_wsConnected = false;
|
||||||
|
static uint32_t g_lastConnectTryMs = 0;
|
||||||
|
static uint32_t g_nextOutUs = 0;
|
||||||
|
static bool g_streamingActive = false;
|
||||||
|
|
||||||
|
static constexpr size_t RX_SAMPLES_CAP = 16000;
|
||||||
|
static int16_t g_rxSamples[RX_SAMPLES_CAP];
|
||||||
|
static size_t g_rxHead = 0;
|
||||||
|
static size_t g_rxTail = 0;
|
||||||
|
static size_t g_rxCount = 0;
|
||||||
|
|
||||||
|
static bool initMicI2s() {
|
||||||
|
const i2s_config_t i2sConfig = {
|
||||||
|
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
|
||||||
|
.sample_rate = MIC_SAMPLE_RATE,
|
||||||
|
.bits_per_sample = MIC_BITS,
|
||||||
|
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
|
||||||
|
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
|
||||||
|
.intr_alloc_flags = 0,
|
||||||
|
.dma_buf_count = 8,
|
||||||
|
.dma_buf_len = 256,
|
||||||
|
.use_apll = false,
|
||||||
|
.tx_desc_auto_clear = false,
|
||||||
|
.fixed_mclk = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const i2s_pin_config_t pinConfig = {
|
||||||
|
.bck_io_num = PIN_I2S_SCK,
|
||||||
|
.ws_io_num = PIN_I2S_WS,
|
||||||
|
.data_out_num = I2S_PIN_NO_CHANGE,
|
||||||
|
.data_in_num = PIN_I2S_SD,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (i2s_driver_install(MIC_I2S_PORT, &i2sConfig, 0, nullptr) != ESP_OK) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (i2s_set_pin(MIC_I2S_PORT, &pinConfig) != ESP_OK) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool initPwmOut() {
|
||||||
|
#if ESP_ARDUINO_VERSION_MAJOR >= 3
|
||||||
|
return ledcAttach(PIN_AUDIO_OUT, PWM_FREQ, PWM_RES_BITS);
|
||||||
|
#else
|
||||||
|
ledcSetup(PWM_CHANNEL, PWM_FREQ, PWM_RES_BITS);
|
||||||
|
ledcAttachPin(PIN_AUDIO_OUT, PWM_CHANNEL);
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline void pwmWrite(uint8_t value) {
|
||||||
|
#if ESP_ARDUINO_VERSION_MAJOR >= 3
|
||||||
|
ledcWrite(PIN_AUDIO_OUT, value);
|
||||||
|
#else
|
||||||
|
ledcWrite(PWM_CHANNEL, value);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert signed 16-bit PCM to unsigned 8-bit PWM domain.
|
||||||
|
static inline uint8_t pcm16ToPwm8(int16_t s) {
|
||||||
|
return (uint16_t)(s + 32768) >> 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool enqueuePcmSample(int16_t s) {
|
||||||
|
if (g_rxCount >= RX_SAMPLES_CAP) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
g_rxSamples[g_rxTail] = s;
|
||||||
|
g_rxTail = (g_rxTail + 1) % RX_SAMPLES_CAP;
|
||||||
|
++g_rxCount;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool dequeuePcmSample(int16_t& out) {
|
||||||
|
if (g_rxCount == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out = g_rxSamples[g_rxHead];
|
||||||
|
g_rxHead = (g_rxHead + 1) % RX_SAMPLES_CAP;
|
||||||
|
--g_rxCount;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void enqueuePcmFrame(const int16_t* frame, size_t count) {
|
||||||
|
for (size_t i = 0; i < count; ++i) {
|
||||||
|
if (!enqueuePcmSample(frame[i])) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onWsMessageCallback(WebsocketsMessage message) {
|
||||||
|
if (!message.isBinary()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const String payload = message.data();
|
||||||
|
const size_t n = payload.length();
|
||||||
|
if (n < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8_t* bytes = reinterpret_cast<const uint8_t*>(payload.c_str());
|
||||||
|
const size_t sampleCount = n / 2;
|
||||||
|
for (size_t i = 0; i < sampleCount; ++i) {
|
||||||
|
const uint8_t lo = bytes[2 * i];
|
||||||
|
const uint8_t hi = bytes[2 * i + 1];
|
||||||
|
const int16_t s = (int16_t)((uint16_t)hi << 8 | lo);
|
||||||
|
if (!enqueuePcmSample(s)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onWsEventCallback(WebsocketsEvent event, String) {
|
||||||
|
if (event == WebsocketsEvent::ConnectionOpened) {
|
||||||
|
g_wsConnected = true;
|
||||||
|
if (g_mode == DeviceMode::StreamToServer && !g_streamingActive) {
|
||||||
|
g_ws.send("{\"type\":\"start\"}");
|
||||||
|
g_streamingActive = true;
|
||||||
|
}
|
||||||
|
Serial.println("WS connected");
|
||||||
|
} else if (event == WebsocketsEvent::ConnectionClosed) {
|
||||||
|
g_wsConnected = false;
|
||||||
|
g_streamingActive = false;
|
||||||
|
Serial.println("WS disconnected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String makeWsUrl() {
|
||||||
|
String url = "ws://";
|
||||||
|
url += EVS_BRIDGE_HOST;
|
||||||
|
url += ":";
|
||||||
|
url += String(EVS_WS_PORT);
|
||||||
|
url += EVS_WS_PATH;
|
||||||
|
url += "?device_id=";
|
||||||
|
url += EVS_DEVICE_ID;
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ensureConnectivity() {
|
||||||
|
const uint32_t now = millis();
|
||||||
|
if ((now - g_lastConnectTryMs) < EVS_RECONNECT_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_lastConnectTryMs = now;
|
||||||
|
|
||||||
|
if (WiFi.status() != WL_CONNECTED) {
|
||||||
|
Serial.println("WiFi connecting...");
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!g_wsConnected) {
|
||||||
|
const String url = makeWsUrl();
|
||||||
|
Serial.print("WS connecting: ");
|
||||||
|
Serial.println(url);
|
||||||
|
g_ws.connect(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void setMode(DeviceMode mode) {
|
||||||
|
if (g_mode == mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (g_mode == DeviceMode::StreamToServer && g_wsConnected && g_streamingActive) {
|
||||||
|
g_ws.send("{\"type\":\"stop\"}");
|
||||||
|
g_streamingActive = false;
|
||||||
|
}
|
||||||
|
g_mode = mode;
|
||||||
|
if (g_mode == DeviceMode::StreamToServer && g_wsConnected && !g_streamingActive) {
|
||||||
|
g_ws.send("{\"type\":\"start\"}");
|
||||||
|
g_streamingActive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send PCM16 mono frame to server.
|
||||||
|
static void handleFrameForServer(const int16_t* frame, size_t count) {
|
||||||
|
if (!g_wsConnected || count == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_ws.sendBinary(reinterpret_cast<const char*>(frame), count * sizeof(int16_t));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void serviceSpeaker() {
|
||||||
|
const uint32_t periodUs = 1000000UL / SPEAKER_SAMPLE_RATE;
|
||||||
|
const uint32_t now = micros();
|
||||||
|
if ((int32_t)(now - g_nextOutUs) < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
g_nextOutUs += periodUs;
|
||||||
|
|
||||||
|
int16_t s = 0;
|
||||||
|
if (dequeuePcmSample(s)) {
|
||||||
|
pwmWrite(pcm16ToPwm8(s));
|
||||||
|
} else {
|
||||||
|
pwmWrite(128);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void printHelp() {
|
||||||
|
Serial.println();
|
||||||
|
Serial.println("Commands:");
|
||||||
|
Serial.println(" i = idle");
|
||||||
|
Serial.println(" s = stream mode (stub)");
|
||||||
|
Serial.println(" l = local loopback mode");
|
||||||
|
Serial.println(" p = print network status");
|
||||||
|
Serial.println(" h = help");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handleSerialCommands() {
|
||||||
|
while (Serial.available()) {
|
||||||
|
const char c = (char)Serial.read();
|
||||||
|
if (c == 'i') {
|
||||||
|
setMode(DeviceMode::Idle);
|
||||||
|
Serial.println("Mode -> Idle");
|
||||||
|
} else if (c == 's') {
|
||||||
|
setMode(DeviceMode::StreamToServer);
|
||||||
|
Serial.println("Mode -> StreamToServer");
|
||||||
|
} else if (c == 'l') {
|
||||||
|
setMode(DeviceMode::LocalLoopback);
|
||||||
|
Serial.println("Mode -> LocalLoopback");
|
||||||
|
} else if (c == 'p') {
|
||||||
|
Serial.print("WiFi: ");
|
||||||
|
Serial.print((WiFi.status() == WL_CONNECTED) ? "connected " : "disconnected ");
|
||||||
|
Serial.print("IP=");
|
||||||
|
Serial.println(WiFi.localIP());
|
||||||
|
Serial.print("WS: ");
|
||||||
|
Serial.println(g_wsConnected ? "connected" : "disconnected");
|
||||||
|
} else if (c == 'h') {
|
||||||
|
printHelp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setup() {
|
||||||
|
Serial.begin(115200);
|
||||||
|
delay(300);
|
||||||
|
Serial.println("EVS Client boot");
|
||||||
|
|
||||||
|
if (!initMicI2s()) {
|
||||||
|
Serial.println("ERROR: I2S init failed");
|
||||||
|
while (true) delay(1000);
|
||||||
|
}
|
||||||
|
if (!initPwmOut()) {
|
||||||
|
Serial.println("ERROR: PWM init failed");
|
||||||
|
while (true) delay(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
g_ws.onMessage(onWsMessageCallback);
|
||||||
|
g_ws.onEvent(onWsEventCallback);
|
||||||
|
g_nextOutUs = micros();
|
||||||
|
|
||||||
|
setMode(DeviceMode::LocalLoopback);
|
||||||
|
Serial.println("Audio init ok");
|
||||||
|
Serial.println("Set local environment values in include/secrets.h");
|
||||||
|
printHelp();
|
||||||
|
}
|
||||||
|
|
||||||
|
void loop() {
|
||||||
|
handleSerialCommands();
|
||||||
|
ensureConnectivity();
|
||||||
|
g_ws.poll();
|
||||||
|
serviceSpeaker();
|
||||||
|
|
||||||
|
size_t bytesRead = 0;
|
||||||
|
const esp_err_t res = i2s_read(
|
||||||
|
MIC_I2S_PORT, g_micBuffer, sizeof(g_micBuffer), &bytesRead, 0);
|
||||||
|
if (res != ESP_OK || bytesRead == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t sampleCount = bytesRead / sizeof(int32_t);
|
||||||
|
static int16_t pcm16[MIC_FRAME_SAMPLES];
|
||||||
|
for (size_t i = 0; i < sampleCount; ++i) {
|
||||||
|
// INMP441 delivers meaningful data in the high bits for 32-bit slot formats.
|
||||||
|
pcm16[i] = (int16_t)(g_micBuffer[i] >> 14);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g_mode == DeviceMode::StreamToServer) {
|
||||||
|
handleFrameForServer(pcm16, sampleCount);
|
||||||
|
} else if (g_mode == DeviceMode::LocalLoopback) {
|
||||||
|
enqueuePcmFrame(pcm16, sampleCount);
|
||||||
|
} else {
|
||||||
|
// idle
|
||||||
|
}
|
||||||
|
}
|
||||||
11
test/README
Normal file
11
test/README
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
This directory is intended for PlatformIO Test Runner and project tests.
|
||||||
|
|
||||||
|
Unit Testing is a software testing method by which individual units of
|
||||||
|
source code, sets of one or more MCU program modules together with associated
|
||||||
|
control data, usage procedures, and operating procedures, are tested to
|
||||||
|
determine whether they are fit for use. Unit testing finds problems early
|
||||||
|
in the development cycle.
|
||||||
|
|
||||||
|
More information about PlatformIO Unit Testing:
|
||||||
|
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html
|
||||||
Reference in New Issue
Block a user