Overview
WhisperBridge is an ESP32 firmware that acts as a protocol bridge between a home automation network and a Vent-Axia Svara bathroom fan. The fan exposes a proprietary BLE GATT interface — it has no WiFi or MQTT support. WhisperBridge fills that gap: it connects to the home network, subscribes to an MQTT topic, and translates each ON command into the correct BLE authentication + boost command sequence targeted at the fan by MAC address.
On first boot with no stored WiFi credentials, the device starts a WPA2 captive-portal access point (WhisperBridge-Setup) at 10.0.0.1. The user connects, scans nearby networks, picks one, and saves credentials — the device then restarts in station mode. In station mode it registers as whisperbridge.local via mDNS, starts ArduinoOTA, and publishes Home Assistant MQTT auto-discovery so the fan appears automatically as a switch entity with the mdi:fan icon.
The BLE authentication and boost sequence blocks for hundreds of milliseconds. Rather than stalling the main loop, this work runs in a dedicated FreeRTOS task (ble_boost, 8 KB stack). The main loop posts a task notification to trigger a run; the BLE task atomically updates _running and _lastSuccess flags that the loop reads safely without locks. Once the task completes, the loop detects the falling edge on _running and publishes OFF to the state topic to keep Home Assistant in sync.
System Flow
BLE Boost Sequence
whisperbridge/<id>/boost — MqttManager::onMessage fires on the lwIP taskBle.trigger() sends a FreeRTOS task notification to the ble_boost task; returns immediately — lwIP task is not blocked_running = true, calls NimBLEDevice::createClient() and connects to the fan by MAC addresse6834e4b-…, writes 4-byte PIN 0xe3 0x14 0x62 0x05 to auth characteristic; waits 200 ms for firmware settlec119e858-…, writes 5-byte boost payload 0x01 0x60 0x09 0x84 0x03 to command characteristic_running = false, _lastSuccess reflects the write resultloop() detects the _running falling edge and publishes OFF retained to whisperbridge/<id>/boost/stateAsyncWebServer and MQTT callbacks run on the lwIP task — calling delay() or ESP.restart() from those contexts corrupts the network stack. Any action requiring a restart (credential save, factory reset) sets the s_pendingRestart flag which loop() polls safely on the Arduino task.Screenshots
Hardware
ESP32-WROOM-32, dual-core 240 MHz, 4 MB flash
2.4 GHz integrated PCB antenna
any phone charger or USB wall adapter
HIGH while BLE boost sequence is running; enable via
STATUS_LED_PINHold 5 s to clear WiFi credentials; enable via
RESET_BUTTON_PINESP_PWR_LVL_P9) because the fan may be across a room or behind a wall. If connection failures persist, move the ESP32 closer to the fan or reduce physical obstructions.Wiring
The minimal build requires only a USB power supply. The optional status LED (GPIO 2) and reset button (GPIO 0) add no required components to the core circuit.
Pins Used
| Pin | Function | Direction | Notes |
|---|---|---|---|
| VIN | 5 V power input | IN | Supplied from USB power adapter |
| GND | Ground | — | Common ground for board and optional LED circuit |
| GPIO 2 | Status LED | OUT | Optional. HIGH while BLE boost sequence is running. On-board LED on most DevKit V1 boards. Enable with #define STATUS_LED_PIN 2 in config.h. |
| GPIO 0 | Factory reset button | IN | Optional. Pull-up input; hold LOW for 5 s to clear WiFi credentials and restart into AP mode. This is the BOOT button on most DevKit boards. Enable with #define RESET_BUTTON_PIN 0 in config.h. |
| (internal) | WiFi 802.11 b/g/n | IN/OUT | Station or soft-AP mode. Credentials stored in NVS under namespace whisper via Preferences. mDNS hostname whisperbridge.local. |
| (internal) | BLE 4.2 (NimBLE) | OUT | GATT client role only. TX power ESP_PWR_LVL_P9 (maximum). Targets fan by MAC address configured in config.h. |
| (internal) | NVS (Non-Volatile Storage) | IN/OUT | Stores WiFi SSID and password. Read on boot; written by the captive-portal /api/networkset endpoint. Cleared by factory reset. |
| (internal) | LittleFS (SPI flash) | IN | Read-only at runtime. Stores index.html and setup.html served by AsyncWebServer. Uploaded separately with the uploadfs PlatformIO target. |
Setup Guide
This guide walks through everything needed to get WhisperBridge running from scratch — hardware assembly, firmware configuration, first flash, WiFi provisioning, and Home Assistant integration.
Prerequisites
| Item | Details |
|---|---|
| ESP32 DevKit V1 | Any standard 38-pin ESP32-WROOM-32 development board |
| USB cable | Micro-USB data cable (not charge-only) for initial flash |
| Vent-Axia Svara fan | Powered on and within BLE range (~10 m) of the ESP32 |
| MQTT broker | Mosquitto or similar; must be reachable on the local network |
| PlatformIO | VS Code extension or CLI. Install from platformio.org |
| Node.js ≥ 18 | Only needed for the mock dev server (WhisperClient/test-server) |
Step-by-step
Find your fan's BLE MAC address
Detach the fan unit from its base — the MAC address and PIN are printed on a label on the inside of the device. Note both values: the 6-byte MAC address (e.g. A1:B2:C3:D4:E5:F6) and the 8-character hex PIN (e.g. e3146205).
No pre-flash configuration needed
All runtime settings — MQTT broker host/port/credentials, fan MAC address, and OTA password — are configured via the device web UI after flashing. config.h only contains compile-time defaults and optional hardware pin assignments; you do not need to edit it for a standard setup.
Build and flash the firmware (USB)
Connect the ESP32 via USB. Run both commands — firmware and filesystem are separate uploads.
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev -t upload -d WhisperServer
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev -t uploadfs -d WhisperServer
Provision WiFi via captive portal
On first boot with no stored credentials the device starts a soft-AP:
- SSID:
WhisperBridge-Setup - Password:
whisperbridge - Portal:
http://10.0.0.1(any HTTP request redirects here)
Connect a phone or laptop to that AP, open a browser, and you will be redirected to setup.html. Select your home network, enter the password, and tap Save & Connect. The device restarts into station mode.
Configure device settings (first station boot)
Once the device has joined your network, navigate to http://whisperbridge.local/settings.html (or use the IP shown in the serial monitor) and fill in all three cards:
- MQTT — broker host, port, username, and password
- Fan — Bluetooth MAC address and PIN (both from the label found in step 1)
- OTA — a password for future wireless firmware updates
All values are stored in NVS on the device. MQTT and OTA changes take effect after the device restarts; the fan MAC and PIN take effect immediately on the next boost.
Verify Home Assistant auto-discovery
Within a few seconds of connecting to the broker, WhisperBridge publishes an MQTT discovery payload to:
homeassistant/switch/whisperbridge_<id>_boost/config
In Home Assistant go to Settings → Devices & Services → MQTT. A new device named WhisperBridge with a Boost switch entity should appear automatically. No manual YAML required.
Test the boost command
Either toggle the switch in Home Assistant, press the Boost button on the device web UI at http://whisperbridge.local, or publish directly:
mosquitto_pub -h 192.168.1.x -t "whisperbridge/<id>/boost" -m "ON"
The status LED (GPIO 2) lights while the BLE sequence is in progress. The web UI status card updates every 2 s and shows Last result: success when the fan responds.
Future OTA updates
After initial USB flash, subsequent firmware updates can be pushed wirelessly. The device must be reachable as whisperbridge.local. On Windows, allow inbound connections for python.exe in the PlatformIO venv through Windows Firewall.
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev_ota -t upload -d WhisperServer
Factory reset
To clear stored WiFi credentials and return to AP/provisioning mode: hold the BOOT button (GPIO 0) for 5 seconds. The device clears NVS and restarts into WhisperBridge-Setup AP mode. Requires #define RESET_BUTTON_PIN 0 to be enabled in config.h.
pio device monitor -d WhisperServer) to see the assigned IP address, MQTT connection status, and any BLE errors in real time.Firmware
The firmware is structured as three cooperating singletons: Ble (BleBoost), Mqtt (MqttManager), and the application entry point in main.cpp. Each module has a narrow public interface — NimBLE headers are kept out of ble.h via forward declarations, and WiFiClient/PubSubClient are file-scope statics in mqtt.cpp, preventing header leakage into mqtt.h. This keeps compile-time coupling minimal and allows the native test environment to build state.h without any Arduino dependencies.
The web UI is served from LittleFS — two plain HTML/JS pages, no frameworks, designed to fit within the flash filesystem size constraint. In AP mode only the setup endpoints are registered; in station mode the boost API, status endpoint, and MQTT manager activate. All AsyncWebServer callbacks are non-blocking; the s_pendingRestart flag pattern ensures restarts happen safely in loop(), not on the lwIP task.
Build & Flash
Edit config.h
Set MQTT_HOST (broker IP), FAN_MAC_ADDRESS (fan's Bluetooth MAC), and OTA_PASSWORD before flashing.
Build firmware
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev -d WhisperServer
Flash firmware via USB
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev -t upload -d WhisperServer
Upload web UI to LittleFS
Filesystem and firmware are separate uploads — both must be current for the device to serve the web UI correctly.
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev -t uploadfs -d WhisperServer
Flash over-the-air
After initial USB flash. Device must be reachable as whisperbridge.local. Requires Windows Firewall to allow inbound for python.exe in the PlatformIO venv.
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev_ota -t upload -d WhisperServer
Run native unit tests
Unity tests for parseBoostCommand run entirely on the host — no Arduino hardware needed.
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" test -e native -d WhisperServer
Start mock server (UI development)
Node.js/Express server mirroring the firmware REST API. Serves the same data/ files at http://localhost:3000.
cd WhisperClient/test-server && npm start
WhisperBridge-Setup (password: whisperbridge) at 10.0.0.1. Any HTTP request is redirected to setup.html. After saving credentials the device restarts in station mode and registers itself with the MQTT broker and Home Assistant automatically.MQTT & REST API
MQTT Topics
All topics are constructed at runtime using the device ID — the last 3 MAC bytes in lowercase hex (e.g. a1b2c3). Topics are logged to serial on connect.
| Topic | Direction | Payload | Notes |
|---|---|---|---|
| whisperbridge/<id>/boost | Subscribe | ON or 1 |
Triggers BLE boost sequence. OFF is silently ignored — boost is one-shot, not a toggle. |
| whisperbridge/<id>/boost/state | Publish (retained) | ON / OFF |
ON published optimistically on command receipt; OFF published once the BLE task completes. |
| homeassistant/switch/whisperbridge_<id>_boost/config | Publish (retained) | JSON auto-discovery payload | Published on every MQTT reconnect. Registers a switch entity in Home Assistant with mdi:fan icon. |
REST API — Station Mode
| Endpoint | Method | Response | Notes |
|---|---|---|---|
| GET /api/status | GET | {"running":bool,"ble_ok":bool,"ssid":"…","ip":"…","rssi":-60} |
Polled every 2 s by index.html |
| POST /api/boost | POST | {"ok":true} or 409 {"error":"already running"} |
Calls Ble.trigger(); returns immediately |
| POST /api/reprovision | POST | {"ok":true} |
Clears NVS WiFi credentials; schedules restart into AP mode via s_pendingRestart |
| GET /api/deviceinfo | GET | {"id":"a1b2c3","url":"http://whisperbridge.local"} |
Available in both station and AP mode |
REST API — AP / Captive-Portal Mode
| Endpoint | Method | Response | Notes |
|---|---|---|---|
| GET /api/networkdata | GET | {"scanning":bool,"networks":[{"ssid","rssi","secure"}]} |
Async scan — poll until scanning:false. Deduplicates by SSID, keeps strongest RSSI per network. |
| POST /api/networkset | POST | {"ok":true} |
Body: {"ssid":"…","password":"…"}. Saves to NVS; sets s_pendingRestart flag. |
BLE / GATT Reference
WhisperBridge acts as a GATT client. The Vent-Axia Svara fan is the peripheral (GATT server). The UUIDs and byte sequences below were determined by reverse-engineering the fan's BLE advertisement and GATT table.
| Role | Service UUID | Characteristic UUID | Properties | Value (hex bytes) |
|---|---|---|---|---|
| Auth | e6834e4b-7b3a-48e6-91e4-f1d005f564d3 |
4cad343a-209a-40b7-b911-4d9b3df569b2 |
Write with response | e3 14 62 05 |
| Boost command | c119e858-0531-4681-9674-5a11f0e53bb4 |
118c949c-28c8-4139-b0b3-36657fd055a9 |
Write with response | 01 60 09 84 03 |
vTaskDelay(pdMS_TO_TICKS(200)) is inserted between the auth write and the boost command write. The fan requires this pause to process authentication before it will accept the command characteristic write. Removing the delay causes the command write to fail silently.Project Structure
WhisperBridge/
├── WhisperServer/ # PlatformIO firmware project
│ ├── platformio.ini # envs: esp32dev (USB), esp32dev_ota (OTA), native (tests)
│ ├── src/
│ │ ├── main.cpp # setup/loop, WiFi STA/AP, mDNS, OTA, AsyncWebServer endpoints
│ │ ├── ble.cpp # BleBoost: NimBLE GATT client, FreeRTOS task, runSequence()
│ │ └── mqtt.cpp # MqttManager: PubSubClient, HA auto-discovery, subscribe/publish
│ ├── include/
│ │ ├── config.h # MQTT host, BLE MAC, UUIDs, PIN/boost bytes, GPIO pin defines
│ │ ├── ble.h # BleBoost class — forward-declares NimBLE types, no headers
│ │ ├── mqtt.h # MqttManager class — no PubSubClient/WiFiClient headers
│ │ └── state.h # header-only parseBoostCommand(); #ifndef ARDUINO guard for native
│ ├── data/ # LittleFS web UI (upload separately with uploadfs)
│ │ ├── index.html # station UI: status card, Boost button, 2 s poll of /api/status
│ │ └── setup.html # captive-portal: async WiFi scan, SSID/password form
│ └── test/
│ └── test_mqtt.cpp # 6 Unity test cases for parseBoostCommand — host-only
└── WhisperClient/
└── test-server/ # Node.js/Express dev mock (no hardware required)
└── server.js # mirrors all REST endpoints; serves data/ at localhost:3000