引言
经过前9章的学习,我们已经逐一攻克了ESP32-S3的GPIO、定时器、UART、I2C、SPI、I2S、Wi-Fi和BLE等核心外设。如果把每个外设比作一块乐高积木,那本章就是要教你把它们拼成一个完整的作品。
本章将构建一个BLE低功耗温湿度传感器节点,实现以下闭环:
scss
传感器采集(I2C) → 数据处理 → BLE通知(Notify) → 手机App接收显示
这不仅是前几章知识的综合运用,更是物联网产品开发中最典型、复用率最高的项目原型------智能家居传感器、环境监测节点、可穿戴设备,本质上都是这个模式的变体。
一、项目概述
1.1 功能需求
| 功能 | 说明 |
|---|---|
| 环境温湿度采集 | 通过I2C总线读取SHT30传感器数据 |
| BLE实时上报 | 以Notify方式每秒推送温湿度数据 |
| 手机端查看 | 使用nRF Connect或LightBlue App接收数据 |
| 电池电量监测 | ADC读取电池电压,换算为百分比 |
| 低功耗运行 | 传感器采集间隔可配置,支持Deep Sleep |
1.2 硬件清单
- ESP32-S3 DevKit(任意开发板)
- SHT30 温湿度传感器模块(I2C接口,地址 0x44)
- 面包板 + 杜邦线若干
- (可选)18650锂电池 + 分压电阻(用于电量检测)
1.3 系统框图
scss
┌─────────────────────────────────────┐
│ 手机 App │
│ (nRF Connect / LightBlue) │
└──────────────┬──────────────────────┘
│ BLE (Notify)
┌──────────────▼──────────────────────┐
│ ESP32-S3 │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ BLE GATT │ │ Sensor Task │ │
│ │ Server │◄─┤ (每N秒采集一次) │ │
│ └──────────┘ └────────┬─────────┘ │
└─────────────────────────┼───────────┘
│ I2C
┌──────────▼───────────┐
│ SHT30 传感器 │
│ (温湿度) │
└──────────────────────┘
二、硬件连接
SHT30 与 ESP32-S3 的连接非常简单------标准的 I2C 接线:
| SHT30 引脚 | ESP32-S3 引脚 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SCL | GPIO 4 |
| SDA | GPIO 5 |
SHT30 的 I2C 地址默认为 0x44(ADDR引脚接GND时),若ADDR接VCC则地址为0x45。本章使用默认地址0x44。
三、GATT 服务与协议设计
协议设计是"传感器+无线"项目的核心环节。它决定了手机端如何解析传感器数据。
3.1 Service 与 Characteristic 定义
我们定义一个自定义Service,包含4个Characteristics:
| 特征 | UUID (16-bit) | 属性 | 数据格式 | 说明 |
|---|---|---|---|---|
| Temperature | 0xF001 | Notify | 小端 float32 (℃) | 温度上报 |
| Humidity | 0xF002 | Notify | 小端 float32 (%RH) | 湿度上报 |
| Battery Level | 0xF003 | Read + Notify | uint8 (0~100) | 电量百分比 |
| Sample Interval | 0xF004 | Read + Write | uint16 (秒) | 采集间隔配置 |
3.2 为什么选择 Custom Service?
BLE 规范定义了标准 Environmental Sensing Service (0x181A),其中包含 Temperature (0x2A6E) 和 Humidity (0x2A6F)。标准服务的优势在于手机App可以直接识别,不需要自定义解析。
但本章选择自定义Service,原因有三:
- 教学目的:自定义Service能完整展示 Characteristic 的声明、UUID 分配、属性配置全过程
- 灵活性:实际项目中,标准服务的格式往往不够灵活(比如温度必须是 IEEE 11073 格式),而自定义格式可以按需设计
- 扩展性:后续增加风速、气压、PM2.5 等特征时,自定义UUID无需担心与标准冲突
实际建议:产品开发中,能用标准Service就用标准------手机端的生态兼容性更好。本章先用自定义Service讲透原理,文末"深度思考"会讨论何时切换到标准Service。
四、代码实现
4.1 SHT30 驱动
SHT30 是 Sensirion 公司生产的数字温湿度传感器,精度为 ±0.3℃ / ±2%RH,通过 I2C 接口通信。其核心指令只有三条:
c
// sht30.h
#pragma once
#include <stdint.h>
// SHT30 I2C 地址 (ADDR = GND)
#define SHT30_ADDR 0x44
// 指令集
#define SHT30_CMD_MEAS_HIGH 0x2400 // 高精度测量
#define SHT30_CMD_MEAS_MED 0x240B // 中精度
#define SHT30_CMD_MEAS_LOW 0x2416 // 低精度
#define SHT30_CMD_SOFT_RESET 0x30A2 // 软复位
驱动实现代码:
c
// sht30.c
#include "sht30.h"
#include "driver/i2c.h"
#define I2C_MASTER_NUM I2C_NUM_0
/**
* @brief 发送 SHT30 命令
*/
static esp_err_t sht30_send_cmd(uint16_t cmd)
{
uint8_t buf[2] = { (cmd >> 8) & 0xFF, cmd & 0xFF };
i2c_master_write_to_device(I2C_MASTER_NUM, SHT30_ADDR,
buf, 2, pdMS_TO_TICKS(100));
}
/**
* @brief 读取 SHT30 温湿度数据
* @param temp 温度输出 (℃)
* @param humid 湿度输出 (%RH)
* @return ESP_OK 或错误码
*/
esp_err_t sht30_read_data(float *temp, float *humid)
{
uint8_t buf[6] = {0};
// 1. 发送测量命令 (高精度)
sht30_send_cmd(SHT30_CMD_MEAS_HIGH);
// 2. 等待测量完成 (最大 15ms)
vTaskDelay(pdMS_TO_TICKS(20));
// 3. 读取 6 字节数据
esp_err_t ret = i2c_master_read_from_device(
I2C_MASTER_NUM, SHT30_ADDR,
buf, 6, pdMS_TO_TICKS(100));
if (ret != ESP_OK) return ret;
// 4. 校验 CRC (每2字节后跟1字节CRC)
// 实际项目中建议实现 CRC 校验,这里简化为直接转换
uint16_t raw_temp = (buf[0] << 8) | buf[1];
uint16_t raw_humid = (buf[3] << 8) | buf[4];
// 5. 将原始值转换为物理量
*temp = -45.0f + 175.0f * raw_temp / 65535.0f;
*humid = 100.0f * raw_humid / 65535.0f;
return ESP_OK;
}
SHT30 的原始 16 位数据到物理量的转换公式来自数据手册。注意:-45 和 175 不是经验值,而是 Sensirion 官方定义的线性映射参数。
4.2 BLE GATT Server 配置
基于第9章的基础框架,配置自定义 Service 和 Characteristic:
c
// ble_sensor_server.c - 部分关键代码
#include "esp_bt.h"
#include "esp_bt_main.h"
#include "esp_gap_ble_api.h"
#include "esp_gatts_api.h"
// 自定义 Service UUID
#define GATTS_SERVICE_UUID 0x00FF
#define GATTS_CHAR_TEMP_UUID 0xF001
#define GATTS_CHAR_HUMID_UUID 0xF002
#define GATTS_CHAR_BATT_UUID 0xF003
#define GATTS_CHAR_INTERVAL_UUID 0xF004
// Characteristic 属性
#define CHAR_PROP_NOTIFY (ESP_GATT_CHAR_PROP_BIT_NOTIFY)
#define CHAR_PROP_READ (ESP_GATT_CHAR_PROP_BIT_READ)
#define CHAR_PROP_RW (ESP_GATT_CHAR_PROP_BIT_READ | \
ESP_GATT_CHAR_PROP_BIT_WRITE)
// 当前传感器数据 (全局, 供回调访问)
static float g_temperature = 25.0f;
static float g_humidity = 60.0f;
static uint8_t g_battery = 100;
static uint16_t g_interval = 2; // 采集间隔(秒)
服务注册回调中的初始化逻辑:
c
// GATTS 回调事件处理
static void gatts_event_handler(esp_gatts_cb_event_t event,
esp_gatts_cb_param_t *param)
{
switch (event) {
case ESP_GATTS_REG_EVT:
// 创建服务表
esp_ble_gatts_create_attr_tab(gatts_attr_tab,
param->reg.app_id,
GATTS_IDX_NB);
break;
case ESP_GATTS_CREAT_ATTR_TAB_EVT:
if (param->add_attr_tab.status == ESP_GATT_OK) {
// 启动服务
esp_ble_gatts_start_service(
param->add_attr_tab.handles[GATTS_IDX_SVC]);
}
break;
case ESP_GATTS_CONNECT_EVT:
// 连接成功,配置 MTU
esp_ble_gatts_send_indicate(param->connect.conn_id,
param->connect.remote_bda);
break;
case ESP_GATTS_WRITE_EVT:
// 处理写入 (如修改采集间隔)
if (param->write.handle == g_handle_table[GATTS_IDX_CHAR_VAL_INTERVAL]) {
uint16_t new_interval = (param->write.value[1] << 8)
| param->write.value[0];
if (new_interval >= 1 && new_interval <= 3600) {
g_interval = new_interval;
ESP_LOGI(TAG, "间隔更新为 %d 秒", g_interval);
}
}
break;
default:
break;
}
}
属性表定义(使用 gatts_attr_tab 方式):
c
// 属性表枚举
enum {
GATTS_IDX_SVC, // Service 声明
GATTS_IDX_CHAR_TEMP, // Temperature Characteristic声明
GATTS_IDX_CHAR_VAL_TEMP, // Temperature Characteristic值
GATTS_IDX_CHAR_HUMID, // Humidity Characteristic声明
GATTS_IDX_CHAR_VAL_HUMID,// Humidity Characteristic值
GATTS_IDX_CHAR_BATT, // Battery Characteristic声明
GATTS_IDX_CHAR_VAL_BATT, // Battery Characteristic值
GATTS_IDX_CHAR_CFG_BATT, // Battery CCCD (用于通知开关)
GATTS_IDX_CHAR_INTERVAL, // Interval Characteristic声明
GATTS_IDX_CHAR_VAL_INTERVAL, // Interval Characteristic值
GATTS_IDX_NB, // 总数
};
// 完整属性表 (简化展示关键项)
static const esp_gatts_attr_db_t gatts_attr_tab[GATTS_IDX_NB] = {
// 1. Service 声明
[GATTS_IDX_SVC] = {
.attr_control = {.auto_rsp = ESP_GATT_AUTO_RSP},
.att_desc = {
.uuid_length = ESP_UUID_LEN_16,
.uuid_p = (uint8_t *)&GATTS_SERVICE_UUID,
.perm = ESP_GATT_PERM_READ,
.max_length = 16,
.length = 16,
.value = {0x00, 0xFF, /* ... 完整128位UUID */},
}
},
// 2. Temperature Characteristic
[GATTS_IDX_CHAR_TEMP] = {
.attr_control = {.auto_rsp = ESP_GATT_AUTO_RSP},
.att_desc = {
.uuid_length = ESP_UUID_LEN_16,
.uuid_p = (uint8_t *)&GATTS_CHAR_TEMP_UUID,
.perm = ESP_GATT_PERM_READ,
.max_length = 3,
.length = 3,
.value = {CHAR_PROP_NOTIFY,
(uint8_t)0xF0, (uint8_t)0x01},
}
},
// [Temperature Value 和 Humidity/Battery等配置类似,省略]
};
4.3 传感器采集与Notify推送
核心业务逻辑------定时采集并通知手机端:
c
// 传感器数据采集 + BLE 推送任务
static void sensor_task(void *pvParameters)
{
float temp, humid;
while (1) {
// 1. 采集
esp_err_t ret = sht30_read_data(&temp, &humid);
if (ret == ESP_OK) {
g_temperature = temp;
g_humidity = humid;
ESP_LOGI(TAG, "温度: %.1f℃, 湿度: %.1f%%",
temp, humid);
} else {
ESP_LOGW(TAG, "传感器读取失败 (0x%x)", ret);
}
// 2. ADC读取电池电压 (GPIO 7, ADC1_CH4)
uint32_t adc_reading = 0;
adc_reading = adc1_get_raw(ADC1_CHANNEL_4);
// 简单换算: 0~4095 -> 0%~100% (需根据分压电路调整)
g_battery = (uint8_t)(adc_reading * 100 / 4095);
// 3. 通过 Notify 推送数据 (如果已连接)
if (g_conn_id != 0xFFFF) {
// 推送温度 (float32 -> uint8[4])
uint8_t temp_buf[4];
memcpy(temp_buf, &g_temperature, 4);
esp_ble_gatts_send_indicate(g_gatts_if, g_conn_id,
g_handle_table[GATTS_IDX_CHAR_VAL_TEMP],
4, temp_buf, false);
// 推送湿度
uint8_t humid_buf[4];
memcpy(humid_buf, &g_humidity, 4);
esp_ble_gatts_send_indicate(g_gatts_if, g_conn_id,
g_handle_table[GATTS_IDX_CHAR_VAL_HUMID],
4, humid_buf, false);
// 推送电量
esp_ble_gatts_send_indicate(g_gatts_if, g_conn_id,
g_handle_table[GATTS_IDX_CHAR_VAL_BATT],
1, &g_battery, false);
ESP_LOGI(TAG, "BLE 数据推送完成");
}
// 4. 等待下一个采集周期
vTaskDelay(pdMS_TO_TICKS(g_interval * 1000));
}
}
4.4 主函数与初始化
c
void app_main(void)
{
// 1. 初始化 NVS
esp_err_t ret = nvs_flash_init();
if (ret == ESP_ERR_NVS_NO_FREE_PAGES) {
ESP_ERROR_CHECK(nvs_flash_erase());
ret = nvs_flash_init();
}
ESP_ERROR_CHECK(ret);
// 2. 初始化 I2C (复用第5章的配置)
i2c_config_t i2c_conf = {
.mode = I2C_MODE_MASTER,
.sda_io_num = GPIO_NUM_5,
.scl_io_num = GPIO_NUM_4,
.sda_pullup_en = GPIO_PULLUP_ENABLE,
.scl_pullup_en = GPIO_PULLUP_ENABLE,
.master.clk_speed = 100000, // 标准模式 100kHz
};
ESP_ERROR_CHECK(i2c_param_config(I2C_NUM_0, &i2c_conf));
ESP_ERROR_CHECK(i2c_driver_install(I2C_NUM_0,
I2C_MODE_MASTER, 0, 0, 0));
// 3. 初始化 BLE
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
esp_bt_controller_init(&bt_cfg);
esp_bt_controller_enable(ESP_BT_MODE_BTDM);
esp_bluedroid_init();
esp_bluedroid_enable();
// 4. 注册 GAP 和 GATTS 回调
esp_ble_gap_register_callback(gap_event_handler);
esp_ble_gatts_register_callback(gatts_event_handler);
esp_ble_gatts_app_register(0);
// 5. 配置广播数据 (参考第9章)
esp_ble_adv_data_t adv_data = {
.set_scan_rsp = false,
.include_name = true,
.include_txpower = true,
.flag = ESP_BLE_ADV_FLAG_GEN_DISC,
};
esp_ble_gap_config_adv_data(&adv_data);
// 6. 启动传感器采集任务
xTaskCreate(sensor_task, "sensor", 4096, NULL, 5, NULL);
}
4.5 完整项目的 CMakeLists.txt
cmake
idf_component_register(
SRCS "sht30.c" "ble_sensor_server.c"
INCLUDE_DIRS "."
REQUIRES driver nvs_flash bt
)
五、手机端调试
5.1 nRF Connect 连接步骤
- 手机安装 nRF Connect App(iOS/Android 均可)
- 打开 App,扫描设备,找到名为
ESP_SENSOR_01的设备 - 点击 Connect,进入服务列表
- 找到自定义 Service (UUID: 0x00FF)
- 点击 Temperature Characteristic (0xF001) 右侧的 🔔图标 启用 Notify
- 同样操作启用 Humidity 的 Notify
此时,App 会实时显示 ESP32-S3 推送的温湿度数据。
5.2 修改采集间隔
找到 Sample Interval 特征 (0xF004),点击 向上箭头 (Write),以 小端 uint16 格式写入数值:
makefile
写入: 05 00 → 表示 5 秒
写入: 0A 00 → 表示 10 秒
🧠 深度思考
本章的项目组合了 I2C 和 BLE,但我最想分享的并不是技术本身,而是协议设计的思维层次。
很多初学者在做"传感器+无线"项目时,会直接拿传感器原始数据往蓝牙特征里塞------温度值、时间戳、设备ID,一股脑拼成一个字节数组传给手机端。这种做法"能用",但后续扩展(加传感器、改数据格式、OTA 升级)时,你会发现手机端、服务端、设备端需要同步修改,牵一发而动全身。
真正好的协议设计,应该先做抽象分层:
- 应用层(传感器数据格式)→ 只关注"温度是 float32、单位是摄氏度"
- 传输层(BLE Notify / Indicate)→ 只关注"数据可靠到达"
- 表示层(UUID 映射)→ 只关注"哪个特征对应哪个物理量"
每一层独立演进,互不干扰。当你要从 SHT30 换到 SHT40 时,只需要改应用层的驱动代码,UUID 和传输逻辑完全不变。这种"面向接口而非面向实现"的设计思路,是嵌入式工程师从"写代码"到"做架构"的分水岭。
⚠️ 避坑指南 / 注意事项
-
SHT30 CRC 校验不可省略:本文示例代码为了简洁跳过了 CRC 校验。实际产品中,SHT30 每 2 字节数据后跟了 1 字节 CRC(使用 CRC-8 多项式 0x31)。I2C 总线受干扰时可能读到错误数据,加 CRC 校验后可以过滤掉约 99.9% 的异常值。
-
Notify 频率不要过快 :BLE 4.2 的连接间隔默认最小 7.5ms(对应约 133 包/秒),但 ESP32-S3 的协议栈处理能力有限。实测每秒超过 10 次 Notify(且数据包 > 20 字节)时,容易出现
ESP_GATT_INSUFFICIENT_RESOURCES错误。建议传感器数据推送控制在 1~5Hz。 -
ADC 电池测量的分压电阻:ESP32-S3 的 ADC 输入电压范围为 0~3.3V,而锂电池满电 4.2V。必须用电阻分压(例如 100kΩ + 100kΩ 分压至 2.1V 以下),且分压后的待机电流可能达到微安级。如果追求低功耗,建议使用专用的电量计芯片(如 MAX17048)而不是 ADC 分压。
-
BLE 连接状态跟踪 :代码中的
g_conn_id != 0xFFFF判断连接状态有潜在风险------断连后g_conn_id重置需要与ESP_GATTS_DISCONNECT_EVT事件同步处理。更稳妥的方式是封装一个is_connected()函数,从事件标志位中读取状态。 -
I2C 总线超时 :SHT30 在测量期间(约 15ms)不会响应 I2C 访问。如果在测量结束前就发起读操作,会收到 NACK。这就是代码中
vTaskDelay(20ms)的原因------不能省。更好的方案是使用 SHT30 的"周期性模式"(Periodic Data Acquisition Mode),它会在数据就绪后自动更新寄存器,主机无需等待。 -
广播名称不宜过长 :BLE 广播包的数据段限制为 31 字节。本文使用的设备名
ESP_SENSOR_01约 13 字节,加上 Service UUID 和 Tx Power 已占约 23 字节。如果名称过长,ESP-IDF 会自动截断,但截断策略可能不是你想要的效果------建议自定义名称控制在 12 字节以内。 -
ESP-IDF v5.x 的蓝牙配置变化 :自 ESP-IDF v5.0 起,Bluedroid 不再默认启用。需要在 menuconfig 中手动开启
(Top) → Component config → Bluetooth → Bluedroid Enable,并在 SDK 配置中选择ESP_BT_CONTROLLER_INIT_CONFIG_DEFAULT()为BTDM(双模)模式。默认配置从传统蓝牙启动,会导致 BLE 无法正常初始化。
总结
本章我们从零构建了一个完整的 BLE 低功耗传感器节点,核心收获包括:
- 系统级设计思维:将 I2C 传感器驱动、BLE GATT Server、数据采集与通知三部分整合到同一个 FreeRTOS 项目中
- GATT 协议设计:从 Service 定义、Characteristic 属性配置到 Notify 推送的完整流程
- 双向交互能力:手机端可以实时接收数据,也可以通过 Write 特征修改设备配置(采集间隔)
- 实战经验:SHT30 驱动编写、CRC 校验的必要性、BLE Notify 频率限制等工程细节
接下来,你可以在这个项目基础上做很多有趣的扩展:
- 同时接入 BH1750 光照传感器(复用第5章驱动),在同一个 Service 下增加光照特征
- 增加 Deep Sleep 模式,用定时器唤醒,实现纽扣电池供电运行数月
- 配合第9章的 Client 代码,让另一个 ESP32 作为 BLE Central 接收多个传感器节点的数据
下一章我们将进入 低功耗管理 的专题,深入探讨 ESP32-S3 的多种睡眠模式,以及如何在保持通信的同时将功耗降到微安级别------这对于电池供电的物联网产品来说,是必须攻克的关键技术。