air ESP32 Firmware · IoT Bridge

WhisperBridge

WiFi/MQTT-to-BLE bridge that lets Home Assistant control a Vent-Axia Svara fan via a one-shot boost command.

codeC++ memoryESP32 DevKit V1 routerMQTT bluetoothBLE / NimBLE wifiWiFi 2.4 GHz homeHome Assistant buildPlatformIO storageLittleFS
info

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

home Home Assistant Automation / UI
arrow_forward MQTT publish
router MQTT Broker Mosquitto / etc. whisperbridge/<id>/boost
arrow_forward WiFi / TCP
developer_board WhisperBridge ESP32 + NimBLE …/boost/state
arrow_forward BLE GATT
air Svara Fan Vent-Axia

BLE Boost Sequence

1
MQTT broker delivers ON to whisperbridge/<id>/boostMqttManager::onMessage fires on the lwIP task
2
Ble.trigger() sends a FreeRTOS task notification to the ble_boost task; returns immediately — lwIP task is not blocked
3
BLE task wakes, sets _running = true, calls NimBLEDevice::createClient() and connects to the fan by MAC address
4
Discovers auth service e6834e4b-…, writes 4-byte PIN 0xe3 0x14 0x62 0x05 to auth characteristic; waits 200 ms for firmware settle
5
Discovers command service c119e858-…, writes 5-byte boost payload 0x01 0x60 0x09 0x84 0x03 to command characteristic
6
Disconnects and deletes the NimBLE client; sets _running = false, _lastSuccess reflects the write result
7
Main loop() detects the _running falling edge and publishes OFF retained to whisperbridge/<id>/boost/state
info
Key architectural constraint: AsyncWebServer 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.
photo_library

Screenshots

Main UI showing device status and Boost button
Station mode UI — status card, network info, BLE result, and Boost button
WiFi setup page with network list and credential form
Captive-portal setup — async WiFi scan with signal strength, lock icons, and credential form
memory

Hardware

MCU
ESP32 DevKit V1
ESP32-WROOM-32, dual-core 240 MHz, 4 MB flash
Connectivity
WiFi 802.11 b/g/n + BLE 4.2
2.4 GHz integrated PCB antenna
Power
USB 5 V / 500 mA minimum
any phone charger or USB wall adapter
Optional — Status LED
LED + 330 Ω resistor on GPIO 2
HIGH while BLE boost sequence is running; enable via STATUS_LED_PIN
Optional — Reset Button
Momentary switch on GPIO 0 (BOOT)
Hold 5 s to clear WiFi credentials; enable via RESET_BUTTON_PIN
warning
BLE TX power: NimBLE is initialised at maximum power (ESP_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.
electrical_services

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.

USB PSU 5 V / 1 A min phone charger OK ESP32 DevKit V1 USB VIN GND IO2 5 V in (USB or VIN pin) Ground Status LED (optional) ESP32-WROOM 240 MHz dual-core 2.4 GHz antenna WiFi 2.4 GHz → MQTT broker BLE 2.4 GHz → Svara fan (wireless) 5 V GND OPTIONAL — status LED LED any colour 330 Ω ¼ W GND GPIO 2 (optional status LED) LEGEND 5 V power Ground Optional LED signal Wireless (WiFi / BLE)
developer_board

Pins Used

PinFunctionDirectionNotes
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.
rocket_launch

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

ItemDetails
ESP32 DevKit V1Any standard 38-pin ESP32-WROOM-32 development board
USB cableMicro-USB data cable (not charge-only) for initial flash
Vent-Axia Svara fanPowered on and within BLE range (~10 m) of the ESP32
MQTT brokerMosquitto or similar; must be reachable on the local network
PlatformIOVS Code extension or CLI. Install from platformio.org
Node.js ≥ 18Only needed for the mock dev server (WhisperClient/test-server)

Step-by-step

1

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).

2

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.

3

Build and flash the firmware (USB)

Connect the ESP32 via USB. Run both commands — firmware and filesystem are separate uploads.

powershell — flash firmwarecontent_copy
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev -t upload -d WhisperServer
powershell — upload web UI (LittleFS)content_copy
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev -t uploadfs -d WhisperServer
4

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.

5

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.

6

Verify Home Assistant auto-discovery

Within a few seconds of connecting to the broker, WhisperBridge publishes an MQTT discovery payload to:

topiccontent_copy
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.

7

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:

bashcontent_copy
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.

8

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.

powershellcontent_copy
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev_ota -t upload -d WhisperServer
9

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.

info
Tip: Keep a serial monitor open during first boot (pio device monitor -d WhisperServer) to see the assigned IP address, MQTT connection status, and any BLE errors in real time.
code

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

1

Edit config.h

Set MQTT_HOST (broker IP), FAN_MAC_ADDRESS (fan's Bluetooth MAC), and OTA_PASSWORD before flashing.

2

Build firmware

powershellcontent_copy
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev -d WhisperServer
3

Flash firmware via USB

powershellcontent_copy
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev -t upload -d WhisperServer
4

Upload web UI to LittleFS

Filesystem and firmware are separate uploads — both must be current for the device to serve the web UI correctly.

powershellcontent_copy
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev -t uploadfs -d WhisperServer
5

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.

powershellcontent_copy
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" run -e esp32dev_ota -t upload -d WhisperServer
6

Run native unit tests

Unity tests for parseBoostCommand run entirely on the host — no Arduino hardware needed.

powershellcontent_copy
& "$env:USERPROFILE\.platformio\penv\Scripts\pio.exe" test -e native -d WhisperServer
7

Start mock server (UI development)

Node.js/Express server mirroring the firmware REST API. Serves the same data/ files at http://localhost:3000.

bashcontent_copy
cd WhisperClient/test-server && npm start
info
First-boot provisioning: On first boot with no stored credentials, the device starts AP 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.
hub

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.

TopicDirectionPayloadNotes
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

EndpointMethodResponseNotes
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

EndpointMethodResponseNotes
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.
bluetooth

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.

RoleService UUIDCharacteristic UUIDPropertiesValue (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
warning
200 ms settle delay: A 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.
folder_open

Project Structure

treecontent_copy
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
menu_book

Documentation & References