Перевод статьи ESP32 Web BLE: Live Sensor Data Visualization (BME280 Charts)Rui Santos & Sara Santos, Random Nerd Tutorials.

В этом проекте мы покажем, как с помощью Web Bluetooth (Web BLE) выводить показания датчика прямо в браузере, будучи подключённым к ESP32 по BLE. Web Bluetooth (Web BLE) позволяет подключаться к BLE-устройствам вроде ESP32 и управлять ими прямо из браузера — на JavaScript.

Web BLE поддерживается только на устройствах Android, компьютерах с Windows и macOS. Рекомендуем браузер Google Chrome. В iOS он не поддерживается, но там можно воспользоваться приложением вроде Bluefy, эмулирующим BLE-браузер (как им пользоваться, тоже покажем).

Что нужно заранее#

1. Подготовка Arduino IDE#

Программировать ESP32 будем из Arduino IDE. Перед началом убедитесь, что поддержка плат ESP32 установлена. Если ещё нет — сначала пройдите вот это руководство:

Установка библиотек#

В этом руководстве мы используем датчик BME280 и библиотеку Adafruit_BME280. Установите её перед началом.

В Arduino IDE зайдите в Sketch > Include Library > Manage Libraries…, найдите по названию Adafruit BME280 и установите. Ещё нужно поставить Adafruit Unified Sensor Library (если она ещё не установлена, всплывёт окно с предложением её поставить).

Установка библиотеки Adafruit BME280 в Arduino IDE

Ставим Adafruit BME280 через менеджер библиотек.

2. Что понадобится#

Подойдёт любая плата ESP32 на ваш выбор.

Плата ESP32, подключённая к датчику BME280

ESP32 с подключённым датчиком BME280.

В качестве датчика возьмём BME280 — он будет отдавать по BLE показания температуры, влажности и давления. С небольшими правками в коде можно использовать любой другой датчик.

3. Схема подключения#

Подключите датчик BME280 к ESP32. Мы используем GPIO 21 и 22 под SDA и SCL. Можно взять любые другие GPIO, если соответственно поправить код.

Схема подключения ESP32 к BME280

Схема подключения BME280 к ESP32.

4. Установка приложения Bluefy (для устройств iOS)#

Web Bluetooth API даёт возможность подключаться к периферийным BLE-устройствам (вроде ESP32) и взаимодействовать с ними. API всё ещё в разработке, но в целом считается стабильным и рабочим. Он реализован в Chrome, Edge, Opera (Android) и поддерживается на Android и Windows. Однако в iOS он пока не поддерживается.

Проверить совместимость браузера/устройства можно в документации Web Bluetooth API.

Если у вас устройство на iOS, повторить проект всё равно можно — установите приложение Bluefy, это BLE-браузер на веб-основе:

Приложение Bluefy — BLE-браузер для iOS

Приложение Bluefy.

Web BLE — краткое введение#

Web Bluetooth (его же иногда называют Web BLE) — это технология, позволяющая подключаться к BLE-устройствам вроде ESP32 и управлять ими прямо из браузера на JavaScript.

С Web BLE можно писать веб-приложения, взаимодействующие с ESP32 по Bluetooth: управлять пинами GPIO, обмениваться данными и удалённо управлять устройствами через веб-интерфейс — с любого устройства, где есть браузер (компьютер или смартфон*).

Как работает Web Bluetooth с ESP32

Браузер общается с ESP32 по BLE.

* кроме устройств iOS. В этом случае можно воспользоваться приложением вроде Bluefy – Web BLE Browser.

Одно из главных преимуществ Web Bluetooth в том, что он работает на разных устройствах и операционных системах через совместимый браузер — без установки отдельного приложения.

Значит, подключаться к ESP32 и управлять им через Web-BLE-приложение в браузере можно со смартфона, планшета или настольного компьютера.

Материалы по Web BLE и BLE#

