ESP32 Web BLE: Live Sensor Data Visualization (BME280 Charts)

In this project, we’ll show you how to use Web Bluetooth (Web BLE) with the ESP32 to display live sensor data on a browser while you’re connected to your ESP32 via BLE. Web Bluetooth (Web BLE) allows you to connect and control BLE-enabled devices, like the ESP32, directly from your web browser using JavaScript.

ESP32 Web BLE: Live Data Visualization Charts

Web BLE is only supported on Android devices, Windows computers, and Mac OS computers. We recommend using Google Chrome browser. It is not supported in iOS, but you can use an app like Bluefy to emulate a BLE web browser on iOS (we’ll show you how to use it in this tutorial).

Table of Contents

In this tutorial, we’ll cover the following subjects:

Prerequisites

Before proceeding with this tutorial, make sure you follow these prerequisites:

1. Preparing the Arduino IDE

We’ll program the ESP32 using Arduino IDE. Make sure you have the ESP32 boards installed before proceeding. Follow the next tutorial first, you haven’t already:

Installing Libraries

For this tutorial, we’ll use a BME280 sensor and the Adafruit_BME280 library. Install it before proceeding.

In the Arduino IDE, go to Sketch > Include Library > Manage Libraries… and search for the library name: Adafruit BME280. You also need to install the Adafruit Unified Sensor Library (a pop-up shows up to install it if you haven’t already)

Installing Adafruit BME280 Sensor Library Arduino IDE

2. Parts Required

For this tutorial, you can use any ESP32 of your choice.

ESP32 Board Connected to a BME280 sensor

For the sensor, we’ll use a BME280 sensor that will send temperature, humidity, and pressure readings via BLE. You can use any other sensor with just a few modifications to the code.

You can use the preceding links or go directly to MakerAdvisor.com/tools to find all the parts for your projects at the best price!

3. Circuit Diagram

Connect a BME280 sensor to your ESP32. We’re using GPIOs 21 and 22 for SDA and SCL. You can choose any other GPIOs as long as you modify the code accordingly.

ESP32 with BME280 Wiring Diagram

4. Install the Bluefy App (for IOS Devices)

The Web Bluetooth API provides the ability to connect and interact with Bluetooth Low Energy peripherals (like the ESP32). The API is still under development, but it is generally considered to be stable and usable. It has been implemented in Chrome, Edge, Opera (Android), and it is supported on Android and Windows. However, it is not yet supported on iOS.

You can check the web browser/device compatibility on the Web Bluetooth API documentation.

If you have an iOS device, you can still follow this project by installing the Bluefy app, which is a web-based BLE browser. Here’s the link to the app:

Bluefy app

Web BLE – Quick Introduction

Web Bluetooth (also sometimes referred to as Web BLE) is a technology that allows you to connect and control BLE-enabled devices, like the ESP32, directly from your web browser using JavaScript.

With Web BLE, you can create web applications that interact with your ESP32 devices via Bluetooth, enabling you to control GPIO pins, exchange data, and manage your devices remotely through a web interface (any device that supports a web browser, like your computer or smartphone*).

Explaining Web Bluetooth with ESP32

* except iOS devices. Alternatively, you can use an app like Bluefy – Web BLE Browser.

One of the main advantages of Web Bluetooth is that it works across different devices and operating systems through a compatible web browser, without the need to install a dedicated app.

This means you can use a smartphone, tablet, or desktop computer to connect and control ESP32 devices using a Web BLE application in your web browser.

The Web Bluetooth API is still under development, but it is generally considered to be stable and usable. It has been implemented in Chrome, Edge, Opera, and Firefox, and it is supported on Android and Windows. However, it is not yet supported on iOS.

Web BLE and BLE Resources

If you’re new to Web BLE and BLE on the ESP32, we recommend taking a quick look at the following tutorials to get familiar with the basic concepts of Bluetooth Low Energy.


Project Overview

Before proceeding, let’s take a quick look at the features of the project we’ll build:

  • The ESP32 acts as a BLE Peripheral, advertising its presence.
  • Your web browser acts as a BLE Controller that gets sensor readings (reads the ESP32 characteristics).
ESP32 Web BLE Simple Demonstration example
  • The ESP32 GATT structure will have one service (environmental sensing service: UUID 181A) with three characteristics (temperature: UUID 2A6E, humidity: UUID 2A6F, and pressure: UUID 2A6D).
ESP32 Environmental Sensing Service  GATT
  • The ESP32 continuously reads the BME280 sensor and writes the new values to the temperature, humidity, and pressure characteristics.
  • Your browser will connect to the ESP32 Bluetooth device and will receive notifications whenever a value changes on the ESP32 characteristics.
  • It reads those characteristics and updates a web page with the sensor data.
  • The web page displays the current readings on cards and the latest sensor readings on charts.
ESP32 Web BLE App - Display Sensor Data on Charts temperature humidity
ESP32 Web BLE App - Display Sensor Data on Charts

ESP32 BLE Device – Arduino Code

The following code turns the ESP32 into a BLE device with the BLE environmental sensing service and three characteristics: temperature, humidity, and pressure.

Upload the following code to your board, and it will work straight away.

/*
  Rui Santos & Sara Santos - Random Nerd Tutorials
  Complete project details at https://RandomNerdTutorials.com/esp32-web-ble-sensor-visualization/
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
  The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

// BME280 GPIOs
#define SDA_PIN 21
#define SCL_PIN 22

// BME280 
Adafruit_BME280 bme;

// BLE UUIDs 
// Environmental Sensing Service
#define SERVICE_UUID              "181A"
// Temperature
#define TEMP_CHARACTERISTIC_UUID  "2A6E"
// Humidity
#define HUM_CHARACTERISTIC_UUID   "2A6F"
// Pressure
#define PRESS_CHARACTERISTIC_UUID "2A6D"

// BLE variables
BLEServer*         pServer            = nullptr;
BLECharacteristic* pTempChar          = nullptr;
BLECharacteristic* pHumChar           = nullptr;
BLECharacteristic* pPressChar         = nullptr;
bool               deviceConnected    = false;
bool               oldDeviceConnected = false;

// Send a reading every 5 seconds
const unsigned long SAMPLE_INTERVAL_MS = 5000;
unsigned long lastSampleMs = 0;

// BLE server callbacks
class ServerCallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer* pServer) override {
    deviceConnected = true;
    Serial.println("BLE client connected.");
  }
  void onDisconnect(BLEServer* pServer) override {
    deviceConnected = false;
    Serial.println("BLE client disconnected.");
  }
};

// Format a float to one decimal place and set it on a BLE characteristic,
// then call notify() so connected clients receive it immediately
void notifyFloat(BLECharacteristic* pChar, float value) {
  char buf[16];
  snprintf(buf, sizeof(buf), "%.1f", value);
  pChar->setValue(buf);
  pChar->notify();
}

void setup() {
  Serial.begin(115200);
  Serial.println("\nESP32 BME280 BLE Server starting...");

  // Initialize BME280 Sensor
  Wire.begin(SDA_PIN, SCL_PIN);
  if (!bme.begin(0x76)) { 
    Serial.println("BME280 not found!");
    while(1); 
  }

  // ESP32 BLE init
  BLEDevice::init("ESP32");
  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new ServerCallbacks());
  BLEService* pService = pServer->createService(SERVICE_UUID);

  // Temperature characteristic — READ + NOTIFY
  pTempChar = pService->createCharacteristic(
    TEMP_CHARACTERISTIC_UUID,
    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
  );
  pTempChar->addDescriptor(new BLE2902());

  // Humidity characteristic — READ + NOTIFY
  pHumChar = pService->createCharacteristic(
    HUM_CHARACTERISTIC_UUID,
    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
  );
  pHumChar->addDescriptor(new BLE2902());

  // Pressure characteristic — READ + NOTIFY
  pPressChar = pService->createCharacteristic(
    PRESS_CHARACTERISTIC_UUID,
    BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
  );
  pPressChar->addDescriptor(new BLE2902());

  // Set initial values
  pTempChar->setValue("0.0");
  pHumChar->setValue("0.0");
  pPressChar->setValue("0.0");

  // Start the service
  pService->start();

  // Start BLE Device Advertising
  BLEAdvertising* pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->setScanResponse(false);
  pAdvertising->setMinPreferred(0x0);
  BLEDevice::startAdvertising();

  Serial.println("BLE advertising started. Waiting for client...");
}

void loop() {
  unsigned long now = millis();

  // Sample and notify on schedule
  if (now - lastSampleMs >= SAMPLE_INTERVAL_MS) {
    lastSampleMs = now;

    float tempC     = bme.readTemperature();   // °C
    float humidity  = bme.readHumidity();      // %
    float pressurePa  = bme.readPressure();    // Pa
    float pressureHPa = pressurePa / 100.0F;   // hPa

    Serial.printf("Temp: %.1f °C  Hum: %.1f %%  Press: %.1f hPa\n",
                   tempC, humidity, pressureHPa);

      // Notify BLE client with sensor readings if a BLE client is connected
      if (deviceConnected) {
        notifyFloat(pTempChar,  tempC);
        notifyFloat(pHumChar,   humidity);
        notifyFloat(pPressChar, pressureHPa);
      }
  }

  // Handle BLE reconnect after unexpected disconnect
  if (!deviceConnected && oldDeviceConnected) {
    delay(500);
    pServer->startAdvertising();
    Serial.println("Restarted advertising.");
    oldDeviceConnected = false;
  }

  if (deviceConnected && !oldDeviceConnected) {
    oldDeviceConnected = true;
    Serial.println("BLE client connected.");
  }
}

View raw code

How the Code Works

In summary, this code sets up a BLE server with the environmental sensing service with three characteristics: temperature, humidity, and pressure. When a BLE client connects, it can read the sensor data. The code also handles device connection and disconnection events.

Including Libraries

First, you need to import the following libraries to deal with Bluetooth.

#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>

These are used to connect and read from the BME280 sensor:

#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

BME280

Define the pins that the BME280 sensor is connected to. We’re using GPIOs 21 and 22.

// BME280 GPIOs
#define SDA_PIN 21
#define SCL_PIN 22

Create an instance of the Adafruit_BME280 called bme.

Adafruit_BME280 bme;

UUID Definitions

Then, you set the UUIDs for the Service and Characteristics. The official UUIDs defined in the SIG for the environmental sensing service and temperature, humidity, and pressure characteristics are as follows:

// BLE UUIDs 
// Environmental Sensing Service
#define SERVICE_UUID              "181A"
// Temperature
#define TEMP_CHARACTERISTIC_UUID  "2A6E"
// Humidity
#define HUM_CHARACTERISTIC_UUID   "2A6F"
// Pressure
#define PRESS_CHARACTERISTIC_UUID "2A6D"

Global BLE Variables

Then you create some global variables to use later in your code.

// BLE variables
BLEServer*         pServer            = nullptr;
BLECharacteristic* pTempChar          = nullptr;
BLECharacteristic* pHumChar           = nullptr;
BLECharacteristic* pPressChar         = nullptr;
bool               deviceConnected    = false;
bool               oldDeviceConnected = false;
  • pServer: pointer to the BLEServer object;
  • pTempChar: pointer to the BLECharacteristic for the temperature reading;
  • pHumChar: pointer to the BLECharacteristic for the humidity reading;
  • pPressChar: pointer to the BLECharacteristic for the pressure reading;
  • deviceConnected: a boolean variable to track whether a BLE device is connected;
  • oldDeviceConnected: a boolean variable to track the previous connection status.

BLE Server and Callbacks

Then, you create several callback functions. The ServerCallbacks defines a callback function for device connection and disconnection events. In this case, we change the value of the deviceConnected variable to true or false depending on the connection state.

// BLE server callbacks
class ServerCallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer* pServer) override {
    deviceConnected = true;
    Serial.println("BLE client connected.");
  }
  void onDisconnect(BLEServer* pServer) override {
    deviceConnected = false;
    Serial.println("BLE client disconnected.");
  }
};

Notify Values

The notifyFloat() function writes a float value on a specific BLECharacteristic. Since all our values are floats with several decimal numbers, we first format them to one decimal place.

void notifyFloat(BLECharacteristic* pChar, float value) {
  char buf[16];
  snprintf(buf, sizeof(buf), "%.1f", value);

We set the characteristic value.

pChar->setValue(buf);

And then, we notify the connected client that the characteristic has changed.

pChar->notify();

setup()

In the setup(), initialize serial communication for debugging.

Serial.begin(115200);

Initialize the BME280 sensor.

// Initialize BME280 Sensor
Wire.begin(SDA_PIN, SCL_PIN);
if (!bme.begin(0x76)) { 
  Serial.println("BME280 not found!");
  while(1); 
}

Initialize the ESP32 as a BLE device called ESP32. You can call it any other name.

BLEDevice::init("ESP32");

Then, create a BLE server and set its callbacks for connection and disconnection.

pServer = BLEDevice::createServer();
pServer->setCallbacks(new ServerCallbacks());

Create a BLE service with the UUID we’ve defined earlier.

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

Create the temperature, humidity, and pressure BLE characteristics (inside the service we just created) and set their properties (read, and notify). Add BLE descriptors (BLE2902) to all characteristics.

// Temperature characteristic — READ + NOTIFY
pTempChar = pService->createCharacteristic(
  TEMP_CHARACTERISTIC_UUID,
  BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
);
pTempChar->addDescriptor(new BLE2902());

// Humidity characteristic — READ + NOTIFY
pHumChar = pService->createCharacteristic(
  HUM_CHARACTERISTIC_UUID,
  BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
);
pHumChar->addDescriptor(new BLE2902());

// Pressure characteristic — READ + NOTIFY
pPressChar = pService->createCharacteristic(
  PRESS_CHARACTERISTIC_UUID,
  BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY
);
pPressChar->addDescriptor(new BLE2902());

Note: BLE2902 is a specific descriptor that is often used for Client Characteristic Configuration (CCC). CCC descriptors are used to configure how a client (the device connecting to the server) wants to be notified or indicated of changes in a characteristic’s value. In simpler terms, they control whether the client should receive notifications or indications when the value of the associated characteristic changes.

By adding BLE2902 descriptors to our characteristics, you make it possible for clients to configure how they want to be notified or updated when the values of these characteristics change. Clients can use these descriptors to enable or disable notifications or indications, depending on their preferences or requirements for real-time updates from the ESP32 server.

Set the characteristics’ initial values to 0.0.

// Set initial values
pTempChar->setValue("0.0");
pHumChar->setValue("0.0");
pPressChar->setValue("0.0");

Start the BLE service.

// Start the service
pService->start();

And finally, configure advertising settings and start advertising.

// Start BLE Device Advertising
BLEAdvertising* pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->setScanResponse(false);
pAdvertising->setMinPreferred(0x0);
BLEDevice::startAdvertising();

loop()

In the loop(), get new sensor values every SAMPLE_INTERVAL_MS.

unsigned long now = millis();

// Sample and notify on schedule
if (now - lastSampleMs >= SAMPLE_INTERVAL_MS) {
  lastSampleMs = now;

  float tempC     = bme.readTemperature();   // °C
  float humidity  = bme.readHumidity();      // %
  float pressurePa  = bme.readPressure();    // Pa
  float pressureHPa = pressurePa / 100.0F;   // hPa

  Serial.printf("Temp: %.1f °C  Hum: %.1f %%  Press: %.1f hPa\n",
                 tempC, humidity, pressureHPa);

If a BLE device is connected (deviceConnected is true), notify the client with the new readings.

// Notify BLE client with sensor readings if a BLE client is connected
if (deviceConnected) {
  notifyFloat(pTempChar,  tempC);
  notifyFloat(pHumChar,   humidity);
  notifyFloat(pPressChar, pressureHPa);
}

If a device disconnects and oldDeviceConnected is true, it restarts advertising and logs a message.
If a device connects and oldDeviceConnected is false, it logs a message (you can add your own logic here for actions to be taken on connection).

// Handle BLE reconnect after unexpected disconnect
if (!deviceConnected && oldDeviceConnected) {
  delay(500);
  pServer->startAdvertising();
  Serial.println("Restarted advertising.");
  oldDeviceConnected = false;
}

if (deviceConnected && !oldDeviceConnected) {
  oldDeviceConnected = true;
  Serial.println("BLE client connected.");
}

Uploading the Code

Upload the code to your ESP32 board. After uploading, open the Serial Monitor and restart your board.

You’ll see that it initialized the BLE service and is waiting for a client connection.

ESP32 BLE device with BME280 waiting for a connection.

Creating the Web BLE App

Now that you’ve set the ESP32 as a BLE Client, we’ll create the web app so that we can interact with the ESP32 via BLE using our web browser.

Web BLE App - ESP32 - Environmental Sensing Service

Alternatively, you can use our web app by going to this URL:

HTML Page

Create an HTML file called index.html with the following code (it contains both the HTML to build the web page and JavaScript to handle Web Bluetooth).

<!-- Rui Santos & Sara Santos - Random Nerd Tutorials
  Complete project details at https://RandomNerdTutorials.com/esp32-web-ble-sensor-visualization/
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -->
<!DOCTYPE html>
<html lang="en">
<head>
    <title>ESP32 Web BLE - SENSOR</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta charset="UTF-8">
    <link rel="icon" type="image/png" href="favicon.ico">
    <link rel="stylesheet" href="style.css">
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
</head>
<body>
<!-- Navigation -->
<div class="topnav">
    <h1>ESP32 Web BLE - SENSOR</h1>
</div>
<div class="content">
    <!-- Connect/Disconnect buttons -->
    <div class="connection-card">
        <button id="connectBleButton" class="connectButton">&#x1F4F6;&nbsp; Connect to BLE device</button>
        <button id="disconnectBleButton" class="disconnectButton hidden">&#x274C;&nbsp; Disconnect</button>
        <div class="ble-status">
            <span class="ble-dot" id="bleDot"></span>
            <span id="bleState">Disconnected</span>
        </div>
    </div>
    <!-- Sensor Reading Cards -->
    <div class="sensor-grid">
        <div class="sensor-card temp">
            <span class="sensor-icon">&#x1F321;</span>
            <div class="sensor-label">Temperature</div>
            <div class="sensor-value">
                <span id="tempValue">--</span><span class="sensor-unit" id="tempUnitLabel">&nbsp;&deg;C</span>
            </div>
            <div class="sensor-timestamp" id="tempTimestamp">No reading</div>
            <div class="unit-toggle">
                <button class="unit-btn active" id="btnC" onclick="setTempUnit('C')">&deg;C</button>
                <button class="unit-btn" id="btnF" onclick="setTempUnit('F')">&deg;F</button>
            </div>
        </div>
        <div class="sensor-card hum">
            <span class="sensor-icon">&#x1F4A7;</span>
            <div class="sensor-label">Humidity</div>
            <div class="sensor-value">
                <span id="humValue">--</span><span class="sensor-unit">&nbsp;%</span>
            </div>
            <div class="sensor-timestamp" id="humTimestamp">No reading</div>
        </div>
        <div class="sensor-card press">
            <span class="sensor-icon">&#8595;</span>
            <div class="sensor-label">Pressure</div>
            <div class="sensor-value">
                <span id="pressValue">--</span><span class="sensor-unit">&nbsp;hPa</span>
            </div>
            <div class="sensor-timestamp" id="pressTimestamp">No reading</div>
        </div>
    </div>
    <!-- Charts -->
    <div class="charts-section">
        <div class="chart-card">
            <div class="chart-card-header">
                <h2>&#x1F321;&nbsp; Temperature</h2>
            </div>
            <div class="chart-wrapper">
                <canvas id="tempChart" role="img"></canvas>
            </div>
        </div>
        <div class="chart-card">
            <div class="chart-card-header">
                <h2>&#x1F4A7;&nbsp; Humidity</h2>
            </div>
            <div class="chart-wrapper">
                <canvas id="humChart" role="img"></canvas>
            </div>
        </div>
        <div class="chart-card">
            <div class="chart-card-header">
                <h2>&#8595;&nbsp; Pressure</h2>
            </div>
            <div class="chart-wrapper">
                <canvas id="pressChart" role="img"></canvas>
            </div>
        </div>
    </div>
</div>
<div class="footer">
    <p><a href="https://randomnerdtutorials.com/esp32-web-ble-sensor-visualization/" target="_blank">Created by RandomNerdTutorials.com - Read the Complete Project »</a></p>
</div>
<script>
    // DOM Elements
    const connectButton = document.getElementById('connectBleButton');
    const disconnectButton = document.getElementById('disconnectBleButton');
    const bleStateEl = document.getElementById('bleState');
    const bleDot = document.getElementById('bleDot');
  
    const tempValueEl = document.getElementById('tempValue');
    const humValueEl = document.getElementById('humValue');
    const pressValueEl = document.getElementById('pressValue');
    const tempTimestamp = document.getElementById('tempTimestamp');
    const humTimestamp = document.getElementById('humTimestamp');
    const pressTimestamp = document.getElementById('pressTimestamp');
    const tempUnitLabel = document.getElementById('tempUnitLabel');

    // BLE UUIDs
    var deviceName    = 'ESP32';
    var bleService    = '0000181a-0000-1000-8000-00805f9b34fb';
    var tempCharUUID  = '00002a6e-0000-1000-8000-00805f9b34fb';
    var humCharUUID   = '00002a6f-0000-1000-8000-00805f9b34fb';
    var pressCharUUID = '00002a6d-0000-1000-8000-00805f9b34fb';

    // BLE state
    var bleServer;
    var tempCharFound;
    var humCharFound;
    var pressCharFound;
    var userDisconnected = false;

    // Sensor state
    var currentUnit = 'C';
    var lastTempC = null;
    const MAX_POINTS = 60;
    var tempData = { labels: [], valuesC: [], valuesF: [] };
    var humData = { labels: [], values: [] };
    var pressData = { labels: [], values: [] };

    // Charts
    function drawChart(canvasId, color, yLabel) {
        return new Chart(document.getElementById(canvasId), {
            type: 'line',
            data: {
                labels: [],
                datasets: [{
                    label: yLabel,
                    borderColor: color,
                    backgroundColor: color + '18',
                    borderWidth: 2,
                    pointRadius: 3,
                    pointHoverRadius: 5,
                    tension: 0.3,
                    fill: true,
                    data: []
                }]
            },
            options: {
                responsive: true,
                maintainAspectRatio: false,
                animation: { duration: 300 },
                interaction: { mode: 'index', intersect: false },
                scales: {
                    x: {
                        ticks: { font: { size: 11 }, maxRotation: 45, autoSkip: true, maxTicksLimit: 10 },
                        grid: { color: 'rgba(0,0,0,0.05)' }
                    },
                    y: {
                        title: { display: true, text: yLabel, font: { size: 11 } },
                        grid: { color: 'rgba(0,0,0,0.05)' }
                    }
                },
                plugins: { legend: { display: false } }
            }
        });
    }

    var tempChart = drawChart('tempChart', '#e07b3a', 'Temperature (°C)');
    var humChart = drawChart('humChart', '#3a90c4', 'Humidity (%)');
    var pressChart = drawChart('pressChart', '#7c5cbf', 'Pressure (hPa)');

    // Temperature unit toggle
    function setTempUnit(unit) {
        currentUnit = unit;
        document.getElementById('btnC').classList.toggle('active', unit === 'C');
        document.getElementById('btnF').classList.toggle('active', unit === 'F');
        tempUnitLabel.innerHTML = '&nbsp;&deg;' + unit;
        refreshTempDisplay();
        refreshTempChart();
    }

    function cToF(c) {
        return (c * 9 / 5 + 32).toFixed(1);
    }

    function refreshTempDisplay() {
        if (lastTempC === null) return;
        tempValueEl.textContent = currentUnit === 'C'
            ? parseFloat(lastTempC).toFixed(1)
            : cToF(parseFloat(lastTempC));
    }

    function refreshTempChart() {
        tempChart.data.labels = tempData.labels;
        tempChart.data.datasets[0].data = currentUnit === 'C' ? tempData.valuesC : tempData.valuesF;
        tempChart.data.datasets[0].label = currentUnit === 'C' ? 'Temperature (°C)' : 'Temperature (°F)';
        tempChart.options.scales.y.title.text = currentUnit === 'C' ? 'Temperature (°C)' : 'Temperature (°F)';
        tempChart.update();
    }

    function pushPoint(dataObj, chart, label, value, extraValue = null) {
        dataObj.labels.push(label);
        if (extraValue !== null) {
            dataObj.valuesC.push(value);
            dataObj.valuesF.push(extraValue);
        } else {
            dataObj.values.push(value);
        }
        if (dataObj.labels.length > MAX_POINTS) {
            dataObj.labels.shift();
            if (extraValue !== null) {
                dataObj.valuesC.shift();
                dataObj.valuesF.shift();
            } else {
                dataObj.values.shift();
            }
        }
        chart.data.labels = dataObj.labels;
        if (extraValue !== null) {
            chart.data.datasets[0].data = currentUnit === 'C' ? dataObj.valuesC : dataObj.valuesF;
        } else {
            chart.data.datasets[0].data = dataObj.values;
        }
        chart.update('none');
    }

    // BLE UI helpers
    function showConnectButton() {
        connectButton.classList.remove('hidden');
        disconnectButton.classList.add('hidden');
    }

    function showDisconnectButton() {
        connectButton.classList.add('hidden');
        disconnectButton.classList.remove('hidden');
    }

    function setBleConnected(deviceName) {
        bleStateEl.textContent = 'Connected to ' + deviceName;
        bleDot.className = 'ble-dot connected';
        showDisconnectButton();
    }

    function setBleDisconnected(msg) {
        bleStateEl.textContent = msg || 'Disconnected';
        bleDot.className = 'ble-dot';
        showConnectButton();
    }

    function isWebBluetoothEnabled() {
        if (!navigator.bluetooth) {
            setBleDisconnected('Web Bluetooth not available in this browser');
            return false;
        }
        return true;
    }

    // Connect BLE handler
    async function connectToDevice() {
        userDisconnected = false;
        try {
            const device = await navigator.bluetooth.requestDevice({
                filters: [{ name: deviceName }],
                optionalServices: [bleService]
            });

            device.addEventListener('gattserverdisconnected', onDisconnected);
            const gattServer = await device.gatt.connect();
            
            bleServer = gattServer;
            setBleConnected(gattServer.device.name);

            const service = await gattServer.getPrimaryService(bleService);

            await subscribeChar(service, tempCharUUID, handleTemp, v => { tempCharFound = v; });
            await subscribeChar(service, humCharUUID, handleHum, v => { humCharFound = v; });
            await subscribeChar(service, pressCharUUID, handlePress, v => { pressCharFound = v; });

        } catch (err) {
            console.error('Connection error:', err);
            setBleDisconnected('Connection failed: ' + err.message);
        }
    }

    async function subscribeChar(service, uuid, handler, storeFn) {
        try {
            const char = await service.getCharacteristic(uuid);
            storeFn(char);
            
            char.addEventListener('characteristicvaluechanged', handler);
            await char.startNotifications();
            
            try {
                const val = await char.readValue();
                await new Promise(resolve => setTimeout(resolve, 50));
                handler({ target: { value: val } });
            } catch (readErr) {
                console.warn('Initial read failed for', uuid, ':', readErr);
            }
            
            console.log(`Subscribed to ${uuid}`);
            return char;
        } catch (err) {
            console.warn('Could not subscribe to', uuid, ':', err);
            throw err;
        }
    }

    // Characteristic handlers
    function handleTemp(event) {
        var val = parseFloat(new TextDecoder().decode(event.target.value));
        if (isNaN(val)) return;
        lastTempC = val;
        refreshTempDisplay();
        var ts = getTime();
        tempTimestamp.textContent = 'Last reading: ' + ts;
        const valC = parseFloat(val.toFixed(1));
        const valF = parseFloat(cToF(val));
        pushPoint(tempData, tempChart, ts, valC, valF);
    }

    function handleHum(event) {
        var val = parseFloat(new TextDecoder().decode(event.target.value));
        if (isNaN(val)) return;
        humValueEl.textContent = val.toFixed(1);
        var ts = getTime();
        humTimestamp.textContent = 'Last reading: ' + ts;
        pushPoint(humData, humChart, ts, parseFloat(val.toFixed(1)));
    }

    function handlePress(event) {
        var val = parseFloat(new TextDecoder().decode(event.target.value));
        if (isNaN(val)) return;
        pressValueEl.textContent = val.toFixed(1);
        var ts = getTime();
        pressTimestamp.textContent = 'Last reading: ' + ts;
        pushPoint(pressData, pressChart, ts, parseFloat(val.toFixed(1)));
    }

    // Disconnect handlers
    function onDisconnected(event) {
        if (!userDisconnected) {
            setBleDisconnected('Device disconnected unexpectedly');
        }
    }

    function disconnectDevice() {
        if (!bleServer || !bleServer.connected) {
            alert('Bluetooth is not connected.');
            return;
        }
        userDisconnected = true;
        const stops = [];
        if (tempCharFound) stops.push(tempCharFound.stopNotifications().catch(() => {}));
        if (humCharFound) stops.push(humCharFound.stopNotifications().catch(() => {}));
        if (pressCharFound) stops.push(pressCharFound.stopNotifications().catch(() => {}));
        
        Promise.all(stops)
            .then(() => bleServer.disconnect())
            .then(() => setBleDisconnected('Disconnected'))
            .catch(err => console.error('Disconnect error:', err));
    }

    function getTime() {
        var d = new Date();
        var p = n => ('00' + n).slice(-2);
        return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
    }

    // Button listeners
    connectButton.addEventListener('click', () => {
        if (isWebBluetoothEnabled()) connectToDevice();
    });
    disconnectButton.addEventListener('click', disconnectDevice);
</script>
</body>
</html>

View raw code

How does it work?

This HTML and JavaScript code creates a web application that allows you to connect to an ESP32 device over Bluetooth Low Energy (BLE). The application provides a user interface to display the data “sent” by the ESP32.

Let’s take a look at the relevant parts of the HTML file that create the elements on our page.

We first create a card that includes the Connect/Disconnect buttons and a paragraph to display the BLE connection state.

ESP32 Web BLE App: Connect/Disconnect Device
<!-- Connect/Disconnect buttons -->
<div class="connection-card">
    <button id="connectBleButton" class="connectButton">&#x1F4F6;&nbsp; Connect to BLE device</button>
    <button id="disconnectBleButton" class="disconnectButton hidden">&#x274C;&nbsp; Disconnect</button>
    <div class="ble-status">
        <span class="ble-dot" id="bleDot"></span>
        <span id="bleState">Disconnected</span>
    </div>
</div>

Then, we have cards to display the current sensor readings and the corresponding timestamps.

ESP32 Web BLE App - Display Sensor Data
<div class="sensor-grid">
(...)
</div>

This is the card to display the temperature. Notice that the place where we’ll display the actual temperature has the id tempValue, and the place to display the timestamp has the tempTimestamp id. This is important in the JavaScript section of the code.

<div class="sensor-card temp">
    <span class="sensor-icon">&#x1F321;</span>
    <div class="sensor-label">Temperature</div>
    <div class="sensor-value">
        <span id="tempValue">--</span><span class="sensor-unit" id="tempUnitLabel">&nbsp;&deg;C</span>
    </div>
    <div class="sensor-timestamp" id="tempTimestamp">No reading</div>
    <div class="unit-toggle">
        <button class="unit-btn active" id="btnC" onclick="setTempUnit('C')">&deg;C</button>
        <button class="unit-btn" id="btnF" onclick="setTempUnit('F')">&deg;F</button>
    </div>
</div>

In this particular card, we also add a toggle switch that allows us to change the temperature values between Celsius and Fahrenheit.

<div class="sensor-timestamp" id="tempTimestamp">No reading</div>
<div class="unit-toggle">
    <button class="unit-btn active" id="btnC" onclick="setTempUnit('C')">&deg;C</button>
    <button class="unit-btn" id="btnF" onclick="setTempUnit('F')">&deg;F</button>
</div>

This is the section to display the humidity reading. The place where the humidity value will be displayed has the humValue id and the humTimestamp for the timestamp.

<div class="sensor-card hum">
    <span class="sensor-icon">&#x1F4A7;</span>
    <div class="sensor-label">Humidity</div>
    <div class="sensor-value">
        <span id="humValue">--</span><span class="sensor-unit">&nbsp;%</span>
    </div>
    <div class="sensor-timestamp" id="humTimestamp">No reading</div>
</div>

And this is the section to display the pressure reading with the pressValue and pressTimestamp ids for the pressure reading and the corresponding timestamp.

<div class="sensor-card press">
    <span class="sensor-icon">&#8595;</span>
    <div class="sensor-label">Pressure</div>
    <div class="sensor-value">
        <span id="pressValue">--</span><span class="sensor-unit">&nbsp;hPa</span>
    </div>
    <div class="sensor-timestamp" id="pressTimestamp">No reading</div>
</div>

We’ll also display the timestamp of when the value was received in the following paragraph.

<p>Last reading: <span id="timestamp"></span></p>

Finally, we have a section for the charts. We’ll create the charts using the Chart.js library. The places to display the charts have the following ids: tempChart, humChart, and pressChart.

<!-- Charts -->
<div class="charts-section">
    <div class="chart-card">
        <div class="chart-card-header">
           <h2>&#x1F321;&nbsp; Temperature</h2>
        </div>
        <div class="chart-wrapper">
            <canvas id="tempChart" role="img"></canvas>
        </div>
    </div>
    <div class="chart-card">
        <div class="chart-card-header">
            <h2>&#x1F4A7;&nbsp; Humidity</h2>
        </div>
        <div class="chart-wrapper">
            <canvas id="humChart" role="img"></canvas>
        </div>
    </div>
    <div class="chart-card">
        <div class="chart-card-header">
            <h2>&#8595;&nbsp; Pressure</h2>
        </div>
        <div class="chart-wrapper">
            <canvas id="pressChart" role="img"></canvas>
        </div>
    </div>
  </div>
ESP32 Web BLE App - Display Sensor Readings

JavaScript – Web BLE

Next, inside the <script></script> tags, we have the JavaScript code responsible for connecting/disconnecting with the BLE client and displaying the data.

DOM Elements

First, we select the HTML elements and assign them to a variable name for easier manipulation throughout the code.

// DOM Elements
const connectButton    = document.getElementById('connectBleButton');
const disconnectButton = document.getElementById('disconnectBleButton');
const bleStateEl       = document.getElementById('bleState');
const bleDot           = document.getElementById('bleDot');

const tempValueEl    = document.getElementById('tempValue');
const humValueEl     = document.getElementById('humValue');
const pressValueEl   = document.getElementById('pressValue');
const tempTimestamp  = document.getElementById('tempTimestamp');
const humTimestamp   = document.getElementById('humTimestamp');
const pressTimestamp = document.getElementById('pressTimestamp');
const tempUnitLabel  = document.getElementById('tempUnitLabel');

BLE UUIDs

Then, we have the BLE device name and the BLE UUIDs for the environmental sensing service and temperature, humidity, and pressure characteristics. This is the data we’ll be looking for in the ESP32 Client GATT.

var deviceName          = 'ESP32';
var bleService          = '0000181a-0000-1000-8000-00805f9b34fb';
var tempCharUUID        = '00002a6e-0000-1000-8000-00805f9b34fb';
var humCharUUID         = '00002a6f-0000-1000-8000-00805f9b34fb';
var pressCharUUID       = '00002a6d-0000-1000-8000-00805f9b34fb';

Note: in this code, we’re still using the official UUIDs for the service and characteristics we’re looking for, but we’re using the extended UUIDs.

We found that shortened UUIDs didn’t work in the Bluefy app (I’m not sure why. If you have any ideas, please let us know). In any case, the shortened UUIDs worked well in our Chrome (Windows) web browser. If you want to use the shortened UUIDs, use the following lines instead.

var bleService          = 0x181A;
var tempCharUUID        = 0x2A6E;
var humCharUUID         = 0x2A6F;
var pressCharUUID       = 0x2A6D;

Defining Global Variables

Then, we create global variables to handle Bluetooth communication and device discovery later in our code.

// BLE state
var bleServer;
var tempCharFound;
var humCharFound;
var pressCharFound;
var userDisconnected = false;

We also create variables to handle sensor data. We’re using objects tempData, humData, and pressData to hold data related to each reading.

// Sensor state
var currentUnit = 'C';
var lastTempC   = null;
const MAX_POINTS = 60;
var tempData  = { labels: [], valuesC: [], valuesF: [] };
var humData   = { labels: [], values: [] };
var pressData = { labels: [], values: [] };

Creating the Charts

The drawChart() function will create the charts. It accepts as arguments the canvasId (the ID of the HTML element where we’ll place the chart), color, and the label for the y-axis.

function drawChart(canvasId, color, yLabel) {
    return new Chart(document.getElementById(canvasId), {
        type: 'line',
        data: {
            labels: [],
            datasets: [{
                label: yLabel,
                borderColor: color,
                backgroundColor: color + '18',
                borderWidth: 2,
                pointRadius: 3,
                pointHoverRadius: 5,
                tension: 0.3,
                fill: true,
                data: []
            }]
        },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            animation: { duration: 300 },
            interaction: { mode: 'index', intersect: false },
            scales: {
                x: {
                    ticks: { font: { size: 11 }, maxRotation: 45, autoSkip: true, maxTicksLimit: 10 },
                    grid: { color: 'rgba(0,0,0,0.05)' }
                },
                y: {
                    title: { display: true, text: yLabel, font: { size: 11 } },
                    grid: { color: 'rgba(0,0,0,0.05)' }
               }
            },
            plugins: { legend: { display: false } }
        }
    });
}

To learn more about creating charts and how to change their properties and looks, check the charts.js documentation here.

Then, we call the drawChart() function to create the three charts.

var tempChart  = drawChart('tempChart',  '#e07b3a', 'Temperature (°C)');
var humChart   = drawChart('humChart',   '#3a90c4', 'Humidity (%)');
var pressChart = drawChart('pressChart', '#7c5cbf', 'Pressure (hPa)');

Temperature Units

The following section handles changing the unit of the temperature values. We need to get the desired unit selection and handle displaying the unit on the cards (setTempUnit() function).

// Temperature unit toggle
function setTempUnit(unit) {
    currentUnit = unit;
    document.getElementById('btnC').classList.toggle('active', unit === 'C');
    document.getElementById('btnF').classList.toggle('active', unit === 'F');
    tempUnitLabel.innerHTML = '&nbsp;&deg;' + unit;
    refreshTempDisplay();
    refreshTempChart();
}

The function that actually converts the values (cToF() with one decimal value):

function cToF(c) { return (c * 9 / 5 + 32).toFixed(1); }

A function to refresh the temperature card:

function refreshTempDisplay() {
    if (lastTempC === null) return;
    tempValueEl.textContent = currentUnit === 'C'
        ? parseFloat(lastTempC).toFixed(1)
        : cToF(parseFloat(lastTempC));
}

And a function to refresh the temperature chart when we change the temperature unit.

function refreshTempChart() {
    tempChart.data.labels                 = tempData.labels;
    tempChart.data.datasets[0].data       = currentUnit === 'C' ? tempData.valuesC : tempData.valuesF;
    tempChart.data.datasets[0].label      = currentUnit === 'C' ? 'Temperature (°C)' : 'Temperature (°F)';
    tempChart.options.scales.y.title.text = currentUnit === 'C' ? 'Temperature (°C)' : 'Temperature (°F)';
    tempChart.update();
}

Add Data to the Charts

The pushPoint() function will add data to the data objects we created previously (tempData, humData, and pressData) to a maximum of 60 points (defined in the MAX_POINTS variable). Then, it adds the new data values to the charts.

function pushPoint(dataObj, chart, label, value, extraValue = null) {
    dataObj.labels.push(label);
    if (extraValue !== null) {
        dataObj.valuesC.push(value);
        dataObj.valuesF.push(extraValue);
    } else {
        dataObj.values.push(value);
    }
    if (dataObj.labels.length > MAX_POINTS) {
        dataObj.labels.shift();
        if (extraValue !== null) {
            dataObj.valuesC.shift();
            dataObj.valuesF.shift();
        } else {
            dataObj.values.shift();
        }
    }
    chart.data.labels = dataObj.labels;
    if (extraValue !== null) {
        chart.data.datasets[0].data = currentUnit === 'C' ? dataObj.valuesC : dataObj.valuesF;
    } else {
        chart.data.datasets[0].data = dataObj.values;
    }
    chart.update('none');
}

We’ll call this function later when we are notified of new characteristic values.

Assigning Events to Buttons

Almost at the end of the code, there’s a section where we add event listeners to the buttons to trigger actions when they are clicked (we’ll see the rest of the code later).

Connect To BLE Device Button

The Connect To BLE Device button will trigger the connectToDevice() function. But first, we check if the Web BLE JavaScript API is available in your browser before proceeding, and we display a message on the bleStateContainer in case Web BLE is not supported.

connectButton.addEventListener('click', () => {
    if (isWebBluetoothEnabled()) connectToDevice();
});

To check if the Web Bluetooth is enabled in your browser, we created a function called isWebBluetoothEnabled(). The method that checks if Web BLE is enabled is nagivator.bluetooth.

// Web Bluetooth availability check
function isWebBluetoothEnabled() {
    if (!navigator.bluetooth) {
        setBleDisconnected('Web Bluetooth not available in this browser');
        return false;
    }
    return true;
}

Disconnect BLE Device Button

The Disconnect BLE Device Button will call the disconnectDevice function.

disconnectButton.addEventListener('click', disconnectDevice);

Connecting to BLE Device and Searching Services and Characteristics

The connectToDevice() function is triggered when you click on the Connect to BLE Device button. This function searches for BLE Device, the service, and the characteristics we have defined previously.

function connectToDevice(){

The following lines of code search for BLE Devices with the service and BLE name we defined.

try {
    const device = await navigator.bluetooth.requestDevice({
    filters: [{ name: deviceName }],
    optionalServices: [bleService]
});

Once we’ve connected to a device, we add an event listener to our device, in case it disconnects (it will call the onDisconnected function).

device.addEventListener('gattserverdisconnected', onDisconnected);

From the device, we can get our GATT server (the hierarchical structure that stores data in the BLE protocol). We save our GATT server in our bleServer global variable. From the GATTT server, we can get the service with the UUID we’ve defined at the beginning of the code, service.

 bleServer = gattServer;
 setBleConnected(gattServer.device.name);

 const service = await gattServer.getPrimaryService(bleService);

Then, we’ll call the subscribeChar() function that will listen to new data whenever the characteristic value changes.

await subscribeChar(service, tempCharUUID, handleTemp, v => { tempCharFound = v; });
await subscribeChar(service, humCharUUID, handleHum, v => { humCharFound = v; });
await subscribeChar(service, pressCharUUID, handlePress, v => { pressCharFound = v; });

When we find our sensor characteristics, we assign them to the corresponding temCharFound, humCharFound, and pressCharFound variables. We add an event listener to our characteristics to handle what happens when the characteristic values change. We call the handleTemp, handleHum, or handlePres functions. We also start notifications on those characteristics. Finally, we return the current value written on the characteristic.

async function subscribeChar(service, uuid, handler, storeFn) {
    try {
        const char = await service.getCharacteristic(uuid);
        storeFn(char);
            
        char.addEventListener('characteristicvaluechanged', handler);
        await char.startNotifications();            
        try {
            const val = await char.readValue();
            await new Promise(resolve => setTimeout(resolve, 50));
            handler({ target: { value: val } });
        } catch (readErr) {
            console.warn('Initial read failed for', uuid, ':', readErr);
        }
            
        console.log(`Subscribed to ${uuid}`);
        return char;
    } catch (err) {
        console.warn('Could not subscribe to', uuid, ':', err);
        throw err;
    }
}

Handle Characteristic Change

The handleTemp(), handleHum(), and handlePres() functions will be called when the corresponding characteristic value changes. These functions are very similar. They get the characteristic value, the timestamp and push the new data to the corresponding data objects. They also update the timestamp HTML element with the time.

function handleTemp(event) {
    var val = parseFloat(new TextDecoder().decode(event.target.value));
    if (isNaN(val)) return;
    lastTempC = val;
    refreshTempDisplay();
    var ts = getTime();
    tempTimestamp.textContent = 'Last reading: ' + ts;
    const valC = parseFloat(val.toFixed(1));
    const valF = parseFloat(cToF(val));
    pushPoint(tempData, tempChart, ts, valC, valF);
}

function handleHum(event) {
    var val = parseFloat(new TextDecoder().decode(event.target.value));
    if (isNaN(val)) return;
    humValueEl.textContent = val.toFixed(1);
    var ts = getTime();
    humTimestamp.textContent = 'Last reading: ' + ts;
    pushPoint(humData, humChart, ts, parseFloat(val.toFixed(1)));
}

function handlePress(event) {
    var val = parseFloat(new TextDecoder().decode(event.target.value));
    if (isNaN(val)) return;
    pressValueEl.textContent = val.toFixed(1);
    var ts = getTime();
    pressTimestamp.textContent = 'Last reading: ' + ts;
    pushPoint(pressData, pressChart, ts, parseFloat(val.toFixed(1)));
}

Disconnect BLE Device

When you click on the Disconnect BLE Device button, the disconnectDevice() function is called.

disconnectButton.addEventListener('click', disconnectDevice);

This function first checks if we’re connected to the server. Then, we stop notifications on the sensorCharacteristic, and disconnect from the GATT server.

function disconnectDevice() {
    if (!bleServer || !bleServer.connected);
        alert('Bluetooth is not connected.');
        return;
    }
    userDisconnected = true;
    const stops = [];
    if (tempCharFound) stops.push(tempCharFound.stopNotifications().catch(() => {}));
    if (humCharFound) stops.push(humCharFound.stopNotifications().catch(() => {}));
    if (pressCharFound) stops.push(pressCharFound.stopNotifications().catch(() => {}));
        
    Promise.all(stops)
        .then(() => bleServer.disconnect())
        .then(() => setBleDisconnected('Disconnected'))
        .catch(err => console.error('Disconnect error:', err));
}

Styling the Web BLE App (style.css)

In the same folder as the index.html file, create a new file called style.css with the following code to style the app.

* {
    box-sizing: border-box;
}

html, body {
    font-family: Arial, Helvetica, sans-serif;
    margin: 0;
    padding: 0;
    background-color: #f0f2f5;
}

.hidden {
    display: none !important;
}

.topnav {
    overflow: hidden;
    background-color: #0A1128;
    padding: 0 20px;
    display: flex;
    align-items: center;
    justify-content: center;
}

.topnav h1 {
    font-size: 1.5rem;
    color: white;
    margin: 0;
    padding: 16px 0;
    text-align: center;
}

.content {
    padding: 24px 20px;
    max-width: 1100px;
    margin: 0 auto;
}

.connection-card {
    background: white;
    border-radius: 10px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.07);
    padding: 20px 24px;
    margin-bottom: 24px;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-wrap: wrap;
    gap: 16px;
}

.ble-status {
    display: flex;
    align-items: center;
    gap: 8px;
    background: #f1f1f1;
    border-radius: 20px;
    padding: 6px 14px;
    font-size: 0.85rem;
    color: #555;
}

.ble-dot {
    width: 9px;
    height: 9px;
    border-radius: 50%;
    background-color: #d13a30;
    transition: background-color 0.4s;
}

.ble-dot.connected {
    background-color: #24af37;
}

button {
    color: white;
    padding: 11px 20px;
    border: none;
    cursor: pointer;
    border-radius: 6px;
    font-size: 0.9rem;
    font-weight: 600;
    transition: opacity 0.15s, transform 0.1s;
}

button:hover {
    opacity: 0.88;
}

button:active {
    transform: scale(0.97);
}

.connectButton    { background-color: #24af37; }
.disconnectButton { background-color: #d13a30; }

.sensor-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
    gap: 16px;
    margin-bottom: 24px;
}

.sensor-card {
    background: white;
    border-radius: 10px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.07);
    padding: 20px 24px;
    text-align: center;
}

.sensor-card .sensor-icon {
    font-size: 2rem;
    margin-bottom: 6px;
    display: flex;
    align-items: center;
    justify-content: center;
    height: 2.4rem;
}

.sensor-card .sensor-label {
    font-size: 1.1rem;
    color: #999;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    margin-bottom: 6px;
}

.sensor-card .sensor-value {
    font-size: 2.4rem;
    font-weight: 700;
    line-height: 1;
    margin-bottom: 4px;
}

.sensor-card .sensor-unit {
    font-size: 1rem;
    font-weight: 400;
    color: #888;
}

.sensor-card .sensor-timestamp {
    font-size: 0.75rem;
    color: #bbb;
    margin-top: 8px;
}

.sensor-card.temp  .sensor-value { color: #e07b3a; }
.sensor-card.hum   .sensor-value { color: #3a90c4; }
.sensor-card.press .sensor-value { color: #7c5cbf; }

.unit-toggle {
    display: inline-flex;
    background: #f1f1f1;
    border-radius: 50px;
    padding: 3px;
    margin-top: 10px;
}

.unit-btn {
    padding: 4px 14px;
    font-size: 0.78rem;
    font-weight: 600;
    border: none;
    border-radius: 50px;
    cursor: pointer;
    background: transparent;
    color: #666;
    transition: all 0.2s;
}

.unit-btn.active {
    background: #0A1128;
    color: white;
}

.charts-section {
    display: flex;
    flex-direction: column;
    gap: 20px;
    margin-bottom: 24px;
}

.chart-card {
    background: white;
    border-radius: 10px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.07);
    padding: 20px 24px;
}

.chart-card-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 16px;
    flex-wrap: wrap;
    gap: 10px;
}

.chart-card-header h2 {
    margin: 0;
    font-size: 1.5rem;
    color: #333;
    display: flex;
    align-items: center;
}

.chart-wrapper {
    position: relative;
    height: 220px;
}

.footer {
    text-align: center;
    padding: 22px;
    font-size: 1.1rem;
    color: #aaa;
}

.footer a {
    color: #1b8a94;
    text-decoration: none;
}

@media (max-width: 600px) {
    .topnav { padding: 12px 16px; }
    .content { padding: 16px 12px; }
}

View raw code

favicon

Additionally, add the following favicon to your folder.

Testing the Web BLE App

After creating all the required files in the same folder, drag the index.html file to your web browser.

The following page will open.

ESP32 Web BLE Sensor App

This will work both on Windows and iOS computers. In this case, it works because the web browser running the web page is on the localhost.

With the ESP32 running the code we’ve provided previously, let’s test the web app.

Start by clicking on the Connect to BLE Device button. A window will pop up, and you should see the ESP32 BLE Device. Connect to that device.

Web BLE App - connect to ESP32

You’ll see that the BLE Status will change to connected, and you’ll start receiving the values written by the ESP32 on the temperature, humidity, and pressure characteristics.

Web BLE App Connected to ESP32

Simultaneously, you should get the following messages in the Arduino Serial Monitor showing that the connection was successful.

ESP32 BLE Client Connected

Getting back to the app. It displays the most recent reading and corresponding timestamp on the cards.

ESP32 Web BLE App - Display Sensor Data

And a chart for each characteristic. The chart is automatically updated as new sensor values are written to the characteristics.

ESP32 Web BLE App - Display Sensor Readings

Hosting your Web BLE App

With the current setup, you can only connect to the ESP32 via BLE by opening the index.html file in your web browser of your computer. If you want to open it on your smartphone or any other device you would need to copy that file to the device and then, open it on the web browser. This is not very convenient.

The best way to have access to your web app on any device is to host your files on a server. To work with BLE, the files need to be served via HTTPS.

In this case, it will not work on iOS devices. You’ll need to use the Bluefy app (Web BLE browser) that we talked about previously.

To host our web app, we’ll use GitHub pages. If you don’t have a GitHub account, create one before proceeding.

1. On your GitHub account dashboard, click on the + icon and create a New repository.

GitHub Create a New Repository for project

2. The following page will load. Give a name to your repository, and make sure it is set to Public. Then, click on the Create repository button.

GitHub Add Repository Name and Set Repository to Public Create new

3. Click on the uploading an existing file link.

GitHub upload files to repository

4. Drag your index.html, style.css, and favicon.ico files to the repository. Then, click the Commit changes button.

GitHub files uploaded commit changes

5. Next, go to Settings > Pages and make sure you have the options highlighted in red below. Finally, click the Save button.

GitHub Set GitHub pages and published them

After submitting, wait a few minutes for the web page to be available. Your web app will be in the following domain:

YOUR_GITHUB_USERNAME.github.io/YOUR_REPOSITORY_NAME

In our case, it is available on the following web page: https://ruisantosdotme.github.io/esp32-web-ble-sensor/

https://ruisantosdotme.github.io/esp32-web-ble-sensor/

Demonstration

Now, you can access your Web BLE App on any device (that supports Web BLE) with a web browser by going to that URL. Then, you can connect to the ESP32 via BLE using that device and see the BME280 temperature, humidity, and pressure live data. You easily modify this project to display data from any other sensor, like a heart rate sensor.

ESP32 Web BLE App Smartphone - connect to device
Web BLE App on Smartphone connected to ESP32
ESP32 Web BLE App - Display Sensor Data on Charts

If you’re using an iOS device, you need to open the Bluefy app and then insert your app URL so that you can use Web BLE.

Wrapping Up

In this tutorial, you learned about the Web BLE technology. In simple terms, it is a JavaScript API that allows us to create web apps to interact with BLE devices from any web browser that supports Web BLE.

You learned how to set the ESP32 as a BLE device with the Environmental Sensing Service with temperature, humidity, and pressure characteristics.

We hope you’ve found this tutorial useful. We have other guides about ESP32 and Bluetooth that you may like:

Do you want to learn more about the ESP32? Check out all our resources:

Thanks for reading



Learn how to build a home automation system and we’ll cover the following main subjects: Node-RED, Node-RED Dashboard, Raspberry Pi, ESP32, ESP8266, MQTT, and InfluxDB database DOWNLOAD »
Learn how to build a home automation system and we’ll cover the following main subjects: Node-RED, Node-RED Dashboard, Raspberry Pi, ESP32, ESP8266, MQTT, and InfluxDB database DOWNLOAD »

Enjoyed this project? Stay updated by subscribing our newsletter!

Leave a Comment

Download Our Free eBooks and Resources

Get instant access to our FREE eBooks, Resources, and Exclusive Electronics Projects by entering your email address below.