ESP32 BLE Voltage Monitor: End-to-End in 6 Hours
A complete Bluetooth voltage monitoring system — ESP32 reads voltage via ADS1115 ADC, broadcasts over BLE GATT, and an Android app displays live readings in real time. Built end-to-end in ~6 hours across 3 platforms.
The goal: measure voltage wirelessly and display it on your phone. Simple enough, but it means touching embedded C++, Bluetooth protocols, and Android Kotlin — all in one session. Here’s how the 6 hours broke down and what we learned along the way.

Architecture
⚡ Voltage Source → ADS1115 (I²C)
↓
ESP32 (BLE GATT Server)
↓
📱 Android App (Jetpack Compose)
The ESP32 reads differential voltage from the ADS1115 (16-bit ADC over I²C), then exposes the reading as a BLE GATT characteristic. The Android app discovers the device, subscribes to notifications, and displays live voltage updates.
Tech Stack
| Layer | Technology |
|---|---|
| MCU | ESP32 |
| ADC | ADS1115 (16-bit, I²C) |
| Wireless | BLE GATT |
| Mobile | Kotlin + Jetpack Compose |
| Dev Environment | WSL2 + Arduino CLI |
| USB Bridge | usbipd-win (WSL ↔ Windows) |
Build Timeline
Phase 1: Environment Setup (~24 min)
The ESP32 was connected to a Windows machine, but development happens in WSL2. Getting USB passthrough working via usbipd-win was the first hurdle — attach the device, verify /dev/ttyUSB0 access, install Arduino CLI, ESP32 core, and the ADS1X15 library.
Phase 2: Hello World → ADS1115 (~26 min)
Compiled and flashed a minimal sketch, verified serial output at 115200 baud. Then built an I²C scanner to detect the ADS1115 at address 0x48, and implemented differential reading between A0 and A1 with ±6.144V range.
The voltage calculation is straightforward:
voltage = raw × (6.144 / 32768)
16-bit resolution over a ±6.144V range gives you ~0.1875mV precision.
Phase 3: BLE GATT Server (~3.5 hrs)
This was the bulk of the work. Creating a BLE server on ESP32 means defining a GATT service with characteristics for:
- Voltage notify — pushes readings to subscribed clients on change
- Calibration read/write — allows the client to adjust the calibration factor
The tricky part was CCCD (Client Characteristic Configuration Descriptor) subscribe tracking. The initial implementation only checked global connection state — it didn’t track whether the client had actually subscribed to notifications. This caused the ESP32 to send notifications to clients that hadn’t asked for them.
Fix: track subscriptions at the descriptor level, not just the connection level.
The WSL–Windows bridge problem. BLE doesn’t cross the WSL boundary. The AI coding agent runs in WSL but needs to test BLE on Windows. Solution: a file-based IPC bridge (watcher.py) — the agent writes a command file, a Windows-side watcher picks it up and triggers BLE scans/tests. USB re-attach after flashing was also scripted via usbipd attach.
Phase 4: Android App (~1 hr)

Kotlin + Jetpack Compose app with three core components:
- BleScanner — discovers BLE devices in range
- BleManager — handles connection lifecycle, notification subscriptions, and reconnection
- Live display — voltage reading updates in real time
Key implementation details:
- Proper permission flow for Android 12+ BLE permissions (
BLUETOOTH_SCAN,BLUETOOTH_CONNECT,ACCESS_FINE_LOCATION) - Thread-safe device map to prevent crashes on rapid reconnect
- Auto-reconnect with proper connection callback handling
Phase 5: Polish (~1 hr)
FusedLocationProvider for GPS tagging of measurements, Material 3 UI components (TopAppBar, FilterChips), and a generally polished app experience.
Bugs Fixed
| Bug | Problem | Fix |
|---|---|---|
| Auto-reconnect no-op | Connection callback was missing | Proper callback registration in reconnect path |
| Thread-unsafe device map | Concurrent modification crash on rapid reconnect | Synchronized access to device collection |
| CCCD race condition | Notifications sent before subscribe completed | Wait for descriptor write confirmation |
| Permission spam loop | Permissions requested repeatedly on each resume | Track permission state, request only once |
The CCCD race condition is worth highlighting — it’s a classic BLE bug. The client subscribes to notifications by writing to the CCCD descriptor, but the server started sending notifications before the write completed. The fix: don’t start notifying until the descriptor write callback confirms the subscription.
Final Thoughts
6 hours from “what’s the USB device path?” to a working end-to-end Bluetooth voltage monitor with a polished Android app. The ADS1115 gives excellent 16-bit resolution, BLE GATT provides a clean interface for real-time data, and Jetpack Compose makes the Android side almost pleasant. The biggest time sink was BLE descriptor handling — if you’re building something similar, budget extra time for testing CCCD subscription behavior with real devices.