Back to projects
IoTSoftwareIn Progress

WhisperBridge

ESP32 BLE-to-MQTT bridge enabling smart home control of Bluetooth devices through Home Assistant, with no cloud subscription required.

ESP32BLEMQTTHome AssistantNimBLEC++20PlatformIOLittleFSFreeRTOS

// Overview

WhisperBridge is an ESP32 firmware that acts as a wireless bridge between the Vent-Axia Svara bathroom fan and a home automation network. The Svara fan exposes a BLE GATT interface for control but has no native WiFi or MQTT support. WhisperBridge solves this by sitting on the same network as a Home Assistant instance, subscribing to an MQTT command topic, and translating incoming boost commands into the required BLE GATT write sequence on the fan.

When a boost command arrives via MQTT (triggered by a Home Assistant automation, voice assistant, or the device's own web UI), WhisperBridge connects to the fan by MAC address, authenticates with a PIN, sends the boost payload, and disconnects — all on a dedicated FreeRTOS task so the WiFi and MQTT event loop is never blocked. State feedback (ON / OFF) is published back to MQTT so Home Assistant can track whether a boost is in progress.

First-time setup uses a captive-portal AP mode: connecting to the WhisperBridge-Setup WiFi network opens a browser page that scans for available networks and saves credentials to NVS. On subsequent boots the device connects in station mode, advertises via mDNS as whisperbridge.local, and registers itself with Home Assistant automatically through MQTT discovery.

The BLE GATT command sequence was reverse-engineered from the manufacturer's app. Every boost triggers a seven-step sequence: connect by MAC address at maximum TX power, discover the auth service, write the PIN bytes (e3 14 62 05), wait 200 ms for authentication to settle, discover the command service, write the boost payload (01 60 09 84 03), then disconnect. The firmware is structured into three translation units — main.cpp owns WiFi, the web server, and OTA; ble.cpp encapsulates the NimBLE GATT client and FreeRTOS task; mqtt.cpp manages PubSubClient and Home Assistant discovery.

// Schematic

WhisperBridge schematic

// Pins used

PinGPIOFunction
VIN5 V power input — from USB Micro-B or external 5 V on the VIN header pin
GNDGround — multiple GND pins available on both headers
GPIO 2GPIO2Status LED (optional) — HIGH while BLE boost sequence runs; connect via 330 Ω resistor to any colour LED
TXD0GPIO1UART debug output — Serial monitor at 115200 baud; [BLE], [MQTT], [WiFi] log messages; not required for operation
(internal)WiFi 2.4 GHz — STA: home network, HTTP on port 80, mDNS as whisperbridge.local; AP: WhisperBridge-Setup SSID on 10.0.0.1
(internal)BLE 2.4 GHz — GATT client (central) role only; connects to Svara fan by MAC address at ESP_PWR_LVL_P9 (max TX power)
(internal)NVS (Preferences) — namespace "whisper": keys ssid and pass for WiFi credentials
(internal)LittleFS — flash partition stores index.html and setup.html web UI files

// Key decisions

Why NimBLE over the default BLE stack?

NimBLE uses significantly less flash and RAM than the default ESP32 BLE stack, leaving more headroom for application logic. It also has a cleaner API for GATT client operations.

Why a dedicated FreeRTOS task for the BLE sequence?

The entire BLE sequence — connect, authenticate, command, disconnect — blocks for several seconds. Running it on a dedicated FreeRTOS task (ble_boost, priority 5, 8 KB stack) keeps the Arduino loop task free to service the MQTT client, OTA updates, and REST requests without any stall. The task wakes on xTaskNotifyGive and blocks on ulTaskNotifyTake between commands.

Why Emulated Hue for Alexa?

Avoids any cloud dependency or Alexa skill development. Emulated Hue presents devices as Philips Hue lights on the local network, which Alexa discovers natively. No subscription, no account linking.

Why not use the fan's cloud API?

The whole point is local-only control with no cloud subscription. BLE gives direct device access, and MQTT keeps everything on the local network.

Why defer ESP.restart() to loop()?

Calling ESP.restart() directly inside an AsyncWebServer callback would execute on the lwIP network task, which crashes the device. The s_pendingRestart flag lets loop() call it safely from the Arduino task on the next iteration.