Если вы впервые сталкиваетесь с Web BLE и BLE на ESP32, советуем сначала бегло просмотреть эти руководства, чтобы освоиться с базовыми понятиями Bluetooth Low Energy:

Обзор проекта#

Прежде чем двигаться дальше, коротко о том, что мы соберём:

  • ESP32 выступает в роли BLE-периферии (peripheral) и объявляет о своём присутствии;
  • ваш браузер выступает в роли BLE-контроллера, который получает показания датчика (читает характеристики ESP32).
Простая демонстрация Web BLE с ESP32

ESP32 — периферия, браузер — контроллер.

  • GATT-структура ESP32 содержит один сервис (сервис измерения параметров окружающей среды, Environmental Sensing Service: UUID 181A) с тремя характеристиками (температура: UUID 2A6E, влажность: UUID 2A6F, давление: UUID 2A6D).
GATT сервиса измерения параметров среды у ESP32

GATT: один сервис и три характеристики.

  • ESP32 постоянно читает датчик BME280 и записывает новые значения в характеристики температуры, влажности и давления;
  • браузер подключается к Bluetooth-устройству ESP32 и получает уведомления (notifications) при каждом изменении значения характеристики;
  • он читает эти характеристики и обновляет веб-страницу свежими данными;
  • страница показывает текущие показания на карточках, а последние значения — на графиках.
Web-BLE-приложение ESP32 — графики температуры и влажности

Показания на карточках и графиках.

BLE-устройство ESP32 — код для Arduino#

Следующий код превращает ESP32 в BLE-устройство с сервисом измерения параметров среды и тремя характеристиками: температура, влажность и давление.

Залейте этот код на плату — он заработает сразу.

