拔出式开发之我的第一个Web蓝牙项目
拔出式开发之我的第一个Web蓝牙项目
前言
概述
本项目实现了一个最最基本的Web蓝牙应用:网页端可以用按钮控制开发板上LED灯的亮灭。这个功能看似简单,实际上它具备了一个物联网系统的所有功能:通过Web网页控制蓝牙设备,无论多复杂的物联网蓝牙系统都是这样,只是多加了一些服务功能而已。
思路
大致做法是控制设备(本项目中是开着网页的电脑)启动一个蓝牙信道连接到被控设备(本项目中的开发板)。之后,控制设备可以通过这个信道把数据传给被控设备。
按一下按钮,传过去一个0x01,被控设备通过回调接收,收到以后就把GPIO设为高电平,就是开灯;相反传0x00就设为低电平,关灯。当然,被控设备也可以向控制设备反馈,即完成GPIO切换后把是否成功和当前状态发给前端。
环境配置
搭建Arduino环境
如果选择用c++,最好使用Arduino,而不是原生espressif,那个封装程度太低,蓝牙例程非常让人头疼。
在VSCode搜索PlatformIO IDE
插件安装。点左边的Create New Project初始化项目。由于我用的是官方的迷你板,设备一栏选Espressif ESP32-C3-DevKitM-1
,如果是大板就选DevKitC-02
。框架就选Arduino。点击finish后需要等很长时间。
待项目初始化完成,需要修改platformio.ini
配置文件,直接复制以下内容即可:
[env:esp32-c3-devkitm-1]
platform = espressif32
board = esp32-c3-devkitm-1
framework = arduino
monitor_speed = 115200
board_build.flash_mode = dio
build_flags =
-D ARDUINO_USB_MODE=1
-D ARDUINO_USB_CDC_ON_BOOT=1
最重要的是下半部分,手动把ARDUINO_USB_CDC_ON_BOOT
设为1,如果不加这一条,后续运行程序时是看不到串口输出的(虽然可以正常烧录),所以建议在开发调试阶段把它加上,最终在生产环境下把它删了以优化性能。
环境测试
使用经典的点灯测试,我的这块ESP32-C3 super mini的LED位于GPIO8,不同的板子不一样,需要自己查看原理图。测试代码如下,把GPIO8设为输出模式并在循环中设为高电平:
#include <Arduino.h>
int LED = 8; //定义led引脚为数字引脚9
void setup() {
pinMode(LED, OUTPUT);
Serial.begin(115200);
}
void loop() {
//设置该引脚为高电平,点亮LED
digitalWrite(LED, HIGH);
delay(5000);
Serial.println("Done.");
}
插上线手动选好串口,之后依次点击窗口左下角的对钩(Build)和右箭头(烧录)图标,烧录完成后灯就会亮,之后点插头图标(串口监视器),应该可以看到5秒后有Done.
的输出。
至此环境配置完成,后续只需要修改main中的代码即可。
蓝牙服务实现
代码部分
首先,按BLE(低功耗蓝牙)协议,需要为蓝牙设备指定一个UUID(类似于MAC地址),还要为其提供的所有服务分别指定UUID(类似于API):
设备(Device)
└── 服务(Service)
└── 特征(Characteristic)
├── 属性(Properties)
├── 值(Value)
└── 描述符(Descriptor)
其他都很好理解,特征中的属性如下:
// 必须明确定义操作权限
PROPERTY_READ // 可读
PROPERTY_WRITE // 可写
PROPERTY_NOTIFY // 通知(服务端推送到客户端)
PROPERTY_INDICATE // 指示(需要客户端确认)
本例中用到的服务权限是可读可写且可通知。在后续调试时,也可以用开发工具查看所有蓝牙服务的权限。由于服务更加好理解,下文中我会把服务和特征这两个概念混用,这并不影响开发。
既然如此,代码的第一步就是定义这两个值:
#include <Arduino.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
#define SERVICE_UUID "12345678-1234-1234-1234-1234567890AB"
#define CHARACTERISTIC_UUID "12345678-1234-1234-1234-1234567890AC"
下一步是定义回调。对于设备,只需要实现两个函数,当被连接、被断开连接时需要做什么:
class MyServerCallbacks: public BLEServerCallbacks {
void onConnect(BLEServer* pServer) {
deviceConnected = true;
Serial.println("Device connected");
}
void onDisconnect(BLEServer* pServer) {
deviceConnected = false;
Serial.println("Device disconnected");
BLEDevice::startAdvertising();
}
};
而对于服务,其实只需要写一个回调,就是接收到新数据时需要做什么。首先获取到更新的值,然后判断,去设置自己的值然后做对应的GPIO操作:
class MyCharacteristicCallbacks: public BLECharacteristicCallbacks {
void onWrite(BLECharacteristic* pCharacteristic) {
std::string value = pCharacteristic->getValue();
size_t length = value.length();
if (length > 0) {
uint8_t* data = (uint8_t*)value.data();
uint8_t command = data[0];
// Process command
if (command == 0x00) {
digitalWrite(CONTROL_PIN, LOW);
if (currentState != false) {
currentState = false;
Serial.println("Pin set to LOW");
// Notify phone
if (deviceConnected) {
pCharacteristic->setValue("LOW");
pCharacteristic->notify();
}
}
}
else if (command == 0x01) {
digitalWrite(CONTROL_PIN, HIGH);
if (currentState != true) {
currentState = true;
Serial.println("Pin set to HIGH");
// Notify phone
if (deviceConnected) {
pCharacteristic->setValue("HIGH");
pCharacteristic->notify();
}
}
}
else {
Serial.print("Invalid command: 0x");
Serial.println(command, HEX);
// Notify phone about invalid command
if (deviceConnected) {
String errorMsg = "Invalid: 0x" + String(command, HEX);
pCharacteristic->setValue(errorMsg.c_str());
pCharacteristic->notify();
}
}
}
}
};
这些类继承是固定写法,无需深究。可以看出,当接收到新值时,并不会直接把服务的值更新,而是根据收到的数据来决定如何更新值:
if (command == 0x00) {
// 改变管脚状态
digitalWrite(CONTROL_PIN, LOW);
// 设置自身状态值,方便后续取用。不过本例中其实不太用的上
if (currentState != false) {
currentState = false;
Serial.println("Pin set to LOW");
// Notify
if (deviceConnected) {
pCharacteristic->setValue("LOW");
pCharacteristic->notify();
}
}
}
以上就是所有关键逻辑。不过还需要实现Arduino规定的setup()
函数。这里边需要初始化管脚和蓝牙服务,具体就是按文档初始化并把蓝牙实例和刚才创建的回调函数绑定:
// 管脚初始化
pinMode(CONTROL_PIN, OUTPUT);
digitalWrite(CONTROL_PIN, LOW);
Serial.println("Pin 8 initialized to LOW");
// 蓝牙设备名
BLEDevice::init("ESP32-Pin-Controller");
// 创建蓝牙设备并绑定上面定义的回调函数
pServer = BLEDevice::createServer();
pServer->setCallbacks(new MyServerCallbacks());
// 创建服务
BLEService* pService = pServer->createService(SERVICE_UUID);
对于服务的初始化比较复杂,这一步需要手动指定这个服务的所有条目并启动广播,让周边的控制设备能搜索到这项服务:
// 创建特征
pCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID, // 特征UUID,下面都是权限标识
BLECharacteristic::PROPERTY_READ |
BLECharacteristic::PROPERTY_WRITE |
BLECharacteristic::PROPERTY_NOTIFY
);
// 设置描述符,标准写法
pCharacteristic->addDescriptor(new BLE2902());
// 绑定回调函数
pCharacteristic->setCallbacks(new MyCharacteristicCallbacks());
pCharacteristic->setValue("Ready");
// 启动服务
pService->start();
// 设置广播参数并启动广播
BLEAdvertising* pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->setScanResponse(true);
BLEDevice::startAdvertising();
Serial.println("BLE service started");
Serial.println("Send 0x00 for LOW, 0x01 for HIGH");
最后,loop()
函数中其实啥也不用写,因为所有的功能都已经在回调中完成了。
手机调试
苹果手机下载LightBlue
,找到刚才的设备,向其中写入对应的值就能看到板子上的灯对应开关了。不过,作为一个物联网设备,你不能指望用户手动去给蓝牙服务传值,而是需要一个用户友好的前端界面,通过一个单一的按钮控制它。
前端实现
这里只展示部分关键的js代码。通过js操控蓝牙主要通过Web Bluetooth API实现。首先定义要使用的设备和服务:
// UUID,必须与ESP32代码中的UUID匹配
const SERVICE_UUID = '12345678-1234-1234-1234-1234567890ab';
const CHARACTERISTIC_UUID = '12345678-1234-1234-1234-1234567890ac';
连接蓝牙设备,要写成一个异步函数:
async function connectToDevice() {
try {
addLog('正在扫描蓝牙设备...');
// 请求蓝牙设备
device = await navigator.bluetooth.requestDevice({
filters: [
{ name: 'ESP32-Pin-Controller' }
],
optionalServices: [SERVICE_UUID]
});
addLog(`找到设备: ${device.name}`);
// 连接到GATT服务器
addLog('正在连接...');
const server = await device.gatt.connect();
// 获取服务
addLog('获取服务...');
const service = await server.getPrimaryService(SERVICE_UUID);
// 获取特征
addLog('获取特征...');
characteristic = await service.getCharacteristic(CHARACTERISTIC_UUID);
// 监听特征值变化
characteristic.addEventListener('characteristicvaluechanged', handleNotifications);
await characteristic.startNotifications();
isConnected = true;
updateStatus();
addLog('连接成功!');
// 监听断开事件
device.addEventListener('gattserverdisconnected', onDisconnected);
} catch (error) {
addLog(`连接错误: ${error}`);
console.error('Bluetooth connection error:', error);
}
}
主要功能上,按下按钮后触发toggle
回调,向设备发送反转命令:
function togglePin() {
if (!isConnected) {
addLog('错误: 请先连接设备');
return;
}
// 切换状态: 如果当前是HIGH,则设置为LOW,反之亦然
const newValue = pinState ? 0x00 : 0x01;
writeToDevice(newValue);
}
writeToDevice
的实现如下,它需要先创建一个8位缓冲区才能写数据:
async function writeToDevice(value) {
if (!isConnected || !characteristic) {
addLog('错误: 设备未连接');
return;
}
try {
const data = new Uint8Array([value]);
await characteristic.writeValue(data);
pinState = (value === 0x01);
updateButton();
addLog(`写入值: 0x${value.toString(16).padStart(2, '0')} (${pinState ? 'HIGH' : 'LOW'})`);
} catch (error) {
addLog(`写入错误: ${error}`);
console.error('Write error:', error);
}
}
对于通知的处理,需要在定义好的特征上绑定一个处理回调函数,在这个服务发通知时,会自动调用handleNotifications
:
characteristic.addEventListener('characteristicvaluechanged', handleNotifications);
await characteristic.startNotifications();
这样一来handleNotifications
就可以直接通过event
拿到设备发来的通知并显示在前端(这里可以显示目前灯的亮灭状态):
function handleNotifications(event) {
const value = event.target.value;
// 可以在这里处理从设备发送的通知
addLog(`收到设备通知: ${new TextDecoder().decode(value)}`);
}
之后只需要把这些函数绑在页面上就行了。
成果展示
前端页面:

当点击连接后,浏览器会自动弹窗选择设备,现代浏览器大多都支持蓝牙API。
动态展示:
