From c742d748602eb84e9cf5c567b901e558572c3112 Mon Sep 17 00:00:00 2001 From: Kai Date: Fri, 13 Feb 2026 10:07:11 +0100 Subject: [PATCH] Initial EVS client + bridge setup --- .gitignore | 8 + .vscode/extensions.json | 10 ++ bridge/.env.example | 22 +++ bridge/Dockerfile | 10 ++ bridge/README.md | 73 +++++++++ bridge/app.py | 291 +++++++++++++++++++++++++++++++++ bridge/docker-compose.yml | 11 ++ bridge/requirements.txt | 3 + include/README | 37 +++++ include/secrets.example.h | 21 +++ lib/README | 46 ++++++ platformio.ini | 19 +++ src/main.cpp | 327 ++++++++++++++++++++++++++++++++++++++ test/README | 11 ++ 14 files changed, 889 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 bridge/.env.example create mode 100644 bridge/Dockerfile create mode 100644 bridge/README.md create mode 100644 bridge/app.py create mode 100644 bridge/docker-compose.yml create mode 100644 bridge/requirements.txt create mode 100644 include/README create mode 100644 include/secrets.example.h create mode 100644 lib/README create mode 100644 platformio.ini create mode 100644 src/main.cpp create mode 100644 test/README diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25f27c8 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -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" + ] +} diff --git a/bridge/.env.example b/bridge/.env.example new file mode 100644 index 0000000..195b5a6 --- /dev/null +++ b/bridge/.env.example @@ -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 diff --git a/bridge/Dockerfile b/bridge/Dockerfile new file mode 100644 index 0000000..dfa0329 --- /dev/null +++ b/bridge/Dockerfile @@ -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"] diff --git a/bridge/README.md b/bridge/README.md new file mode 100644 index 0000000..94c43da --- /dev/null +++ b/bridge/README.md @@ -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//status`) +- MQTT playback input (`evs//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//status` (JSON) +- Playback input to device: + - `evs//play_pcm16le` + - payload options: + - raw binary PCM16LE + - JSON `{ "pcm16le_b64": "" }` + +## 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 diff --git a/bridge/app.py b/bridge/app.py new file mode 100644 index 0000000..cb2834b --- /dev/null +++ b/bridge/app.py @@ -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//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 diff --git a/bridge/docker-compose.yml b/bridge/docker-compose.yml new file mode 100644 index 0000000..38b0f13 --- /dev/null +++ b/bridge/docker-compose.yml @@ -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 diff --git a/bridge/requirements.txt b/bridge/requirements.txt new file mode 100644 index 0000000..e161680 --- /dev/null +++ b/bridge/requirements.txt @@ -0,0 +1,3 @@ +websockets==12.0 +paho-mqtt==2.1.0 +aiohttp==3.10.11 diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -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 diff --git a/include/secrets.example.h b/include/secrets.example.h new file mode 100644 index 0000000..03c3ed9 --- /dev/null +++ b/include/secrets.example.h @@ -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 diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -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 +#include + +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 diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..f7f6f27 --- /dev/null +++ b/platformio.ini @@ -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 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..6ceb124 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,327 @@ +#include +#include +#include +#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(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(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 + } +} diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -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