/*
  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.");
  }
}

Исходник скетча на GitHub.

Как работает код#

Если коротко, этот код поднимает BLE-сервер с сервисом измерения параметров среды и тремя характеристиками: температура, влажность и давление. Когда BLE-клиент подключается, он может читать данные датчика. Код также обрабатывает события подключения и отключения устройства.

Подключение библиотек#

Сначала нужно подключить библиотеки для работы с Bluetooth.

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

А эти — для подключения к датчику BME280 и чтения с него:

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

BME280#

Задаём пины, к которым подключён BME280. Мы используем GPIO 21 и 22.

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

Создаём экземпляр Adafruit_BME280 с именем bme.

Adafruit_BME280 bme;

Определения UUID#

Затем задаём UUID для сервиса и характеристик. Официальные UUID, определённые в SIG для сервиса измерения параметров среды и характеристик температуры, влажности и давления, таковы:

// 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-переменные#

Дальше создаём несколько глобальных переменных, которые пригодятся в коде позже.

// BLE variables
BLEServer*         pServer            = nullptr;
BLECharacteristic* pTempChar          = nullptr;
BLECharacteristic* pHumChar           = nullptr;
BLECharacteristic* pPressChar         = nullptr;
bool               deviceConnected    = false;
bool               oldDeviceConnected = false;
  • pServer — указатель на объект BLEServer;
  • pTempChar — указатель на BLECharacteristic для показания температуры;
  • pHumChar — указатель на BLECharacteristic для показания влажности;
  • pPressChar — указатель на BLECharacteristic для показания давления;
  • deviceConnected — логическая переменная, отслеживающая, подключено ли BLE-устройство;
  • oldDeviceConnected — логическая переменная, хранящая предыдущее состояние подключения.

BLE-сервер и колбэки#

Затем создаём несколько колбэк-функций. ServerCallbacks задаёт колбэк для событий подключения и отключения устройства. Здесь мы меняем значение переменной deviceConnected на true или false в зависимости от состояния подключения.

// 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)#

Функция notifyFloat() записывает значение типа float в конкретную BLECharacteristic. Поскольку все наши значения — это float с несколькими знаками после запятой, сначала мы округляем их до одного знака.

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

Задаём значение характеристики.

pChar->setValue(buf);

А затем уведомляем подключённого клиента, что характеристика изменилась.

pChar->notify();

setup()#

В setup() инициализируем последовательную связь для отладки.

Serial.begin(115200);

Инициализируем датчик BME280.

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

Инициализируем ESP32 как BLE-устройство с именем ESP32. Имя может быть любым.

BLEDevice::init("ESP32");

Затем создаём BLE-сервер и задаём ему колбэки для подключения и отключения.

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

Создаём BLE-сервис с UUID, который задали ранее.

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

Создаём характеристики температуры, влажности и давления (внутри только что созданного сервиса) и задаём их свойства (read и notify). Ко всем характеристикам добавляем BLE-дескрипторы (BLE2902).

// 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());

Обратите внимание: BLE2902 — это особый дескриптор, который часто используют для Client Characteristic Configuration (CCC). CCC-дескрипторы задают, как клиент (подключающееся к серверу устройство) хочет получать уведомления (notify) или индикации (indicate) об изменениях значения характеристики. Проще говоря, они управляют тем, получит ли клиент уведомление при изменении значения связанной характеристики.

Добавляя дескрипторы BLE2902 к характеристикам, вы позволяете клиентам настраивать, как именно они хотят получать обновления при изменении значений. Клиенты могут через эти дескрипторы включать и выключать уведомления или индикации — в зависимости от своих потребностей в обновлениях от сервера ESP32 в реальном времени.

Задаём начальные значения характеристик равными 0.0.

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

Запускаем BLE-сервис.

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

И, наконец, настраиваем параметры вещания (advertising) и запускаем его.

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

loop()#

В loop() каждые 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);

Если BLE-устройство подключено (deviceConnected равно true), отправляем клиенту новые показания.

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

Если устройство отключается, а oldDeviceConnected равно true, код перезапускает вещание и пишет сообщение. Если же устройство подключается, а oldDeviceConnected равно false, код тоже пишет сообщение (сюда можно добавить свою логику действий при подключении).

// 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.");
}

Заливка кода#

Залейте код на плату ESP32. После заливки откройте монитор порта и перезагрузите плату.

Вы увидите, что BLE-сервис инициализирован и ждёт подключения клиента.

BLE-устройство ESP32 с BME280 ждёт подключения

ESP32 ждёт подключения BLE-клиента.

Создаём Web-BLE-приложение#

Теперь, когда ESP32 настроен как BLE-устройство, соберём веб-приложение, чтобы взаимодействовать с ESP32 по BLE прямо из браузера.

Web-BLE-приложение ESP32 — сервис измерения параметров среды

Готовое веб-приложение.

Как вариант, можно воспользоваться нашим готовым веб-приложением по этому адресу:

HTML-страница#

Создайте HTML-файл index.html со следующим кодом (в нём и HTML для построения страницы, и JavaScript для работы с 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/chart.js@4.4.1/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>

Исходник index.html на GitHub.

Стили Web-BLE-приложения (style.css)#

Создайте файл style.css со следующим содержимым и положите его в ту же папку, что и index.html.

* {
    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; }
}

Исходник style.css на GitHub.

favicon#

Дополнительно положите в папку файл favicon:

Тестирование Web-BLE-приложения#

Создав все нужные файлы в одной папке, перетащите index.html в браузер.

Откроется вот такая страница.

Web-BLE-приложение ESP32 в браузере

Приложение, открытое из локального файла.

Это работает и на Windows, и на компьютерах с iOS. Здесь всё получается потому, что браузер, открывший страницу, работает с localhost.

Пусть ESP32 крутит показанный ранее код — протестируем веб-приложение.

Начните с кнопки Connect to BLE Device. Всплывёт окно, в котором вы увидите BLE-устройство ESP32. Подключитесь к нему.

Подключение Web-BLE-приложения к ESP32

Выбираем устройство ESP32.

Статус BLE сменится на connected, и вы начнёте получать значения, которые ESP32 пишет в характеристики температуры, влажности и давления.

Web-BLE-приложение подключено к ESP32

Приложение подключено, идут показания.

Одновременно в мониторе порта Arduino появятся сообщения об успешном подключении.

BLE-клиент подключён к ESP32 — монитор порта

ESP32 сообщает о подключении клиента.

Вернёмся к приложению. Оно показывает самое свежее показание и соответствующую метку времени на карточках.

Web-BLE-приложение ESP32 показывает данные датчика на карточках

Текущие показания на карточках.

И график для каждой характеристики. График автоматически обновляется по мере записи новых значений в характеристики.

Web-BLE-приложение ESP32 показывает показания датчика на графиках

Живые графики температуры, влажности и давления.

Размещение Web-BLE-приложения в сети#

Пока что подключаться к ESP32 по BLE можно, только открыв index.html в браузере на своём компьютере. Чтобы открыть его на смартфоне или другом устройстве, файл пришлось бы копировать туда и открывать в тамошнем браузере. Не очень удобно.

Лучший способ получить доступ к приложению с любого устройства — разместить файлы на сервере. Для работы с BLE файлы должны отдаваться по HTTPS.

В этом случае на устройствах iOS оно работать не будет. Понадобится приложение Bluefy (BLE-браузер), о котором говорилось выше.

Разместим приложение на GitHub Pages. Если у вас нет аккаунта на GitHub, заведите его перед началом.

  1. На панели своего аккаунта GitHub нажмите значок + и создайте New repository.
Создание нового репозитория на GitHub

Создаём новый репозиторий.

  1. Загрузится следующая страница. Дайте репозиторию имя и обязательно сделайте его Public. Затем нажмите кнопку Create repository.
Задаём имя репозитория и делаем его публичным

Имя репозитория и публичный доступ.

  1. Нажмите ссылку uploading an existing file.
Загрузка файлов в репозиторий GitHub

Загружаем существующие файлы.

  1. Перетащите файлы index.html, style.css и favicon.ico в репозиторий. Затем нажмите кнопку Commit changes.
Файлы загружены — фиксируем изменения

Коммитим загруженные файлы.

  1. Дальше зайдите в Settings > Pages и выставьте выделенные красным опции, как ниже. Наконец, нажмите кнопку Save.
Настройка и публикация GitHub Pages

Включаем GitHub Pages.

После сохранения подождите пару минут, пока страница станет доступна. Ваше приложение будет по адресу:

YOUR_GITHUB_USERNAME.github.io/YOUR_REPOSITORY_NAME

В нашем случае оно доступно здесь: https://ruisantosdotme.github.io/esp32-web-ble-sensor/.

Демонстрация#

Теперь к вашему Web-BLE-приложению можно обратиться с любого устройства (поддерживающего Web BLE) через браузер по этому адресу. Затем с этого устройства можно подключиться к ESP32 по BLE и увидеть живые данные BME280: температуру, влажность и давление. Проект легко переделать под вывод данных с любого другого датчика — например, с пульсометра.

Web-BLE-приложение на смартфоне — подключение к устройству

Подключаемся к устройству со смартфона.

Web-BLE-приложение на смартфоне, подключённое к ESP32

Смартфон подключён к ESP32.

Web-BLE-приложение ESP32 показывает данные датчика на графиках на смартфоне

Живые графики на смартфоне.

Если у вас устройство на iOS, откройте приложение Bluefy и вставьте в него адрес своего приложения — так вы сможете пользоваться Web BLE.

Итого#

Из этого руководства вы узнали про технологию Web BLE. Если совсем просто, это JavaScript-API, позволяющий писать веб-приложения для взаимодействия с BLE-устройствами из любого браузера, который поддерживает Web BLE.

Вы научились настраивать ESP32 как BLE-устройство с сервисом измерения параметров среды и характеристиками температуры, влажности и давления.

Другие наши руководства по ESP32 и Bluetooth, которые могут понравиться: