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.

ESP32 with ADS1115 ADC and voltmeter on a breadboard.
The hardware: ESP32 + ADS1115 ADC on a breadboard.

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

LayerTechnology
MCUESP32
ADCADS1115 (16-bit, I²C)
WirelessBLE GATT
MobileKotlin + Jetpack Compose
Dev EnvironmentWSL2 + Arduino CLI
USB Bridgeusbipd-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)

Android app showing live voltage reading.
The Android app displaying a live voltage reading.

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

BugProblemFix
Auto-reconnect no-opConnection callback was missingProper callback registration in reconnect path
Thread-unsafe device mapConcurrent modification crash on rapid reconnectSynchronized access to device collection
CCCD race conditionNotifications sent before subscribe completedWait for descriptor write confirmation
Permission spam loopPermissions requested repeatedly on each resumeTrack 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.