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