Skip to content

support@quartzcomponents.com

Free Shipping Over INR 500

Electronics Projects

Smart Hydration Tracker Bottle Using ESP32 & VL53L0X Sensor

by RISHABH JANGID 01 Jun 2026 0 Comments
Build your own smart hydration tracking bottle using an ESP32-C3 Super Mini. The VL53L0X laser sensor measures water level in real time and streams data wirelessly over Bluetooth Low Energy to a companion Android app. The system supports live distance measurement, BLE notifications, and real-time UI updates. The project demonstrates practical implementation of I2C sensor interfacing, BLE communication, foreground background services, and modern reactive Android app development.
Smart Bottle Demo

Components Required

About the Components

ESP32-C3 Super Mini Development Board

The ESP32-C3 is a low-power RISC-V microcontroller with integrated Wi-Fi and Bluetooth 5 LE support. It is widely used for IoT prototyping, wearables, and wireless sensor nodes. The compact Super Mini form factor makes it ideal for embedding inside a bottle cap with minimal space requirements.

ESP32-C3 Pinout

  • RISC-V 32-bit single-core processor at 160 MHz
  • Integrated Bluetooth 5 LE and Wi-Fi 802.11 b/g/n
  • 22 programmable GPIO pins
  • Ultra-low deep-sleep current (5 µA)
  • 3.3V operating voltage
  • Supports SPI, I2C, UART, and ADC interfaces
  • Compatible with Arduino IDE and ESP-IDF

In this project, the ESP32-C3 acts as the central controller. It reads raw distance measurements from the VL53L0X over I2C and broadcasts the values to the Android smartphone via BLE notifications every 200ms.

VL53L0X Laser Distance Sensor

The VL53L0X is a Time-of-Flight laser ranging sensor from STMicroelectronics capable of measuring distances up to 2 meters with millimeter-level accuracy. It communicates over I2C and operates at 3.3V, making it directly compatible with the ESP32-C3 without any level shifting.

VL53L0X Back View

  • Time-of-Flight (ToF) laser ranging technology
  • Measurement range: 50 mm to 2000 mm
  • 940 nm invisible laser emitter (Class 1, eye-safe)
  • I2C interface (default address 0x29)
  • Operating voltage: 2.6V to 3.5V
  • Up to 50 readings per second
  • Compact 2.4 x 4.4 mm sensor footprint
  • Compatible with 3.3V and 5V logic

In this project, the VL53L0X sensor is mounted inside the bottle cap pointing downward. It continuously measures the distance from the cap to the water surface and sends the raw millimeter value directly to the Android app over BLE.

Circuit Diagram

The VL53L0X sensor communicates with the ESP32-C3 using the I2C interface. Only four connections are required: VCC, GND, SDA, and SCL. The sensor operates directly at 3.3V, making it fully compatible with the ESP32-C3 without any level shifter.

Fig. ESP32-C3 and VL53L0X Circuit Connections

VL53L0X Pin ESP32-C3 Super Mini Pin Description
VIN 3.3V Main Power Supply
GND GND Common Ground
SDA GPIO8 I2C Data Line
SCL GPIO9 I2C Clock Line

The ESP32-C3 reads distance measurements from the VL53L0X sensor over I2C and transmits the values wirelessly to the Android application using Bluetooth Low Energy notifications.

Assembly

Drill a small hole in the center of the bottle cap and mount the VL53L0X sensor flush with the underside, pointing directly down. Secure the ESP32-C3 and Li-ion battery on top of the cap using epoxy or hot glue. Connect the sensor to the ESP32-C3 using four short jumper wires for VCC, GND, SDA, and SCL according to the circuit connections table. Define the SDA and SCL pins explicitly in the firmware code using Wire.begin(8, 9) to ensure proper hardware I2C mapping on the compact Super Mini board. No external breadboard or additional wiring is needed.

Assembly Diagram

Soldering and final assembly of ESP32-C3, VL53L0X sensor, and battery on the bottle cap

Downloading and Running the Android Application

The complete Android application source code is available in the GitHub repository. Open Android Studio and select File → New → Project from Version Control. Paste the repository URL and clone the project.
After Gradle sync completes, connect your Android phone or start an emulator. Press the Run button to build and install the application.
Grant Bluetooth and notification permissions when prompted. The app will automatically scan for the Smart Bottle device and connect over BLE.

Code Explanation (ESP32 Firmware)

Libraries, UUIDs, and Global Variables

The firmware includes Wire.h and Adafruit_VL53L0X.h for the I2C sensor, plus the four BLE libraries for the ESP32 BLE stack. The service UUID and characteristic UUID are defined here and must exactly match the values used in the Android app. A global deviceConnected flag tracks whether the phone is currently connected.

ESP32 · Includes and UUIDs
#include <Wire.h>
#include "Adafruit_VL53L0X.h"
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>
#include <BLE2902.h>

// Must match the Android App exactly
#define SERVICE_UUID        "12345678-1234-1234-1234-123456789abc"
#define CHARACTERISTIC_UUID "abcd1234-5678-90ab-cdef-123456789abc"

Adafruit_VL53L0X lox = Adafruit_VL53L0X();
BLECharacteristic *pCharacteristic;
bool deviceConnected = false;

BLE Server Callbacks

The MyServerCallbacks class handles connection events. When the Android app connects, deviceConnected is set to true. When it disconnects, advertising restarts automatically so the app can reconnect without needing to reboot the hardware module.

ESP32 · BLE Server Callbacks
class MyServerCallbacks: public BLEServerCallbacks {
    void onConnect(BLEServer* pServer) {
      deviceConnected = true;
      Serial.println(">> App Connected");
    };
    void onDisconnect(BLEServer* pServer) {
      deviceConnected = false;
      Serial.println(">> App Disconnected - Advertising again");
      pServer->getAdvertising()->start();
    }
};

Setup – Sensor and BLE Initialization

The setup() function first initializes the VL53L0X sensor over I2C using pins 8 and 9. If the sensor is not detected, the firmware halts. It then creates the BLE server, service, and characteristic. The BLE2902 descriptor is added to the characteristic — this client characteristic configuration descriptor (CCCD) is required for Android BLE notifications to work correctly. Finally, the device starts advertising under the name SmartBot.

ESP32 · Setup
void setup() {
  Serial.begin(115200);

  // For ESP32-C3 Super Mini, define SDA/SCL pins explicitly
  Wire.begin(8, 9);

  Serial.println("Smart Bottle Initialization...");

  // 1. Initialize Sensor
  if (!lox.begin()) {
    Serial.println(F("Failed to boot VL53L0X"));
    while(1);
  }

  // 2. Initialize BLE
  BLEDevice::init("SmartBot");
  BLEServer *pServer = BLEDevice::createServer();
  pServer->setCallbacks(new MyServerCallbacks());

  BLEService *pService = pServer->createService(SERVICE_UUID);

  pCharacteristic = pService->createCharacteristic(
                      CHARACTERISTIC_UUID,
                      BLECharacteristic::PROPERTY_READ   |
                      BLECharacteristic::PROPERTY_NOTIFY
                    );

  // This descriptor is REQUIRED for Android notifications to work
  pCharacteristic->addDescriptor(new BLE2902());

  pService->start();

  // 3. Start Advertising
  BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  BLEDevice::startAdvertising();

  Serial.println("Ready! Open the Android app to connect.");
}

Main Loop – Sensor Reading and BLE Notify

The main loop calls lox.rangingTest() to get a fresh distance measurement each iteration. A RangeStatus check of 4 indicates an out-of-range error and is skipped. Valid readings are converted to a String and pushed to the connected Android app using pCharacteristic->notify(). A 200ms delay provides smooth UI updates on the phone while keeping power consumption low.

ESP32 · Main Loop
void loop() {
  VL53L0X_RangingMeasurementData_t measure;

  lox.rangingTest(&measure, false);

  if (measure.RangeStatus != 4) {
    int distance = measure.RangeMilliMeter;

    // Convert distance to String for the Android app
    String val = String(distance);

    Serial.print("Distance (mm): "); Serial.println(distance);

    if (deviceConnected) {
      pCharacteristic->setValue(val.c_str());
      pCharacteristic->notify(); // Push to phone
    }
  } else {
    Serial.println(" Out of range ");
  }

  // 200ms is a good balance for smooth UI updates and battery
  delay(200);
}

Android App Architecture & Implementation

The companion Android app uses a split architecture to process, compute, and display water level data:

  • Jetpack Compose UI: Reactive dark-mode theme with a custom Canvas component to animate the fluid level. Navigates between a first-run Calibration Wizard and the main tracking dashboard based on isCalibrationComplete.
  • Foreground Service Thread: Manages the BLE lifecycle in the background, keeping data streaming alive when the app is minimized.
  • StateFlow Tracking: Couples UI rendering with GATT updates via MutableStateFlow<BottleState>.
Smart Bottle Calibration Wizard Smart Bottle Dashboard

Android application showing the first-time calibration wizard and the main hydration tracking dashboard.

Calibration Wizard

On first launch, the app guides the user through a 3-step setup flow to measure the bottle's actual geometry. At each step the live sensor reading is shown, and tapping Save & Continue persists the distance value to SharedPreferences:

Step Bottle State Key Saved Default Fallback (mm)
1 Empty (0%) cal_0 280
2 Half Full (50%) cal_50 180
3 Full (100%) cal_100 70

Once cal_100 is saved, isCalibrationComplete is set to true and the app transitions to the main dashboard permanently.

Key Implementation Snippets

Calibration Wizard UI — Displays the live rawDistanceMm reading and saves each step on tap:

Android · MainActivity.kt — CalibrationWizard()
@Composable
fun CalibrationWizard(state: BottleState, onSave: (Int) -> Unit) {
    val steps = listOf(
        0   to "Empty the bottle completely.",
        50  to "Fill the bottle exactly halfway.",
        100 to "Fill the bottle to the top."
    )
    var currentStepIdx by remember { mutableIntStateOf(0) }
    val currentStep = steps[currentStepIdx]

    Column( ... ) {
        Text("Sensor Reading:", color = Color.Gray)
        Text("${state.rawDistanceMm} mm", fontSize = 36.sp, fontFamily = FontFamily.Monospace)

        Button(onClick = {
            onSave(currentStep.first)          // persist to SharedPreferences
            if (currentStepIdx < steps.size - 1) currentStepIdx++
        }) {
            Text(if (currentStepIdx < steps.size - 1) "Save & Continue" else "Finish Setup")
        }
    }
}

Dynamic calibration in calculatePercentage() — Reads user-saved points from SharedPreferences, falling back to sensible defaults if not yet calibrated:

Android · SmartBottleService.kt — calculatePercentage()
private fun calculatePercentage(d: Float): Int {
    val prefs = getSharedPreferences("SmartBottlePrefs", Context.MODE_PRIVATE)

    val points = listOf(
        prefs.getFloat("cal_0",   280f) to 0f,
        prefs.getFloat("cal_50",  180f) to 50f,
        prefs.getFloat("cal_100",  70f) to 100f
    ).sortedByDescending { it.first }

    if (d >= points.first().first) return 0
    if (d <= points.last().first)  return 100

    for (i in 0 until points.size - 1) {
        val (d1, p1) = points[i]
        val (d2, p2) = points[i + 1]
        if (d <= d1 && d >= d2)
            return (p1 + (d1 - d) * (p2 - p1) / (d1 - d2)).toInt()
    }
    return 100
}

15-point rolling average — Smooths raw sensor spikes caused by water sloshing. Also stores rawDistanceMm in state for the calibration wizard to display live:

Android · SmartBottleService.kt — processNewDistance()
private val samples    = mutableListOf<Int>()
private val maxSamples = 15

private fun processNewDistance(distance: Int) {
    samples.add(distance)
    if (samples.size > maxSamples) samples.removeAt(0)

    val percentage = calculatePercentage(samples.average().toFloat())
    val volume     = (percentage / 100f) * BOTTLE_CAPACITY_LITERS

    _bottleState.update { it.copy(
        waterLevelPercent = percentage,
        volumeLiters      = volume,
        rawDistanceMm     = distance       // exposed for calibration wizard
    ) }

    if (percentage < 15 && System.currentTimeMillis() - lastLowWaterAlertTime > 30 * 60 * 1000L) {
        sendNotification("Low Water Alert", "Your water level is below 15%!", 2)
        lastLowWaterAlertTime = System.currentTimeMillis()
    }
}

CCCD descriptor write — Tells the ESP32-C3 to push notifications automatically instead of waiting for manual poll requests:

Android · SmartBottleService.kt — onServicesDiscovered()
val service = gatt.getService(SERVICE_UUID)
service?.getCharacteristic(DATA_CHAR_UUID)?.let { char ->
    gatt.setCharacteristicNotification(char, true)
    val desc = char.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))
    desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
    gatt.writeDescriptor(desc)
}

The complete Android source — AndroidManifest.xml, MainActivity.kt, SmartBottleService.kt, BottleState.kt, and BootReceiver.kt - is available in the GitHub repository.

Software Architecture Highlights

Result

The ESP32-C3 successfully runs a complete wireless water level monitoring system using the VL53L0X laser sensor and BLE communication. The device advertises as SmartBot, reconnects automatically after disconnection, and streams live distance readings to the Android app every 200ms with smooth real-time UI updates.

The out-of-range status check prevents invalid sensor readings from being transmitted, while the BLE2902 descriptor ensures Android notification subscriptions work reliably without any additional configuration on the phone side.

Smart Bottle Live Demo

Checkout the full tutorial :

Complete Code

The following ESP32 Arduino sketch implements the complete smart bottle firmware using the VL53L0X laser sensor and BLE stack. The code includes sensor initialisation, BLE service and characteristic setup with the required BLE2902 descriptor, connection callbacks with auto re-advertising, and the main 200ms polling loop.

Smart Bottle ESP32 Complete Code
#include <Wire.h>
#include "Adafruit_VL53L0X.h"
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>
#include <BLE2902.h>

// Must match the Android App exactly
#define SERVICE_UUID        "12345678-1234-1234-1234-123456789abc"
#define CHARACTERISTIC_UUID "abcd1234-5678-90ab-cdef-123456789abc"

Adafruit_VL53L0X lox = Adafruit_VL53L0X();
BLECharacteristic *pCharacteristic;
bool deviceConnected = false;

class MyServerCallbacks: public BLEServerCallbacks {
    void onConnect(BLEServer* pServer) {
      deviceConnected = true;
      Serial.println(">> App Connected");
    };
    void onDisconnect(BLEServer* pServer) {
      deviceConnected = false;
      Serial.println(">> App Disconnected - Advertising again");
      pServer->getAdvertising()->start();
    }
};

void setup() {
  Serial.begin(115200);

  // For ESP32-C3 Super Mini, define SDA/SCL pins explicitly
  Wire.begin(8, 9);

  Serial.println("Smart Bottle Initialization...");

  if (!lox.begin()) {
    Serial.println(F("Failed to boot VL53L0X"));
    while(1);
  }

  BLEDevice::init("SmartBot");
  BLEServer *pServer = BLEDevice::createServer();
  pServer->setCallbacks(new MyServerCallbacks());

  BLEService *pService = pServer->createService(SERVICE_UUID);

  pCharacteristic = pService->createCharacteristic(
                      CHARACTERISTIC_UUID,
                      BLECharacteristic::PROPERTY_READ   |
                      BLECharacteristic::PROPERTY_NOTIFY
                    );

  // This descriptor is REQUIRED for Android notifications to work
  pCharacteristic->addDescriptor(new BLE2902());

  pService->start();

  BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  BLEDevice::startAdvertising();

  Serial.println("Ready! Open the Android app to connect.");
}

void loop() {
  VL53L0X_RangingMeasurementData_t measure;

  lox.rangingTest(&measure, false);

  if (measure.RangeStatus != 4) {
    int distance = measure.RangeMilliMeter;

    String val = String(distance);

    Serial.print("Distance (mm): "); Serial.println(distance);

    if (deviceConnected) {
      pCharacteristic->setValue(val.c_str());
      pCharacteristic->notify(); // Push to phone
    }
  } else {
    Serial.println(" Out of range ");
  }

  // 200ms is a good balance for smooth UI updates and battery
  delay(200);
}
Prev Post
Next Post

Leave a comment

Please note, comments need to be approved before they are published.

Thanks for subscribing!

This email has been registered!

Shop the look

Choose Options

Edit Option
Back In Stock Notification
is added to your shopping cart.
this is just a warning
Login