BLE 传感器节点实战

引言

经过前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,原因有三:

  1. 教学目的:自定义Service能完整展示 Characteristic 的声明、UUID 分配、属性配置全过程
  2. 灵活性:实际项目中,标准服务的格式往往不够灵活(比如温度必须是 IEEE 11073 格式),而自定义格式可以按需设计
  3. 扩展性:后续增加风速、气压、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 连接步骤

  1. 手机安装 nRF Connect App(iOS/Android 均可)
  2. 打开 App,扫描设备,找到名为 ESP_SENSOR_01 的设备
  3. 点击 Connect,进入服务列表
  4. 找到自定义 Service (UUID: 0x00FF)
  5. 点击 Temperature Characteristic (0xF001) 右侧的 🔔图标 启用 Notify
  6. 同样操作启用 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 和传输逻辑完全不变。这种"面向接口而非面向实现"的设计思路,是嵌入式工程师从"写代码"到"做架构"的分水岭。


⚠️ 避坑指南 / 注意事项

  1. SHT30 CRC 校验不可省略:本文示例代码为了简洁跳过了 CRC 校验。实际产品中,SHT30 每 2 字节数据后跟了 1 字节 CRC(使用 CRC-8 多项式 0x31)。I2C 总线受干扰时可能读到错误数据,加 CRC 校验后可以过滤掉约 99.9% 的异常值。

  2. Notify 频率不要过快 :BLE 4.2 的连接间隔默认最小 7.5ms(对应约 133 包/秒),但 ESP32-S3 的协议栈处理能力有限。实测每秒超过 10 次 Notify(且数据包 > 20 字节)时,容易出现 ESP_GATT_INSUFFICIENT_RESOURCES 错误。建议传感器数据推送控制在 1~5Hz。

  3. ADC 电池测量的分压电阻:ESP32-S3 的 ADC 输入电压范围为 0~3.3V,而锂电池满电 4.2V。必须用电阻分压(例如 100kΩ + 100kΩ 分压至 2.1V 以下),且分压后的待机电流可能达到微安级。如果追求低功耗,建议使用专用的电量计芯片(如 MAX17048)而不是 ADC 分压。

  4. BLE 连接状态跟踪 :代码中的 g_conn_id != 0xFFFF 判断连接状态有潜在风险------断连后 g_conn_id 重置需要与 ESP_GATTS_DISCONNECT_EVT 事件同步处理。更稳妥的方式是封装一个 is_connected() 函数,从事件标志位中读取状态。

  5. I2C 总线超时 :SHT30 在测量期间(约 15ms)不会响应 I2C 访问。如果在测量结束前就发起读操作,会收到 NACK。这就是代码中 vTaskDelay(20ms) 的原因------不能省。更好的方案是使用 SHT30 的"周期性模式"(Periodic Data Acquisition Mode),它会在数据就绪后自动更新寄存器,主机无需等待。

  6. 广播名称不宜过长 :BLE 广播包的数据段限制为 31 字节。本文使用的设备名 ESP_SENSOR_01 约 13 字节,加上 Service UUID 和 Tx Power 已占约 23 字节。如果名称过长,ESP-IDF 会自动截断,但截断策略可能不是你想要的效果------建议自定义名称控制在 12 字节以内。

  7. 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 的多种睡眠模式,以及如何在保持通信的同时将功耗降到微安级别------这对于电池供电的物联网产品来说,是必须攻克的关键技术。

相关推荐
fliter1 小时前
Go 1.26 新增 `bytes.Buffer.Peek`:只看数据,不移动读取位置
后端
羑悻1 小时前
我受够了 Coding Agent 的“假聪明,真串行”,于是做了 Meta-Orchestrator!
后端
神奇小汤圆1 小时前
字节二面挂了!被问“订单超时自动取消”
后端
饼干哥哥1 小时前
每晚300+个爆款疯狂跑,0人值守TikTok工厂开张!!
后端·操作系统·产品
65岁退休Coder1 小时前
LangChain v1.3.4 笔记 - 02 工具、结构化输出
后端
颜进强2 小时前
Claude Code - 18 从 Figma 到 React:一套让 AI 生成代码"不瞎编"的实战工作流
前端·后端·ai编程
geovindu2 小时前
CSharp: Bridge Pattern
开发语言·后端·桥接模式·结构型模式
霸道流氓气质2 小时前
SpringBoot中设计模式组合使用示例-策略、模板、观察者、门面、工厂、单例。
spring boot·后端·设计模式
货拉拉技术3 小时前
复杂业务规则太多?我们搭了一个规则可视化配置平台
后端