ESP32 Web BLE: живая визуализация данных датчика (графики BME280)

Перевод статьи 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 через менеджер библиотек.
2. Что понадобится#
Подойдёт любая плата ESP32 на ваш выбор.

ESP32 с подключённым датчиком BME280.
В качестве датчика возьмём BME280 — он будет отдавать по BLE показания температуры, влажности и давления. С небольшими правками в коде можно использовать любой другой датчик.
- плата ESP32 на ваш выбор (например, ESP32 DOIT V1 или ESP32-S3 DevKitC);
- датчик BME280;
- макетная плата;
- провода-джамперы.
3. Схема подключения#
Подключите датчик BME280 к ESP32. Мы используем GPIO 21 и 22 под SDA и SCL. Можно взять любые другие GPIO, если соответственно поправить код.

Схема подключения 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.
Web BLE — краткое введение#
Web Bluetooth (его же иногда называют Web BLE) — это технология, позволяющая подключаться к BLE-устройствам вроде ESP32 и управлять ими прямо из браузера на JavaScript.
С Web BLE можно писать веб-приложения, взаимодействующие с ESP32 по Bluetooth: управлять пинами GPIO, обмениваться данными и удалённо управлять устройствами через веб-интерфейс — с любого устройства, где есть браузер (компьютер или смартфон*).

Браузер общается с ESP32 по BLE.
* кроме устройств iOS. В этом случае можно воспользоваться приложением вроде Bluefy – Web BLE Browser.
Одно из главных преимуществ Web Bluetooth в том, что он работает на разных устройствах и операционных системах через совместимый браузер — без установки отдельного приложения.
Значит, подключаться к ESP32 и управлять им через Web-BLE-приложение в браузере можно со смартфона, планшета или настольного компьютера.
Материалы по Web BLE и BLE#
Если вы впервые сталкиваетесь с Web BLE и BLE на ESP32, советуем сначала бегло просмотреть эти руководства, чтобы освоиться с базовыми понятиями Bluetooth Low Energy:
- Getting Started with ESP32 Bluetooth Low Energy (BLE) on Arduino IDE
- ESP32 BLE Server and Client (Bluetooth Low Energy)
- ESP32 Web Bluetooth (BLE): Getting Started Guide
Обзор проекта#
Прежде чем двигаться дальше, коротко о том, что мы соберём:
- ESP32 выступает в роли BLE-периферии (peripheral) и объявляет о своём присутствии;
- ваш браузер выступает в роли BLE-контроллера, который получает показания датчика (читает характеристики ESP32).

ESP32 — периферия, браузер — контроллер.
- GATT-структура ESP32 содержит один сервис (сервис измерения параметров окружающей среды, Environmental Sensing Service: UUID
181A) с тремя характеристиками (температура: UUID2A6E, влажность: UUID2A6F, давление: UUID2A6D).

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

Показания на карточках и графиках.
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.");
}
}
Как работает код#
Если коротко, этот код поднимает 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-сервис инициализирован и ждёт подключения клиента.

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

Готовое веб-приложение.
Как вариант, можно воспользоваться нашим готовым веб-приложением по этому адресу:
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">📶 Connect to BLE device</button>
<button id="disconnectBleButton" class="disconnectButton hidden">❌ 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">🌡</span>
<div class="sensor-label">Temperature</div>
<div class="sensor-value">
<span id="tempValue">--</span><span class="sensor-unit" id="tempUnitLabel"> °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')">°C</button>
<button class="unit-btn" id="btnF" onclick="setTempUnit('F')">°F</button>
</div>
</div>
<div class="sensor-card hum">
<span class="sensor-icon">💧</span>
<div class="sensor-label">Humidity</div>
<div class="sensor-value">
<span id="humValue">--</span><span class="sensor-unit"> %</span>
</div>
<div class="sensor-timestamp" id="humTimestamp">No reading</div>
</div>
<div class="sensor-card press">
<span class="sensor-icon">↓</span>
<div class="sensor-label">Pressure</div>
<div class="sensor-value">
<span id="pressValue">--</span><span class="sensor-unit"> 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>🌡 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>💧 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>↓ 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 = ' °' + 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; }
}
favicon#
Дополнительно положите в папку файл favicon:
Тестирование Web-BLE-приложения#
Создав все нужные файлы в одной папке, перетащите index.html в браузер.
Откроется вот такая страница.

Приложение, открытое из локального файла.
Это работает и на Windows, и на компьютерах с iOS. Здесь всё получается потому, что браузер, открывший страницу, работает с localhost.
Пусть ESP32 крутит показанный ранее код — протестируем веб-приложение.
Начните с кнопки Connect to BLE Device. Всплывёт окно, в котором вы увидите BLE-устройство ESP32. Подключитесь к нему.

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

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

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

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

Живые графики температуры, влажности и давления.
Размещение Web-BLE-приложения в сети#
Пока что подключаться к ESP32 по BLE можно, только открыв index.html в браузере на своём компьютере. Чтобы открыть его на смартфоне или другом устройстве, файл пришлось бы копировать туда и открывать в тамошнем браузере. Не очень удобно.
Лучший способ получить доступ к приложению с любого устройства — разместить файлы на сервере. Для работы с BLE файлы должны отдаваться по HTTPS.
В этом случае на устройствах iOS оно работать не будет. Понадобится приложение Bluefy (BLE-браузер), о котором говорилось выше.
Разместим приложение на GitHub Pages. Если у вас нет аккаунта на GitHub, заведите его перед началом.
- На панели своего аккаунта GitHub нажмите значок + и создайте New repository.

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

Имя репозитория и публичный доступ.
- Нажмите ссылку uploading an existing file.

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

Коммитим загруженные файлы.
- Дальше зайдите в Settings > Pages и выставьте выделенные красным опции, как ниже. Наконец, нажмите кнопку Save.

Включаем 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: температуру, влажность и давление. Проект легко переделать под вывод данных с любого другого датчика — например, с пульсометра.

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

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

Живые графики на смартфоне.
Если у вас устройство на iOS, откройте приложение Bluefy и вставьте в него адрес своего приложения — так вы сможете пользоваться Web BLE.
Итого#
Из этого руководства вы узнали про технологию Web BLE. Если совсем просто, это JavaScript-API, позволяющий писать веб-приложения для взаимодействия с BLE-устройствами из любого браузера, который поддерживает Web BLE.
Вы научились настраивать ESP32 как BLE-устройство с сервисом измерения параметров среды и характеристиками температуры, влажности и давления.
Другие наши руководства по ESP32 и Bluetooth, которые могут понравиться: