项目实战 02:智能家居环境网关——ESP32 做 BLE 中心 + 本地联动

适用人群:会点 ESP32 和 FreeRTOS,能跑通 Wi-Fi 例程,但没做过"多个无线传感器汇总到一个中枢、还要断网也能干活"的同学。没看过本专栏 01 篇也能读,01 篇是电池节点,这篇是常供电网关,两篇正好互补。

读完你能得到:① 一套"BLE 扫描 + GATT 读取 + MQTT 上报 + 本地规则联动"的完整任务架构;② ESP-IDF v5.x 下能编过的 esp_gap_ble / esp_ble_gattc / esp_mqtt_client 骨架代码;③ 搞懂 BLE 回调到底跑在谁的任务上下文里(这是新手必炸的一个坑);④ 一个带滞回、带最短保持时间、不会让继电器"哒哒哒"乱跳的规则引擎;⑤ 一张"常供电网关 vs 电池休眠节点"的架构对照表,面试被追问时能答上来。


一、为什么我要做网关,而不是再做一个节点

先说这东西是怎么被逼出来的。

我出租屋里现在有三个蓝牙温湿度计:一个买米家的、一个是同学送的杂牌、还有一个是我自己用 ESP32 + SHT30 焊的。问题来了:

  • 米家的数据只能在米家 App 里看;
  • 杂牌那个要装一个我根本不敢登录的 App;
  • 我自己焊的那个,数据在串口里打印,人不在电脑前就看不到。

三份数据,三个孤岛,谁也不认识谁。 我想干的事其实特别朴素:卧室湿度低于 30% 的时候,把加湿器打开;客厅温度高于 30 ℃ 的时候,把风扇打开。就这两件事,用现成 App 一个都做不了------因为做决策需要"同时看到所有传感器",而每家 App 只看得到自己家的设备。

那么第一个方案是什么?买个智能音箱做网关。 但这就有两个我不能接受的地方:

  1. 它得联网才工作。 我这边的路由器每天半夜会抽风重启一次,Wi-Fi 断了几分钟,那几分钟里加湿器就不受控了。一个"断网就变砖"的自动化,我不太敢让它半夜自己跑。
  2. 我学不到任何东西。 花 199 买个盒子,我的简历上还是零。

于是就有了这篇的东西:用一块 ESP32 做一个常供电的环境网关。它干三件事:

text 复制代码
  ①  当 BLE 中心设备(Central),主动扫描并读取多个 BLE 温湿度计的数据
  ②  通过 Wi-Fi + MQTT 把汇总数据发到我自己的 MQTT 服务器 / Home Assistant
  ③  本地跑一个规则引擎:湿度<30% 开加湿器、温度>30℃ 开风扇 ------ 断网照样执行

第 ③ 条是这个项目的灵魂,也是它和"买个盒子"最大的区别:决策在本地,云只是观众。 网线拔了、路由器重启、MQTT 服务器挂了,加湿器该开还是开。

💡 这句话展开就是一个能讲很久的面试点:云端联动 vs 本地联动 。云端联动的链路是"传感器 → 网关 → 云 → 规则 → 云 → 网关 → 执行器",中间任何一环断了,你家的灯就不听话;本地联动的链路是"传感器 → 网关 → 执行器",云只在旁边抄一份数据。真实产品里两者是并存的,但保命的那条规则一定要能在本地跑完

1.1 它和上一篇的电池气象节点是什么关系

本专栏第 01 篇做的是"带 OTA 的低功耗气象节点":一块 ESP32 挂电池,睡 10 分钟醒一次,采一次数据发出去继续睡。那是

这一篇做的是中枢 。两者的设计目标几乎处处相反,我把它列成一张表------这张表本身就是可以拿去面试讲的东西,因为它体现的是"你知道架构是被约束逼出来的,不是背来的":

维度 项目实战_01 电池气象节点 本篇 常供电网关
供电 18650 / 干电池,功耗即寿命 USB 5 V 常供电,功耗不敏感
主循环形态 醒 → 采 → 发 → esp_deep_sleep_start() 常驻多任务,永不休眠
功耗目标 平均电流压到 µA 级 mA 级随便花,改盯发热和稳定性
联网方式 每次醒来重连一次 Wi-Fi,发完就断 MQTT 长连接 + keepalive + 自动重连
RTOS 用法 能不用就不用,一条直线跑完 必须多任务解耦(BLE / MQTT / 规则)
失败处理 这次失败就继续睡,下次再说 必须重连、必须本地降级、不许卡死
数据角色 生产者(一个点) 消费者 + 聚合者 + 决策者(多个点)
最怕什么 漏电流、唤醒源没关 任务饿死、回调阻塞、共享变量竞态
面试能聊什么 功耗预算、唤醒源、OTA 安全 上下文隔离、队列解耦、断网降级

一句话记忆:电池节点的敌人是"电",常供电网关的敌人是"并发"。 这篇后面所有的坑,追根究底都是并发问题。


二、硬件与整体架构

2.1 物料清单(我实际用的)

器件 型号/参数 干嘛用的 备注
主控 ESP32-WROOM-32 开发板 BLE 中心 + Wi-Fi + 规则引擎 必须是双模 ESP32/ESP32-S3/C3,ESP32-S2 没有蓝牙
传感端 支持标准 ESS 服务的 BLE 温湿度计 ×N 数据源 见 3.1 的说明,杂牌不一定支持
执行器 1 光耦隔离继电器模块(5 V 供电) 开关加湿器/风扇 不要用 GPIO 直接驱动裸继电器,见第六节
执行器 2(可选) SG90 舵机 拨动机械开关/风门 必须独立 5 V 供电
电源 5 V/2 A 适配器 + AMS1117-3.3 或 LDO 常供电 网关不在乎 Iq,但在乎峰值
调试 USB 转串口(板载即可) 看日志 ---

⚠️ 关于电源,和 低功耗_03 那篇的结论正好可以对照着看:那篇讲电池设备要盯 LDO 的静态电流 Iq(AMS1117 的 Iq 高达 5 mA,能把你所有省电努力清零);但这篇是常供电,Iq 5 mA 完全无所谓,这里要盯的是另一个数------峰值电流。 ESP32 Wi-Fi 发射瞬间可以拉到几百 mA(乐鑫《ESP32-WROOM-32 Datasheet》Table 1 要求电源至少能提供 500 mA),再叠加继电器线圈的 70 mA 和舵机堵转的几百 mA,一个 500 mA 的小适配器立刻就趴窝------表现为"一开继电器 ESP32 就重启"。同一个器件,在两种架构里要看的参数完全不同,这就是选型的意思。

2.2 任务划分:谁跟谁说话,用什么说

这是全篇最重要的一张图。先看,再解释为什么这么切:

text 复制代码
【环境网关任务与队列架构】

   BLE 温湿度计 ×N                          ┌─────────────────────────┐
   (ESS 0x181A)                             │  Bluedroid 协议栈       │
        │  广播/GATT                        │  BTU task + BTC task    │
        └───────── 2.4 GHz ────────────────>│  ★回调在 BTC 任务里跑★  │
                                            └───────────┬─────────────┘
                                                        │ 只做三件事:
                                                        │ 过滤 / 拷贝 / 投队列
                                                        │ xQueueSend(..., 0)
                                                        ▼
                                              ┌───────────────────┐
                                              │ q_sample  深度16   │
                                              │ sensor_sample_t   │
                                              └─────────┬─────────┘
                                                        ▼
                              ┌──────────────────────────────────────┐
                              │  rule_engine_task   prio 5  4096B    │
                              │   1. 更新共享状态表 (mutex 保护)      │
                              │   2. 跑规则表 + 滞回 + 最短保持时间   │
                              │   3. 输出动作 / 转发一份给上报        │
                              └───────┬──────────────────────┬───────┘
                                      │                      │ xQueueSend
                                      ▼                      ▼
                        ┌─────────────────────┐   ┌───────────────────┐
                        │ actuator_set()      │   │ q_report  深度8    │
                        │ GPIO → MOSFET/光耦   │   └─────────┬─────────┘
                        │ 继电器 / 舵机 PWM    │             ▼
                        └─────────────────────┘   ┌───────────────────┐
                                                  │ mqtt_task prio 4  │
                                                  │ 组 JSON + publish │
                                                  └─────────┬─────────┘
                                                            ▼
                                                  esp-mqtt 内部任务
                                                  → Wi-Fi → Broker

   关键:★ 那一行。BLE 回调不是你的任务,它是协议栈借给你的一小段时间。
        在里面待久了,整个蓝牙就停摆。

任务参数我是这么定的(栈大小不是拍脑袋,是用 uxTaskGetStackHighWaterMark() 量出来再留 1 KB 余量的,方法见 调试排错_03 那篇):

任务 优先级 职责 阻塞在哪
rule_engine_task 5 4096 B 更新状态表、跑规则、驱动执行器 xQueueReceive(q_sample, ...)
mqtt_task 4 4096 B q_report 取数据组 JSON 发布 xQueueReceive(q_report, ...)
ble_ctrl_task 3 3072 B 定时重启扫描、管理连接轮询 vTaskDelay()
BTC task(协议栈) 系统给 CONFIG_BT_BTC_TASK_STACK_SIZE 默认 3072 B 跑你的 GAP/GATTC 回调 不归你管
esp-mqtt 内部任务 系统给 CONFIG_MQTT_TASK_STACK_SIZE 收发 MQTT 报文、调你的事件回调 不归你管

为什么规则任务优先级要高于 MQTT 任务? 因为联动是"实时"需求,上报不是。湿度掉到 28% 的时候,我要的是加湿器 100 ms 内开起来,而不是"等这条 MQTT 报文发完再说"。优先级的本质就是回答一句话:两件事撞在一起时,你希望先做哪件。

2.3 为什么是两个队列,不是一个

新手(一年前的我)第一反应会这么写:BLE 回调把数据丢进一个队列,然后规则任务和 MQTT 任务都去读这个队列

这是错的,而且是那种"跑起来看着没事、过一会儿数据莫名其妙少一半"的错。FreeRTOS 的队列是消费即出队:一条数据被谁取走了,另一个任务就再也看不见。两个任务抢一个队列的结果不是"都收到",是"随机分走"------你会看到 MQTT 上报的温度一跳一跳的,因为一半的样本被规则任务吃掉了。

正确做法有两种:

  1. 串联 (本篇采用):BLE → q_sample → 规则任务 →(需要上报的)→ q_report → MQTT 任务。规则任务是唯一消费者,它顺手转发一份。简单、顺序确定、好调试。
  2. 一对多广播 :用 FreeRTOS 的 Stream Buffer/Message Buffer 也做不到广播,得自己写"给每个订阅者各一个队列",或者用 ESP-IDF 的 esp_event 事件循环。设备多、消费者多的时候再上。

💡 关于队列、信号量、任务通知怎么选,本站 FreeRTOS实时操作系统 那篇《任务通信详解》讲得更细。这里只提炼一条规则:要传"数据"用队列,要传"事件发生了"用任务通知或信号量,要保护"共享变量"用互斥量。 这篇三样都会用到。


三、BLE 扫描与 GATT 读取(本篇技术核心)

3.1 先解决"读谁":标准 ESS 服务

想让网关读到温湿度,前提是传感器愿意用你认识的格式 说话。蓝牙 SIG 为这件事定义了标准服务,叫 Environmental Sensing Service(环境感知服务,ESS)

名称 UUID 数据类型 换算
Environmental Sensing Service 0x181A --- 服务本身
Temperature 特征 0x2A6E sint16(有符号!) 值 ÷ 100 = ℃,例如 1200 → 12.00 ℃
Humidity 特征 0x2A6F uint16 值 ÷ 100 = %RH,例如 6233 → 62.33 %RH
Client Characteristic Configuration(CCCD) 0x2902 uint16 0x0001 开启 notify

两个必须记住的细节,错了就是玄学数据:

  • 温度是有符号的 (sint16)。零下 5 ℃ 传过来是 -500,也就是 0xFE0C。你要是当成 uint16 解析,会读出 65036,再除 100 得到 650.36 ℃------冬天在阳台放一个传感器就能复现这个 bug。
  • BLE 全部是小端序 (低字节在前)。0xB0 0x040x04B0 = 1200 = 12.00 ℃,不是 0xB004

⚠️ 现实提醒:不是所有蓝牙温湿度计都实现了 ESS。 很多几十块的杂牌用的是自家私有格式(甚至把数据塞在广播包的厂商自定义字段 0xFF 里)。开工前先用手机装个 nRF Connect(Nordic 官方 App)连上去看一眼:能看到 0x181A 服务的,本篇代码直接能用;看不到的,要么刷第三方固件(小米 LYWSD03MMC 常见的 ATC/pvvx 固件就会用 Service Data 0x16 广播出来),要么老老实实自己做一个------用 SHT30 + 一块 BLE 透传模块(或者再来一块 ESP32 跑 GATT Server)自己发 ESS 数据,也完全可行,而且调试起来最省心,因为两端格式都是你自己定的。

3.2 最重要的一件事:BLE 回调跑在谁身上

这一节如果只能记一句话,请记这句:

你注册的 esp_ble_gap_register_callback() / esp_ble_gattc_register_callback() 回调,不在你的任务里执行,它在 Bluedroid 协议栈自己的 BTC 任务里执行。

不是我猜的。ESP-IDF 的 menuconfig 里有一项配置,名字直接把答案写脸上了(components/bt/host/bluedroid/Kconfig.in):

text 复制代码
config BT_BTC_TASK_STACK_SIZE
    int "Bluetooth event (callback to application) task stack size"
    default 3072

"Bluetooth event (callback to application) task stack size"------这个 3072 字节的栈,就是你的回调函数用的栈。由此推出三条铁律:

铁律一:回调里不许阻塞。 你在回调里 vTaskDelay(1000),睡的不是"你的任务",是整个蓝牙事件分发任务。这一秒里所有扫描结果、连接完成、通知数据全部堵在后面排队,轻则丢广播包,重则协议栈内部超时、连接直接断掉。

铁律二:回调里不许吃栈。 只有 3 KB。你在里面定义一个 char buf[2048] 或者调用一串 printf("%f")(浮点格式化本身就很吃栈),栈溢出的现场会出现在一个你完全没写过的函数里,然后你对着 Guru Meditation Error 查一整晚。

铁律三:回调给你的指针,出了这个函数就作废。 scan_rst.ble_advp_data->read.valuep_data->notify.value 全都是协议栈内部缓冲区,回调一返回就可能被下一包数据覆盖。要用就当场 memcpy 走。

对比一下两种写法:

c 复制代码
/* ❌ 反面教材:新手最容易写出来的样子(会把蓝牙卡死) */
static void gap_cb_bad(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param)
{
    if (event == ESP_GAP_BLE_SCAN_RESULT_EVT) {
        char json[1024];                       // 1 KB 栈,占了 BTC 栈的三分之一
        build_json(json, sizeof(json), param); // 慢
        esp_mqtt_client_publish(s_client, "home/env", json, 0, 1, 0); // 可能阻塞几百 ms
        vTaskDelay(pdMS_TO_TICKS(100));        // 直接让整个协议栈睡 100 ms
    }
}

/* ✅ 正确姿势:回调里只做"过滤 + 拷贝 + 投递"三件事,全程不阻塞 */
static void gap_cb_good(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param)
{
    if (event != ESP_GAP_BLE_SCAN_RESULT_EVT) {
        return;
    }
    if (param->scan_rst.search_evt != ESP_GAP_SEARCH_INQ_RES_EVT) {
        return;
    }

    sensor_sample_t s = { 0 };                       // 24 字节左右,放栈上没压力
    memcpy(s.mac, param->scan_rst.bda, ESP_BD_ADDR_LEN);
    s.rssi = (int8_t)param->scan_rst.rssi;

    /* 超时给 0:宁可丢一包数据,也绝不让协议栈等我 */
    if (xQueueSend(s_q_sample, &s, 0) != pdTRUE) {
        s_drop_cnt++;                                // 丢包计数,后面上报出去便于排查
    }
}

注意 xQueueSend(..., 0) 里那个 0。这里有个特别容易混淆的点,我当时也想错过:

BTC 回调算中断吗?要不要用 xQueueSendFromISR()

不算,不要用。 BTC task 是一个货真价实的 FreeRTOS 任务,不是中断服务程序。在这里调用 xQueueSendFromISR() 是错误用法。正确做法是用普通的 xQueueSend(),但把超时参数写成 0 ------普通 API 保证语义正确,超时 0 保证不阻塞。

真正需要 ...FromISR() 的是 GPIO 中断、定时器中断那种硬件中断上下文。这两件事很多人会搅在一起:"不能阻塞"不等于"是中断"。

3.3 数据结构与队列:先把契约定下来

c 复制代码
/* ===== gateway_types.h ===== */
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include "esp_bt_defs.h"     /* ESP_BD_ADDR_LEN */

#define SAMPLE_FLAG_TEMP  (1U << 0)
#define SAMPLE_FLAG_HUMI  (1U << 1)

/* BLE 侧 → 规则任务 的数据包。
 * 全部用定点整数,不用 float:
 *  1) 队列是按值拷贝的,结构体越小越省内存、拷贝越快;
 *  2) 定点整数比较不会有浮点误差,规则判断更稳;
 *  3) ESS 原始格式本来就是 x100 的整数,不转换反而少一次误差。 */
typedef struct {
    uint8_t  mac[ESP_BD_ADDR_LEN];  /* 6 字节,用来区分是哪个传感器 */
    int16_t  temp_x100;             /* 有符号!-500 = -5.00 ℃ */
    uint16_t humi_x100;             /* 6233 = 62.33 %RH */
    uint8_t  flags;                 /* 哪几个字段有效 */
    int8_t   rssi;                  /* 顺手带上信号强度,排查距离问题用 */
    int64_t  ts_us;                 /* esp_timer_get_time(),判断数据新鲜度 */
} sensor_sample_t;

3.4 蓝牙初始化:一条不能乱的顺序

ESP-IDF 的蓝牙初始化是四层剥洋葱 ,顺序错一步就返回 ESP_ERR_INVALID_STATE。下面这段和乐鑫官方 examples/bluetooth/bluedroid/ble/gatt_clientapp_main() 顺序一致:

c 复制代码
/* ===== ble_gw.c(ESP-IDF v5.x / Bluedroid 主机)===== */
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "nvs_flash.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_bt.h"
#include "esp_bt_main.h"
#include "esp_gap_ble_api.h"
#include "esp_gattc_api.h"
#include "esp_gatt_defs.h"
#include "gateway_types.h"

static const char *TAG = "ble_gw";

#define ESS_SERVICE_UUID   0x181A   /* Environmental Sensing Service */
#define ESS_CHAR_TEMP_UUID 0x2A6E   /* Temperature, sint16, 0.01 ℃ */
#define ESS_CHAR_HUMI_UUID 0x2A6F   /* Humidity,    uint16, 0.01 % */
#define GATTC_APP_ID       0

QueueHandle_t s_q_sample;                 /* BLE → 规则任务 */
static volatile uint32_t s_drop_cnt;      /* 丢包计数,多任务读写,加 volatile */

/* ⚠️ 必须是 static/全局:官方文档明确写了
 * "Memory cannot be freed until callback of set_scan_params"。
 * 放成局部变量 → 函数返回栈销毁 → 协议栈读到垃圾参数。 */
static esp_ble_scan_params_t s_scan_params = {
    .scan_type          = BLE_SCAN_TYPE_ACTIVE,      /* 主动扫描,会发 SCAN_REQ 要 scan response */
    .own_addr_type      = BLE_ADDR_TYPE_PUBLIC,
    .scan_filter_policy = BLE_SCAN_FILTER_ALLOW_ALL,
    .scan_interval      = 0x50,   /* 0x50 × 0.625 ms = 50 ms */
    .scan_window        = 0x30,   /* 0x30 × 0.625 ms = 30 ms,占空比 60% */
    .scan_duplicate     = BLE_SCAN_DUPLICATE_DISABLE, /* 同一设备重复广播也报上来 */
};

void ble_gw_init(void)
{
    /* 0) NVS:蓝牙和 Wi-Fi 都要用它存校准/配置参数,忘了初始化会直接起不来 */
    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        ret = nvs_flash_init();
    }
    ESP_ERROR_CHECK(ret);

    /* 1) 只用 BLE,把经典蓝牙那份内存还给系统(能省下几十 KB DRAM) */
    ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT));

    /* 2) 控制器 */
    esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_bt_controller_init(&bt_cfg));
    ESP_ERROR_CHECK(esp_bt_controller_enable(ESP_BT_MODE_BLE));

    /* 3) 主机协议栈 Bluedroid */
    ESP_ERROR_CHECK(esp_bluedroid_init());
    ESP_ERROR_CHECK(esp_bluedroid_enable());

    /* 4) 注册回调 + 注册 GATTC 应用(回调从这一刻起就会在 BTC 任务里被调) */
    ESP_ERROR_CHECK(esp_ble_gap_register_callback(gap_cb));
    ESP_ERROR_CHECK(esp_ble_gattc_register_callback(gattc_cb));
    ESP_ERROR_CHECK(esp_ble_gattc_app_register(GATTC_APP_ID));

    /* 5) 设置扫描参数;成功后会收到 ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT,
     *    到那时才能真正开始扫描------这是异步状态机,别在这里直接 start_scanning */
    ESP_ERROR_CHECK(esp_ble_gap_set_scan_params(&s_scan_params));
}

⚠️ 这五步里最容易被跳过的是 ESP_ERROR_CHECK 。我知道很多人嫌它啰嗦------直到你的 esp_bluedroid_enable() 因为内存不够静默失败,程序继续往下跑,然后在半小时后某个完全无关的地方崩掉。ESP_ERROR_CHECK 会在出错的那一行把文件名行号打出来然后 abort,这是初始化阶段最划算的一行代码。(当然,运行时的可恢复错误不能用它------那会让设备直接重启,见 4.3。)

3.5 GAP 回调:扫描 → 筛出 ESS 设备 → 发起连接

c 复制代码
static bool         s_connecting = false;  /* 只在 BTC 任务里读写,无需加锁 */
static esp_gatt_if_t s_gattc_if  = ESP_GATT_IF_NONE;   /* 类型就是 esp_gatt_if_t,别图省事写 uint16_t */

/* 判断这包广播里有没有 ESS(0x181A)。BLE 广播是 [len][type][data...] 的 TLV 串,
 * 官方给了现成的解析函数,别自己撸循环。 */
static bool adv_has_ess(uint8_t *adv)
{
    uint8_t  list_len = 0;
    /* 完整 16-bit 服务 UUID 列表,AD type = 0x03 */
    uint8_t *list = esp_ble_resolve_adv_data(adv, ESP_BLE_AD_TYPE_16SRV_CMPL, &list_len);
    if (list == NULL) {
        /* 有些设备只发部分列表,AD type = 0x02,也查一下 */
        list = esp_ble_resolve_adv_data(adv, ESP_BLE_AD_TYPE_16SRV_PART, &list_len);
    }
    if (list == NULL || list_len < 2) {
        return false;
    }
    for (uint8_t i = 0; i + 1 < list_len; i += 2) {
        uint16_t uuid = (uint16_t)list[i] | ((uint16_t)list[i + 1] << 8);  /* 小端 */
        if (uuid == ESS_SERVICE_UUID) {
            return true;
        }
    }
    return false;
}

esp_ble_resolve_adv_data() 自己会按 TLV 走到广播数据末尾,所以不用把长度传给它;它找不到就返回 NULL返回值必须判空,这是最容易漏的一处。

接下来是扫描状态机。BLE 扫描是"设参数 → 收到设置完成事件 → 开扫 → 到期 → 再开扫"的异步循环,不是 while(1) scan()

c 复制代码
/* 注意:C 语言要求先声明后使用。gap_cb / gattc_cb 的原型必须写在 ble_gw_init()
 * 前面(static void gap_cb(esp_gap_ble_cb_event_t, esp_ble_gap_cb_param_t *);),
 * 否则编译器会报隐式声明。 */
#define SCAN_DURATION_S  30      /* 单次扫描 30 秒,到期后重开,便于插入连接动作 */

static void gap_cb(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param)
{
    switch (event) {
    case ESP_GAP_BLE_SCAN_PARAM_SET_COMPLETE_EVT:
        /* 参数设置成功,现在才可以开扫 */
        if (esp_ble_gap_start_scanning(SCAN_DURATION_S) != ESP_OK) {
            ESP_LOGE(TAG, "start scan failed");
        }
        break;

    case ESP_GAP_BLE_SCAN_START_COMPLETE_EVT:
        if (param->scan_start_cmpl.status != ESP_BT_STATUS_SUCCESS) {
            ESP_LOGE(TAG, "scan start err=%d", param->scan_start_cmpl.status);
        }
        break;

    case ESP_GAP_BLE_SCAN_RESULT_EVT:
        switch (param->scan_rst.search_evt) {
        case ESP_GAP_SEARCH_INQ_RES_EVT:                 /* 扫到一个设备 */
            if (s_connecting || s_gattc_if == ESP_GATT_IF_NONE) {
                break;                                   /* 正在连别人,先不管 */
            }
            if (!adv_has_ess(param->scan_rst.ble_adv)) {
                break;                                   /* 不是我们要的设备 */
            }
            ESP_LOGI(TAG, "found ESS dev %02x:%02x:%02x:%02x:%02x:%02x rssi=%d",
                     param->scan_rst.bda[0], param->scan_rst.bda[1],
                     param->scan_rst.bda[2], param->scan_rst.bda[3],
                     param->scan_rst.bda[4], param->scan_rst.bda[5],
                     param->scan_rst.rssi);
            s_connecting = true;
            esp_ble_gap_stop_scanning();                 /* 边扫边连成功率低,先停 */
            /* 这几个 API 都是"发个命令就返回"的异步函数,不阻塞,
             * 所以在回调里调用是安全的------禁止的是"等待",不是"调用"。 */
            esp_ble_gattc_open(s_gattc_if,
                               param->scan_rst.bda,
                               param->scan_rst.ble_addr_type,  /* 地址类型别写死 PUBLIC */
                               true);                          /* is_direct */
            break;

        case ESP_GAP_SEARCH_INQ_CMPL_EVT:                /* 30 秒到了 */
            if (!s_connecting) {
                esp_ble_gap_start_scanning(SCAN_DURATION_S);
            }
            break;

        default:
            break;
        }
        break;

    case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT:
        if (param->scan_stop_cmpl.status != ESP_BT_STATUS_SUCCESS) {
            ESP_LOGW(TAG, "scan stop err=%d", param->scan_stop_cmpl.status);
        }
        break;

    default:
        break;
    }
}

⚠️ esp_ble_gattc_open() 的第三个参数是 ble_addr_type一定要用扫描结果里带回来的那个 ,别自己填 BLE_ADDR_TYPE_PUBLIC。现在市面上的传感器为了防追踪,大量使用随机地址(Random Static / RPA),地址类型填错的表现是"连接请求发出去了,对方完全不理你",然后你会去怀疑天线、怀疑距离、怀疑电池------就是不会怀疑这个参数。(我怀疑过前面三个。)

3.6 GATTC 回调:连上之后怎么把两个数读出来

连接建立之后的流程是一条固定的流水线,每一步都由一个事件驱动下一步:

text 复制代码
esp_ble_gattc_open()
   └→ ESP_GATTC_CONNECT_EVT      物理连接建立(拿到 conn_id)
   └→ ESP_GATTC_OPEN_EVT         逻辑通道打开,检查 status
        └→ esp_ble_gattc_search_service(过滤 0x181A)
             └→ ESP_GATTC_SEARCH_RES_EVT    命中服务,记下 start/end handle
             └→ ESP_GATTC_SEARCH_CMPL_EVT   搜完了
                  └→ get_char_by_uuid(0x2A6E) → esp_ble_gattc_read_char()
                       └→ ESP_GATTC_READ_CHAR_EVT  拿到温度,接着读 0x2A6F
                            └→ ESP_GATTC_READ_CHAR_EVT 拿到湿度
                                 └→ 投队列 + esp_ble_gattc_close()
                                      └→ ESP_GATTC_DISCONNECT_EVT → 重新开扫

代码(为了聚焦主线,我这里只连一个传感器就断开、再扫下一个,也就是轮询式采集;好处是不受同时连接数限制,坏处是采样间隔变长。多路同时连的做法见 3.7):

c 复制代码
static uint16_t s_conn_id;
static uint16_t s_svc_start, s_svc_end;
static uint16_t s_h_temp, s_h_humi;
static sensor_sample_t s_pending;

/* 小端解析。绝对不要写 *(uint16_t *)v ------
 * value 是协议栈缓冲区里的任意地址,强转指针在 Xtensa 上可能非对齐访问,
 * 而且违反严格别名规则,属于未定义行为。逐字节拼是唯一稳的写法。 */
static bool le16(const uint8_t *v, uint16_t len, uint16_t *out)
{
    if (v == NULL || len < 2) {
        return false;
    }
    *out = (uint16_t)v[0] | ((uint16_t)v[1] << 8);
    return true;
}

/* 在已定位的服务范围内按 UUID 找特征,返回 handle;找不到返回 0。
 * 用静态数组而不是 malloc:RTOS 任务里能不动态分配就不动态分配。 */
static uint16_t find_char_handle(esp_gatt_if_t gattc_if, uint16_t conn_id, uint16_t uuid16)
{
    esp_gattc_char_elem_t elems[4];
    uint16_t count = sizeof(elems) / sizeof(elems[0]);   /* 入参=想找几个,出参=找到几个 */
    esp_bt_uuid_t uuid = { .len = ESP_UUID_LEN_16, .uuid = { .uuid16 = uuid16 } };

    esp_gatt_status_t st = esp_ble_gattc_get_char_by_uuid(
            gattc_if, conn_id, s_svc_start, s_svc_end, uuid, elems, &count);
    if (st != ESP_GATT_OK || count == 0) {
        ESP_LOGW(TAG, "char 0x%04x not found (st=%d)", uuid16, st);
        return 0;
    }
    return elems[0].char_handle;
}

static void gattc_cb(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if,
                     esp_ble_gattc_cb_param_t *param)
{
    switch (event) {
    case ESP_GATTC_REG_EVT:                    /* app_register 的回执,拿到 gattc_if */
        if (param->reg.status == ESP_GATT_OK) {
            s_gattc_if = gattc_if;
        }
        break;

    case ESP_GATTC_CONNECT_EVT:
        s_conn_id = param->connect.conn_id;
        break;

    case ESP_GATTC_OPEN_EVT:
        if (param->open.status != ESP_GATT_OK) {
            ESP_LOGW(TAG, "open failed, st=%d", param->open.status);
            s_connecting = false;
            esp_ble_gap_start_scanning(SCAN_DURATION_S);   /* 失败要能自己爬起来 */
            break;
        }
        memset(&s_pending, 0, sizeof(s_pending));
        memcpy(s_pending.mac, param->open.remote_bda, ESP_BD_ADDR_LEN);
        s_h_temp = s_h_humi = 0;
        {
            esp_bt_uuid_t svc = { .len = ESP_UUID_LEN_16,
                                  .uuid = { .uuid16 = ESS_SERVICE_UUID } };
            esp_ble_gattc_search_service(gattc_if, param->open.conn_id, &svc);
        }
        break;

    case ESP_GATTC_SEARCH_RES_EVT:
        if (param->search_res.srvc_id.uuid.len == ESP_UUID_LEN_16 &&
            param->search_res.srvc_id.uuid.uuid.uuid16 == ESS_SERVICE_UUID) {
            s_svc_start = param->search_res.start_handle;
            s_svc_end   = param->search_res.end_handle;
        }
        break;

    case ESP_GATTC_SEARCH_CMPL_EVT:
        if (s_svc_start == 0) {                 /* 广播说有 ESS,实际却没有 */
            ESP_LOGW(TAG, "no ESS service, drop");
            esp_ble_gattc_close(gattc_if, s_conn_id);
            break;
        }
        s_h_temp = find_char_handle(gattc_if, s_conn_id, ESS_CHAR_TEMP_UUID);
        s_h_humi = find_char_handle(gattc_if, s_conn_id, ESS_CHAR_HUMI_UUID);
        if (s_h_temp == 0) {
            esp_ble_gattc_close(gattc_if, s_conn_id);
            break;
        }
        /* 同一条连接上,一次只发一个 ATT 请求,读完一个再读下一个 */
        esp_ble_gattc_read_char(gattc_if, s_conn_id, s_h_temp, ESP_GATT_AUTH_REQ_NONE);
        break;
c 复制代码
    /* --------- 接上一段 switch --------- */
    case ESP_GATTC_READ_CHAR_EVT: {
        if (param->read.status != ESP_GATT_OK) {
            ESP_LOGW(TAG, "read err=%d", param->read.status);
            esp_ble_gattc_close(gattc_if, s_conn_id);
            break;
        }
        uint16_t raw = 0;
        if (param->read.handle == s_h_temp && le16(param->read.value, param->read.value_len, &raw)) {
            s_pending.temp_x100 = (int16_t)raw;      /* ★ 转成有符号,负温度全靠这一步 */
            s_pending.flags    |= SAMPLE_FLAG_TEMP;
            if (s_h_humi != 0) {                     /* 接着读湿度 */
                esp_ble_gattc_read_char(gattc_if, s_conn_id, s_h_humi, ESP_GATT_AUTH_REQ_NONE);
                break;
            }
        } else if (param->read.handle == s_h_humi &&
                   le16(param->read.value, param->read.value_len, &raw)) {
            s_pending.humi_x100 = raw;               /* 湿度是无符号 */
            s_pending.flags    |= SAMPLE_FLAG_HUMI;
        }
        /* 数据齐了:打时间戳 → 投队列 → 立刻断开,去扫下一个 */
        s_pending.ts_us = esp_timer_get_time();
        if (xQueueSend(s_q_sample, &s_pending, 0) != pdTRUE) {
            s_drop_cnt++;
        }
        esp_ble_gattc_close(gattc_if, s_conn_id);
        break;
    }

    case ESP_GATTC_DISCONNECT_EVT:
        ESP_LOGI(TAG, "disconnected, reason=0x%x", param->disconnect.reason);
        s_connecting = false;
        s_svc_start  = 0;
        esp_ble_gap_start_scanning(SCAN_DURATION_S);   /* 回到扫描态,闭环 */
        break;

    default:
        break;
    }
}

几个容易踩空的点:

  • param->read.value 出了这个 case 就不保证有效 ,所以我在这里当场解析成整数存进 s_pending,而不是存指针。
  • esp_ble_gattc_close() 之后不会立刻断开,要等 ESP_GATTC_DISCONNECT_EVT 才算真的结束,状态机复位放在那里做,别放在 close() 后面。
  • param->disconnect.reason 很有用:0x3E(连接建立失败)通常是对方睡着了没在广播,0x08(连接超时)多半是距离太远或者被 Wi-Fi 挤掉了。
  • 如果传感器支持 notify ,用 esp_ble_gattc_register_for_notify() + 往 CCCD(UUID 0x2902)写 0x0001 开启,就能从"我每 30 秒问一次"变成"它有新数据主动推给我",更省电也更及时。代价是要一直保持连接。本篇主线用轮询读,是因为它对传感器的兼容性最好,也更容易看懂。

3.7 多个传感器怎么办:轮询 vs 多连接

做法 怎么实现 优点 缺点
轮询式(本篇) 连 A→读→断→连 B→读→断... 不受连接数限制,代码是单状态机,好调 传感器一多,单个的采样间隔线性变长
多连接 一次开多个 app_id/profile,同时保持 N 条连接 数据实时,可用 notify 受连接数上限约束,状态机要按 conn_id 分表管理
只听广播 完全不连接,直接解析广播里的 Service Data(AD type 0x16 最省事、最省电、天然支持无限多设备 依赖传感器愿意把数据广播出来(标准 ESS 不要求)

多连接的上限由两个配置共同决定,别只改一个:控制器侧 CONFIG_BTDM_CTRL_BLE_MAX_CONN("BLE Max Connections",默认 3 ,范围 19,每条连接常驻占用 1 KB DRAM ),主机侧 CONFIG_BT_ACL_CONNECTIONS("BT/BLE MAX ACL CONNECTIONS(19)",默认 4)。实际能连几个,取小的那个。

⚠️ 还有一个绕不开的物理事实:ESP32 上 Wi-Fi 和 BLE 共用同一个 2.4 GHz 射频。 乐鑫《ESP-IDF 编程指南》的 RF Coexistence 一章写得很直白:"Each type of board has only one 2.4 GHz ISM band RF module, shared by two or three modules",所以芯片是靠时分复用 在两者之间切换的。文档里的共存支持表显示 Wi-Fi STA(扫描/连接中/已连接)× BLE(扫描/广播/连接中/已连接)全是 Y------能同时用,但性能会互相打折 。三条实用结论:

① menuconfig 里必须打开 CONFIG_ESP_COEX_SW_COEXIST_ENABLE,否则共存机制根本没启用;

② 官方建议把蓝牙控制器/主机任务和 Wi-Fi 任务放到不同的 CPU 核 上(CONFIG_BTDM_CTRL_PINNED_TO_CORE_CHOICECONFIG_BT_BLUEDROID_PINNED_TO_CORE_CHOICECONFIG_ESP_WIFI_TASK_CORE_ID);

③ 你会实实在在观察到"Wi-Fi 一忙,BLE 就漏包"------所以扫描到的数据必须允许丢 ,你的规则引擎不能假设"每 30 秒一定有新数据",这就是下面 ts_us 时间戳存在的理由。


四、Wi-Fi + MQTT:把数据送出去,但不让它拖累联动

4.1 为什么是 MQTT

网关要把数据交给谁?两个选择:自己写 HTTP 往服务器 POST,或者用 MQTT。做家里这种场景,MQTT 几乎是唯一合理答案:

  • 长连接:不用每次上报都握手一次 TCP+TLS,几十字节的数据不至于花几 KB 的开销;
  • 发布/订阅 :网关只管往 home/env/<mac>/state 这个主题发,谁想要谁自己订阅。Home Assistant、Node-RED、你自己写的手机 App 可以同时订阅,网关一行代码都不用改;
  • 遗嘱消息(LWT) :这是我最喜欢的特性------你可以提前告诉服务器"如果我掉线了,请替我发一条 offline"。设备意外断电时,订阅方立刻知道网关没了,而不是对着一个 20 分钟没更新的温度值发呆。

ESP-IDF 自带 esp-mqtt 组件(esp_mqtt_client),不用自己找库。

4.2 客户端配置:v5.x 的结构体和老教程不一样

网上大量 ESP32 MQTT 教程还在写 .uri = "mqtt://...",那是 ESP-IDF v4.x 的扁平结构。v5.x 改成了分组嵌套broker / credentials / session / network / task / buffer / outbox),照抄老代码会直接编译报错"struct has no member named 'uri'":

c 复制代码
/* ===== mqtt_gw.c(ESP-IDF v5.x)===== */
#include "mqtt_client.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "gateway_types.h"

static const char *TAG = "mqtt_gw";
static esp_mqtt_client_handle_t s_client;
/* 跨任务共享的连接状态:MQTT 任务写、mqtt_task 读,必须 volatile,
 * 否则编译器可能把它缓存在寄存器里,你会看到"明明连上了却一直判断为没连上" */
static volatile bool s_mqtt_connected = false;

QueueHandle_t s_q_report;      /* 规则任务 → MQTT 任务 */

static void mqtt_event_handler(void *handler_args, esp_event_base_t base,
                               int32_t event_id, void *event_data)
{
    switch ((esp_mqtt_event_id_t)event_id) {
    case MQTT_EVENT_CONNECTED:
        s_mqtt_connected = true;
        ESP_LOGI(TAG, "broker connected");
        /* 上线宣告,retain=1,让后来订阅的人也能看到 */
        esp_mqtt_client_publish(s_client, "home/gw/status", "online", 0, 1, 1);
        break;
    case MQTT_EVENT_DISCONNECTED:
        s_mqtt_connected = false;
        ESP_LOGW(TAG, "broker lost");   /* 注意:这里什么都不用做,重连是自动的 */
        break;
    case MQTT_EVENT_ERROR:
        ESP_LOGE(TAG, "mqtt error");
        break;
    default:
        break;
    }
}

void mqtt_gw_start(void)
{
    esp_mqtt_client_config_t cfg = {
        .broker.address.uri = "mqtt://192.168.1.10:1883",   /* 换成你自己的 broker */
        .credentials = {
            .client_id = "esp32-env-gw",
            .username  = "gw",
            .authentication.password = "在这里填你的密码",
        },
        .session = {
            .keepalive = 30,                 /* 秒;默认 120,家里网络抖建议调小 */
            .last_will = {                   /* 遗嘱:意外掉线时由 broker 代发 */
                .topic  = "home/gw/status",
                .msg    = "offline",
                .msg_len = 0,                /* 0 = 按字符串长度自动算 */
                .qos    = 1,
                .retain = 1,
            },
        },
        .network.reconnect_timeout_ms = 5000, /* 默认 10000,这里改成 5 秒重试一次 */
    };

    s_client = esp_mqtt_client_init(&cfg);
    if (s_client == NULL) {                  /* 返回值是句柄,失败给 NULL,必须判 */
        ESP_LOGE(TAG, "mqtt init failed");
        return;
    }
    ESP_ERROR_CHECK(esp_mqtt_client_register_event(s_client, ESP_EVENT_ANY_ID,
                                                   mqtt_event_handler, NULL));
    ESP_ERROR_CHECK(esp_mqtt_client_start(s_client));
}

⚠️ 密码别硬编码 。上面那行 "在这里填你的密码" 是为了演示,真要用请放 NVS 或编译期从 sdkconfig 注入------不然哪天你把工程传 GitHub,密码就跟着一起上去了。这和 固件升级与OTA 里说的"签名私钥不能进仓库"是同一个道理。

4.3 断网了怎么办:什么都不用做(但你得知道为什么)

新手最容易在这里写一堆自己的重连逻辑:定时器检测断线、断了就 esp_mqtt_client_stop()start()......完全没必要,而且会把事情搞砸。 官方文档写得很清楚:network.disable_auto_reconnect 默认是 false,也就是自动重连默认开着 ,断线后会按 network.reconnect_timeout_ms(默认 10 秒)自动重试。

还有一条硬性限制要记住:esp_mqtt_client_stop()esp_mqtt_client_destroy() 不能在 MQTT 事件回调里调用 (官方明确禁止),因为那等于让一个任务在回调里把自己销毁掉------和 3.2 节 BLE 回调的道理完全一样:回调是别人的任务借给你的,别在里面做主。

4.4 上报任务:一个不会拖慢联动的发布循环

c 复制代码
/* MAC 转成 "aabbccddeeff",当作主题里的设备 ID */
static void mac_to_str(const uint8_t mac[6], char *out, size_t n)
{
    snprintf(out, n, "%02x%02x%02x%02x%02x%02x",
             mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}

void mqtt_task(void *arg)
{
    sensor_sample_t s;
    char topic[64];
    char payload[128];
    char id[13];

    for (;;) {
        /* 唯一的阻塞点:没数据就在这儿睡,不占 CPU */
        if (xQueueReceive(s_q_report, &s, portMAX_DELAY) != pdTRUE) {
            continue;
        }
        if (!s_mqtt_connected) {
            /* 断网就直接丢。环境数据是"易腐品",攒一堆旧温度补发没有意义,
             * 反而会在重连瞬间把内存和 outbox 撑爆。真需要补发就自己做环形缓冲。 */
            continue;
        }

        /* 整数转两位小数,不用 %f:
         * 一是 menuconfig 里若开了 CONFIG_NEWLIB_NANO_FORMAT,%f 直接打不出来;
         * 二是浮点格式化又慢又吃栈。负数要单独处理符号,
         * 否则 -0.5 ℃ 会被打印成 "0.50"(因为 -50/100 == 0)。 */
        int tv = s.temp_x100;
        int t_i = tv / 100;
        int t_f = tv % 100;
        if (t_f < 0) {
            t_f = -t_f;
        }
        const char *sign = (tv < 0 && t_i == 0) ? "-" : "";

        mac_to_str(s.mac, id, sizeof(id));
        snprintf(topic, sizeof(topic), "home/env/%s/state", id);
        snprintf(payload, sizeof(payload),
                 "{\"temp\":%s%d.%02d,\"humi\":%u.%02u,\"rssi\":%d}",
                 sign, t_i, t_f,
                 (unsigned)(s.humi_x100 / 100), (unsigned)(s.humi_x100 % 100),
                 s.rssi);

        /* 返回值:>=0 是 msg_id(QoS0 固定返回 0),-1 失败,-2 outbox 满 */
        int msg_id = esp_mqtt_client_publish(s_client, topic, payload, 0, 0, 0);
        if (msg_id < 0) {
            ESP_LOGW(TAG, "publish failed: %d", msg_id);
        }
    }
}

💡 想接 Home Assistant 的话,把主题改成 HA 的 MQTT Discovery 格式(往 homeassistant/sensor/<id>/config 发一条 retain 的配置消息),HA 就会自动把设备加出来,不用手写 YAML。这属于锦上添花,先把上面这条 home/env/... 跑通再说。


五、本地规则引擎:断网也要能干活的那部分

5.1 先想清楚:规则引擎到底是什么

听起来很唬人,其实就是一张表 + 一个循环

text 复制代码
        ┌──────────────────────────────────────────────┐
   数据 →│  条件(condition)  →  判定  →  动作(action)   │→ GPIO
        └──────────────────────────────────────────────┘
             湿度 < 30%         真         开加湿器
             温度 > 30℃         真         开风扇

难点从来不在"怎么判断大于小于",而在这三件事:

  1. 数据是异步来的、还会丢------不能假设每次循环都有新数据;
  2. 判断结果会在阈值附近抖------湿度在 29.9% 和 30.1% 之间来回跳,继电器就会"哒哒哒"响;
  3. 状态是多个任务共享的------规则任务在写,MQTT 任务想读,一不小心就读到半截数据。

下面逐个解决。

5.2 共享状态:volatile 不是锁

先说一个我以前理解错的点,这也是 C 语言面试的高频陷阱:

volatile 只保证"每次都真的去内存读/写",不保证"读写是原子的",更不保证"一组变量的一致性"。

具体到这里:状态表里每个传感器有温度、湿度、时间戳三个字段。规则任务正在更新温度的一瞬间,MQTT 任务跑来读了整个结构体------它会拿到"新温度 + 旧湿度"这种从未真实存在过的组合。给结构体加 volatile 一点用都没有,因为问题不是"读到缓存的旧值",而是"读到了更新到一半的状态"。

需要的是互斥量(mutex):

c 复制代码
/* ===== rule_engine.c ===== */
#include <string.h>                 /* memcmp / memcpy */
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "esp_timer.h"
#include "esp_log.h"
#include "gateway_types.h"

static const char *TAG = "rule";

#define MAX_SENSORS      4
#define DATA_TIMEOUT_US  (5 * 60 * 1000000LL)   /* 5 分钟没数据就认为这个点失联 */

typedef struct {
    uint8_t  mac[6];
    bool     used;
    int16_t  temp_x100;
    uint16_t humi_x100;
    uint8_t  flags;
    int64_t  ts_us;
} sensor_state_t;

static sensor_state_t s_states[MAX_SENSORS];
static SemaphoreHandle_t s_state_mtx;           /* 保护 s_states 整体的一致性 */

/* 把一条样本合并进状态表。临界区里只做内存拷贝,不打日志、不发 MQTT------
 * 临界区越短越好,这是 C语言 那篇讲临界区时反复强调的。 */
static void state_update(const sensor_sample_t *s)
{
    if (xSemaphoreTake(s_state_mtx, pdMS_TO_TICKS(100)) != pdTRUE) {
        ESP_LOGE(TAG, "mutex timeout");          /* 拿不到锁要报出来,不能默默跳过 */
        return;
    }
    int slot = -1;
    for (int i = 0; i < MAX_SENSORS; i++) {
        if (s_states[i].used && memcmp(s_states[i].mac, s->mac, 6) == 0) {
            slot = i;
            break;
        }
        if (slot < 0 && !s_states[i].used) {
            slot = i;                            /* 记下第一个空位,找不到再用 */
        }
    }
    if (slot >= 0) {
        s_states[slot].used = true;
        memcpy(s_states[slot].mac, s->mac, 6);
        if (s->flags & SAMPLE_FLAG_TEMP) {
            s_states[slot].temp_x100 = s->temp_x100;
        }
        if (s->flags & SAMPLE_FLAG_HUMI) {
            s_states[slot].humi_x100 = s->humi_x100;
        }
        s_states[slot].flags |= s->flags;
        s_states[slot].ts_us  = s->ts_us;
    }
    xSemaphoreGive(s_state_mtx);
}

⚠️ 注意 xSemaphoreTake 我给的是 100 ms 超时,不是 portMAX_DELAY 。一个只做几十字节内存拷贝的临界区,如果 100 ms 还拿不到锁,说明系统已经出事了(死锁 / 优先级反转 / 某个任务在临界区里干了重活)------这时候我要的是一条错误日志把它暴露出来,而不是让规则任务永远挂在这里,加湿器再也不动。任何可能永久阻塞的调用,都要问一句"卡住了怎么办"。

5.3 滞回:让继电器闭嘴的那 5%

如果规则写成 if (humi < 30%) 开加湿器; else 关加湿器;,实际现象会是这样:

text 复制代码
湿度   30.1 → 关 → 加湿器停 → 29.8 → 开 → 加湿器转 → 30.1 → 关 → ...
继电器 哒!          哒!             哒!             哒!

  一分钟响二十次,继电器触点几万次寿命很快就耗光了

解决办法叫滞回(hysteresis) ,思路特别朴素:开的门槛和关的门槛不是同一个数。

text 复制代码
   湿度 %RH
   40 ┤
   35 ┤·······━━━━━ 关闭阈值 35%(回升到这里才关)
      │      ↑     ↓
   30 ┤━━━━━·······  开启阈值 30%(掉到这里才开)
   25 ┤
      └─────────────────────────────→ 时间
      在 30~35 之间:保持当前状态不动,这段就是"死区"

再加一条最短保持时间(开了至少撑 60 秒才允许关),双保险。这两条加起来,就是所有恒温器/空调控制的通用做法。

5.4 规则表:用数据描述逻辑,而不是用 if 堆逻辑

c 复制代码
typedef enum { RULE_SRC_TEMP, RULE_SRC_HUMI } rule_src_t;
typedef enum { RULE_CMP_ABOVE, RULE_CMP_BELOW } rule_cmp_t;

typedef struct {
    const char *name;
    rule_src_t  src;
    rule_cmp_t  cmp;
    int32_t     on_th_x100;      /* 触发阈值 */
    int32_t     off_th_x100;     /* 恢复阈值(和上面差出死区) */
    act_id_t    act;             /* 控制哪个执行器 */
    uint32_t    min_hold_ms;     /* 状态最短保持时间 */
} rule_t;

typedef struct {
    bool    on;
    int64_t changed_us;
} rule_rt_t;

/* 规则表放 const,编译进 Flash,不占 RAM,也防止跑飞时被改坏 */
static const rule_t s_rules[] = {
    /* 名称        源            比较            开     关     执行器        最短保持 */
    { "humidifier", RULE_SRC_HUMI, RULE_CMP_BELOW, 3000, 3500, ACT_HUMIDIFIER, 60000 },
    { "fan",        RULE_SRC_TEMP, RULE_CMP_ABOVE, 3000, 2850, ACT_FAN,        60000 },
};
#define RULE_CNT  (sizeof(s_rules) / sizeof(s_rules[0]))

static rule_rt_t s_rule_rt[RULE_CNT];

写成表而不是写成一串 if,好处在于:加一条规则只要加一行数据 ,不用碰逻辑代码;将来想通过 MQTT 下发规则、或者存到 NVS 里,也只是把这个数组换个来源而已。这就是"数据驱动",比堆 if-else 高级不了多少,但可维护性差很多。

5.5 聚合 + 判定:核心那 40 行

c 复制代码
/* 取所有"数据还新鲜"的传感器里的最高温 / 最低湿。
 * 返回 false 表示没有任何可用数据------这个返回值非常重要,见下面的失联处理。 */
static bool aggregate(rule_src_t src, int32_t *out)
{
    bool    got = false;
    int32_t val = 0;
    int64_t now = esp_timer_get_time();

    if (xSemaphoreTake(s_state_mtx, pdMS_TO_TICKS(100)) != pdTRUE) {
        return false;
    }
    for (int i = 0; i < MAX_SENSORS; i++) {
        if (!s_states[i].used) {
            continue;
        }
        if (now - s_states[i].ts_us > DATA_TIMEOUT_US) {
            continue;                       /* 5 分钟没更新,这个点当失联,不参与决策 */
        }
        if (src == RULE_SRC_TEMP && (s_states[i].flags & SAMPLE_FLAG_TEMP)) {
            int32_t t = s_states[i].temp_x100;
            val = got ? (t > val ? t : val) : t;    /* 温度取最大:哪屋最热就按哪屋来 */
            got = true;
        } else if (src == RULE_SRC_HUMI && (s_states[i].flags & SAMPLE_FLAG_HUMI)) {
            int32_t h = s_states[i].humi_x100;
            val = got ? (h < val ? h : val) : h;    /* 湿度取最小:哪屋最干就按哪屋来 */
            got = true;
        }
    }
    xSemaphoreGive(s_state_mtx);

    if (got) {
        *out = val;
    }
    return got;
}

判定部分:

c 复制代码
static void rules_eval(void)
{
    int64_t now = esp_timer_get_time();

    for (size_t i = 0; i < RULE_CNT; i++) {
        const rule_t *r  = &s_rules[i];
        rule_rt_t    *rt = &s_rule_rt[i];
        int32_t v;

        /* ① 失联保护:所有传感器都超时 → 回到安全默认状态(关掉)。
         *    "没数据"绝不能等于"维持现状",不然传感器电池没电那天,
         *    加湿器可能就一直开着直到你回家。 */
        if (!aggregate(r->src, &v)) {
            if (rt->on) {
                actuator_set(r->act, false);
                rt->on = false;
                rt->changed_us = now;
                ESP_LOGW(TAG, "%s: no fresh data, force OFF", r->name);
            }
            continue;
        }

        /* ② 带滞回的判定:开和关看的是两个不同的阈值 */
        bool want = rt->on;
        if (!rt->on) {
            want = (r->cmp == RULE_CMP_BELOW) ? (v < r->on_th_x100)
                                              : (v > r->on_th_x100);
        } else {
            bool should_off = (r->cmp == RULE_CMP_BELOW) ? (v > r->off_th_x100)
                                                         : (v < r->off_th_x100);
            want = !should_off;
        }
        if (want == rt->on) {
            continue;                      /* 状态没变,什么都不做 */
        }

        /* ③ 最短保持时间:刚切过来不到 60 秒,不许再切回去 */
        if ((now - rt->changed_us) < (int64_t)r->min_hold_ms * 1000) {
            continue;
        }

        actuator_set(r->act, want);
        rt->on = want;
        rt->changed_us = now;
        ESP_LOGI(TAG, "%s -> %s (value=%ld)", r->name, want ? "ON" : "OFF", (long)v);
    }
}

💡 顺带一个白捡的好处:changed_us 初值是 0,而 esp_timer_get_time() 从上电开始计时,所以开机后的头 60 秒里第 ③ 条永远不满足,任何执行器都不会动作 。这正好是一段"上电静默期"------避免设备刚上电、数据还没采全的时候继电器先乱跳一通。想显式一点的话,就在初始化时把 changed_us 设成你想要的静默时长。

5.6 规则任务:为什么不是 portMAX_DELAY

c 复制代码
void rule_engine_task(void *arg)
{
    sensor_sample_t s;

    for (;;) {
        /* 最多等 1 秒。等到了就更新状态,等不到也要往下走跑一遍规则------
         * 因为"最短保持时间到了""数据超时失联了"这两件事的推进
         * 靠的是时间流逝,不是新数据到达。
         * 写成 portMAX_DELAY 的后果:传感器全部掉线之后,
         * 规则任务永远阻塞在这里,失联保护一辈子不会触发。 */
        if (xQueueReceive(s_q_sample, &s, pdMS_TO_TICKS(1000)) == pdTRUE) {
            state_update(&s);
            /* 顺手转发一份给上报任务;队列满就丢,绝不阻塞规则任务 */
            if (xQueueSend(s_q_report, &s, 0) != pdTRUE) {
                ESP_LOGW(TAG, "report queue full, drop");
            }
        }
        rules_eval();
    }
}

这段的注释就是这一节的中心思想:实时控制里,"什么都没发生"本身也是一种需要处理的事件。


六、联动执行:别让 GPIO 直接扛负载

6.1 一个 3.3 V 引脚到底能带动什么

先摆事实。乐鑫《ESP32 Datasheet》的 DC 特性表给的是:高电平输出电流 IOH = 40 mA (条件 VDD = 3.3 V、VOH ≥ 2.64 V、PAD_DRIVER = 3),低电平灌电流 IOL = 28 mA (VOL = 0.495 V、PAD_DRIVER = 3)。ESP-IDF 里对应的接口是 gpio_set_drive_capability(),档位 GPIO_DRIVE_CAP_0(weak)到 GPIO_DRIVE_CAP_3(strongest),默认是 GPIO_DRIVE_CAP_2(medium)。

注意这是"绝对能力上限",不是"建议长期工作值"。 拿它去对照一个常见的 5 V 继电器:

text 复制代码
SRD-05VDC-SL-C 继电器线圈:5 V / 约 70 Ω
  I = 5 V / 70 Ω ≈ 71 mA        ← 已经远超单脚 40 mA
  而且它要 5 V,ESP32 的 GPIO 只有 3.3 V,压根吸合不了

结论:GPIO 直接接继电器线圈 = 要么不动作,要么把 IO 烧了,
      顺便还有线圈断电时的反向感应电压等着击穿你的芯片。

这和 低功耗_03 里那句"别用 GPIO 直接带大负载"是同一件事,只是那篇从功耗角度说,这篇从驱动能力角度说。

6.2 正确的驱动电路

text 复制代码
【方案 A:MOSFET 低边驱动(自己焊板子时用)】

        +5V ───┬──────────────┐
               │              │
            继电器线圈      D1 续流二极管 (1N4148/1N4007)
               │           ↑ 阴极朝 +5V
        ┌──────┴──────────────┘
        │ D (漏极)
   ┌────┤ N-MOSFET  AO3400 / 2N7002 等逻辑电平管
   │    │ S (源极) ── GND
   │    G (栅极)
   │
ESP32 GPIO ──┬── 100 Ω 栅极电阻
             └── 100 kΩ 下拉到 GND   ← ★ 这个下拉非常重要

【方案 B:光耦隔离继电器模块(新手强烈建议,我也是用这个)】

ESP32 GPIO ──→ IN   ┌──────────────────┐
        GND ──→ GND │ 光耦 + 三极管 +   │──→ 220 V 负载
        5V  ──→ VCC │ 继电器 + 续流二极管│
                    └──────────────────┘
   模块内部已经把驱动、隔离、续流全做好了,
   你只要保证:① 模块的 VCC 用 5 V,② 共地,③ 看清它是高电平触发还是低电平触发

三个必须知道为什么的细节:

  1. 续流二极管(flyback diode)不能省。 线圈是电感,断电瞬间电流不能突变,会在两端产生几十上百伏的反向尖峰,直接打穿 MOSFET 甚至 MCU。二极管给这股电流一条回路,让它自己慢慢耗掉。
  2. 栅极下拉电阻不能省。 ESP32 复位期间 GPIO 处于高阻态,栅极电压是"浮"的,可能被静电或耦合抬到导通电压------表现就是每次上电/按复位键,继电器"咔"一声自己吸合一下。100 kΩ 下拉保证芯片没发话时管子必定关断。
  3. 逻辑电平 MOSFET。3.3 V 驱动栅极,必须选 Vgs = 2.5 V 就能充分导通的型号(看 datasheet 里 RDS(on) @ Vgs = 2.5 V 这一栏)。拿老式的 IRF540 来接,3.3 V 根本打不开它,只会让它半导通然后发烫。

6.3 执行器驱动代码

c 复制代码
/* ===== actuator.c ===== */
#include "driver/gpio.h"
#include "driver/ledc.h"
#include "esp_log.h"
#include "gateway_types.h"      /* act_id_t 定义在这里,规则表要用 */

/* typedef enum { ACT_HUMIDIFIER = 0, ACT_FAN, ACT_MAX } act_id_t; */

static const gpio_num_t s_act_gpio[ACT_MAX] = {
    [ACT_HUMIDIFIER] = GPIO_NUM_25,
    [ACT_FAN]        = GPIO_NUM_26,
};

/* 我用的光耦继电器模块是低电平触发,所以"有效电平"是 0。
 * 这个值一定要抽成宏------不同模块不一样,写死在代码里将来必踩。 */
#define ACT_LEVEL_ON   0
#define ACT_LEVEL_OFF  1

void actuator_set(act_id_t id, bool on)
{
    if (id >= ACT_MAX) {
        return;                                   /* 数组越界防一手,规则表写错也不会跑飞 */
    }
    esp_err_t err = gpio_set_level(s_act_gpio[id], on ? ACT_LEVEL_ON : ACT_LEVEL_OFF);
    if (err != ESP_OK) {
        ESP_LOGE("act", "set gpio failed: %s", esp_err_to_name(err));
    }
}

void actuator_init(void)
{
    uint64_t mask = 0;
    for (int i = 0; i < ACT_MAX; i++) {
        mask |= (1ULL << s_act_gpio[i]);
    }
    gpio_config_t io = {
        .pin_bit_mask = mask,
        .mode         = GPIO_MODE_OUTPUT,
        /* 低电平触发的模块,配成上拉,让引脚在"软件还没置位"的空档里偏向无效电平。
         * 若你的模块是高电平触发,这里改成下拉。 */
        .pull_up_en   = GPIO_PULLUP_ENABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type    = GPIO_INTR_DISABLE,
    };
    ESP_ERROR_CHECK(gpio_config(&io));

    for (int i = 0; i < ACT_MAX; i++) {           /* 开机先全部置为"关" */
        actuator_set((act_id_t)i, false);
    }
}

⚠️ 上面那个内部上拉只解决"软件启动之后"的问题,解决不了"上电到 gpio_config() 执行之间"那几十毫秒 ------那段时间里芯片还在跑 bootloader,引脚是高阻态,内部上拉还没配上。真要根治,得在板子上 给驱动脚加一个 10 kΩ 外部上拉(或下拉,取决于你的模块是低电平还是高电平触发)到安全电平。这和 6.2 里 MOSFET 栅极那个 100 kΩ 下拉是同一个思路:"上电瞬间是什么状态"必须由硬件保证,不能指望软件。 这个坑的现象很好认------每次按复位键,继电器"咔"一下。

选脚也有讲究,ESP-IDF 的 GPIO 文档里列得很清楚,抄一遍重点:

  • GPIO611、1617 通常接着模组内部的 SPI Flash / PSRAM,不能动
  • GPIO0、2、5、12(MTDI)、15(MTDO) 是 strapping 引脚,上电瞬间的电平决定启动模式。你要是把继电器接在 GPIO12 上,模块上电时那点漏电流就可能让芯片以为你要 1.8 V Flash,直接起不来;
  • GPIO34~39 只能做输入,没有输出能力,也没有内部上下拉;
  • 所以我选了 GPIO25 / GPIO26:不是 strapping、不接 Flash、能输出、也不影响 ADC1。

舵机(比如拨动机械开关的场景)用 LEDC 输出 50 Hz PWM:

c 复制代码
#define SERVO_GPIO       GPIO_NUM_27
#define SERVO_FREQ_HZ    50                       /* 20 ms 周期,舵机标准 */
#define SERVO_RES        LEDC_TIMER_13_BIT        /* 8192 级,50 Hz 下够用 */

void servo_init(void)
{
    ledc_timer_config_t t = {
        .speed_mode      = LEDC_LOW_SPEED_MODE,
        .timer_num       = LEDC_TIMER_0,
        .duty_resolution = SERVO_RES,
        .freq_hz         = SERVO_FREQ_HZ,
        .clk_cfg         = LEDC_AUTO_CLK,
    };
    ESP_ERROR_CHECK(ledc_timer_config(&t));

    ledc_channel_config_t c = {
        .gpio_num   = SERVO_GPIO,
        .speed_mode = LEDC_LOW_SPEED_MODE,
        .channel    = LEDC_CHANNEL_0,
        .timer_sel  = LEDC_TIMER_0,
        .duty       = 0,
        .hpoint     = 0,
    };
    ESP_ERROR_CHECK(ledc_channel_config(&c));
}

/* SG90 之类:0.5 ms ≈ 0°,2.5 ms ≈ 180°,周期 20000 µs */
void servo_write_us(uint32_t pulse_us)
{
    if (pulse_us < 500)  { pulse_us = 500; }
    if (pulse_us > 2500) { pulse_us = 2500; }      /* 限幅,别让舵机顶死堵转 */
    uint32_t duty = (uint32_t)(((uint64_t)pulse_us << SERVO_RES) / 20000ULL);
    ESP_ERROR_CHECK(ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty));
    ESP_ERROR_CHECK(ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0));
}

⚠️ 舵机绝对不要从开发板的 3.3 V 取电。 SG90 空载才几十 mA,但堵转瞬间能拉到几百 mA,板载 LDO 一压不住,ESP32 立刻掉电重启------现象是"一转舵机就重启",很多人会去查代码,其实是电源问题。正确做法:舵机单独接 5 V 电源,信号线接 GPIO,地线必须和 ESP32 共地(不共地信号就没有参考点,舵机会疯狂抽搐)。
🔌 安全提醒(认真的) :加湿器、风扇这类电器接的是 220 V 市电。学生阶段做实验,强烈建议先用 12 V 的小风扇、LED 灯带来验证逻辑,把整套流程跑通、跑稳定几天之后,再考虑碰市电,而且必须用带外壳的成品继电器模块、做好绝缘和固定,通电时人不要在旁边碰线。这不是客套话------我的第一版就是拿 12 V 电脑风扇当负载调了整整一周。

6.4 app_main:把所有东西串起来(顺序有讲究)

前面的代码都是零件,最后拼装的顺序本身就是一道题:

c 复制代码
/* ===== main.c ===== */
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "esp_log.h"
#include "protocol_examples_common.h"   /* 官方例程的 example_connect() */
#include "gateway_types.h"

/* 这几个句柄跨文件用,真实工程里在 gateway_types.h 里 extern 声明一次,
 * 上面各段示例为了篇幅省掉了 extern 那几行。 */
extern QueueHandle_t    s_q_sample;
extern QueueHandle_t    s_q_report;
extern SemaphoreHandle_t s_state_mtx;
extern TaskHandle_t     s_ble_ctrl_task;

void app_main(void)
{
    /* ① 先建队列和互斥量。xQueueCreate 内存不够会返回 NULL,
     *    这种"创建期"错误必须当场发现,不能等到第一次 xQueueSend 才崩 */
    s_q_sample  = xQueueCreate(16, sizeof(sensor_sample_t));
    s_q_report  = xQueueCreate(8,  sizeof(sensor_sample_t));
    s_state_mtx = xSemaphoreCreateMutex();
    if (s_q_sample == NULL || s_q_report == NULL || s_state_mtx == NULL) {
        ESP_LOGE("main", "create ipc failed");
        abort();
    }

    /* ② 执行器先初始化并强制置为"关",早于任何可能触发它的逻辑 */
    actuator_init();

    /* ③ 联网。这里偷懒用官方 protocol_examples_common 的 example_connect():
     *    它内部做了 esp_netif_init() + 默认事件循环 + 连 AP + 阻塞等到拿到 IP,
     *    SSID/密码在 menuconfig 里填。正式产品需要真正的配网流程(下一篇讲)。 */
    ESP_ERROR_CHECK(example_connect());

    /* ④ 拿到 IP 之后再启动 MQTT,省得开局白白重试好几轮 */
    mqtt_gw_start();

    /* ⑤ ★ 任务必须在 ble_gw_init() 之前创建 ★
     *    因为 ble_gw_init() 一旦跑完,扫描就开始了,BLE 回调随时可能触发,
     *    而回调里会 xQueueSend(s_q_sample) 和 xTaskNotifyGive(s_ble_ctrl_task)。
     *    队列没建 / 任务句柄还是 NULL 的话,第一个广播包就能让你直接 panic。
     *    这类"回调比任务先跑起来"的时序坑,比语法错误难查十倍。 */
    xTaskCreate(rule_engine_task, "rule", 4096, NULL, 5, NULL);
    xTaskCreate(mqtt_task,        "mqtt", 4096, NULL, 4, NULL);
    xTaskCreate(ble_ctrl_task,    "blec", 3072, NULL, 3, &s_ble_ctrl_task);

    /* ⑥ 最后开蓝牙(它内部含 nvs_flash_init) */
    ble_gw_init();

    /* app_main 返回后它自己的任务会被删掉,其余任务照常跑,这是正常的 */
}

💡 xTaskCreate 的返回值也该判(pdPASS),我这里为了篇幅省了。栈大小别照抄我的数字------先给个偏大的值跑起来,再用 uxTaskGetStackHighWaterMark() 看剩余水位,留 1 KB 余量往回削。"栈给多少"永远是量出来的,不是猜的。


七、我真实踩过的四个坑

坑 1:在 BLE 回调里 vTaskDelay,整个蓝牙停摆

现象 :程序跑起来,第一个传感器读到了,然后串口再也没有新日志,蓝牙像死了一样;但 esp_timer 的定时打印还在跑,说明系统没崩。

原因 :我当时想"读完等 2 秒再扫下一个",顺手在 ESP_GATTC_READ_CHAR_EVT 里写了 vTaskDelay(pdMS_TO_TICKS(2000))。前面 3.2 节说过,这睡的是 BTC 任务,也就是整个蓝牙事件分发通道。所有后续事件全堵在队列里,协议栈内部超时,一切停摆。

修法:需要"过一会儿再做某事",让别的任务去等。

c 复制代码
/* ❌ 回调里等 */
case ESP_GATTC_READ_CHAR_EVT:
    vTaskDelay(pdMS_TO_TICKS(2000));      /* 灾难 */
    esp_ble_gap_start_scanning(30);
    break;

/* ✅ 回调只置个标志/发个通知,让 ble_ctrl_task 去等 */
case ESP_GATTC_DISCONNECT_EVT:
    s_connecting = false;
    xTaskNotifyGive(s_ble_ctrl_task);     /* 通知控制任务:该安排下一轮了 */
    break;

坑 2:连上一个"哑巴"传感器,网关再也不扫描了

现象:跑了半天好好的,某次之后就再也收不到任何数据,重启才恢复。

原因s_connecting = true 之后,我依赖 ESP_GATTC_DISCONNECT_EVT 把它清回 false。但如果对方连上却不响应 ATT 请求(传感器电池快没电时很常见),这个事件可能迟迟不来。状态机卡在"正在连接"这个中间态,扫描永远不会重启。

这是一类通病:只要你的状态机里有"等待某个外部事件"的中间态,就必须给它配一个超时兜底。

c 复制代码
#define BLE_STUCK_TIMEOUT_MS  15000

void ble_ctrl_task(void *arg)
{
    for (;;) {
        /* 等断开通知,最多等 15 秒 */
        if (ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(BLE_STUCK_TIMEOUT_MS)) == 0) {
            /* 超时了:说明有一轮连接卡住了,强行收拾残局 */
            if (s_connecting) {
                ESP_LOGW(TAG, "connection stuck, force close");
                esp_ble_gattc_close(s_gattc_if, s_conn_id);
                s_connecting = false;
                esp_ble_gap_start_scanning(SCAN_DURATION_S);
            }
            continue;
        }
        vTaskDelay(pdMS_TO_TICKS(2000));      /* 两轮采集之间歇一下,降低共存干扰 */
        esp_ble_gap_start_scanning(SCAN_DURATION_S);
    }
}

⚠️ 用了这个控制任务之后,记得把 3.6 里 ESP_GATTC_DISCONNECT_EVT 分支中那句直接 esp_ble_gap_start_scanning() 换成 xTaskNotifyGive(s_ble_ctrl_task) (也就是坑 1 里那段"✅ 正确姿势")。重开扫描的动作只能有一个 发起点,两边都开的话,第二次调用会失败并报 ESP_GAP_BLE_SCAN_START_COMPLETE_EVT 的错误状态。3.6 那版是为了让状态机先闭环、能单独跑通;这里是加了健壮性之后的最终版。

再加一道保险:把这个任务注册进任务看门狗(esp_task_wdt_add() / esp_task_wdt_reset()),万一整个任务被别的东西拖死,看门狗会重启设备。对一个要长期无人值守运行的网关来说,"能自己重启"比"永远不出错"现实得多。

坑 3:MQTT 一卡,加湿器跟着卡

现象:路由器重启的那几分钟,湿度明明掉到 25% 了,加湿器就是不开;等 Wi-Fi 恢复,它才"哐"地开起来。

原因:我第一版根本没分任务,规则和上报写在同一个循环里:

c 复制代码
/* ❌ 第一版:上报和联动串在一条线上 */
for (;;) {
    xQueueReceive(s_q_sample, &s, portMAX_DELAY);
    state_update(&s);
    mqtt_publish(&s);     /* 网断了,这里一等就是好几秒 */
    rules_eval();         /* 于是联动被上报活活拖住 */
}

这就是"本地联动"四个字最值钱的地方 :它必须和网络彻底解耦。修法就是本文第二节那张架构图------规则任务只碰队列和 GPIO,绝不碰网络 ;要上报,把数据丢进 q_report 让 MQTT 任务自己去慢慢发,发不出去是它的事。

改完之后再测:拔掉路由器电源,湿度模拟到 25%,加湿器照常在 1 秒内启动。这个测试我建议你一定亲手做一遍,那一下会很有成就感。

坑 4:共享状态没锁,MQTT 上报出现"鬼数据"

现象:偶尔上报一条温度 12.34 ℃、湿度 0% 的数据,而那一刻传感器明明是正常的。

原因 :MQTT 任务直接去读 s_states[] 组 JSON,没加锁。规则任务正在写 slot 的中途(温度写完了,湿度还没写),被高优先级任务打断读走了------读到了一个"半新半旧"的组合。

修法:两条路,我最后选了第二条。

  1. 所有读写 s_states[] 的地方统一加 xSemaphoreTake/Give
  2. MQTT 任务干脆不碰共享状态 ,只消费 q_report 队列里的值拷贝(队列传递本来就是值语义,天生没有竞态)。

第二条更好,因为它把"共享"这个问题从根上消掉了。能用消息传递解决的,就别用共享内存 + 锁------这句话你在学 RTOS、学 Go、学任何并发模型时都会再听到一遍。


八、新手必踩的 N 个坑(速查表)

# 后果 正确做法
1 在 GAP/GATTC 回调里 vTaskDelay、发 MQTT、跑重活 整个蓝牙事件通道停摆,丢包/断连 回调只做"过滤+拷贝+xQueueSend(...,0)",重活交给自己的任务
2 以为回调是中断,用了 xQueueSendFromISR() 用错 API,行为未定义 BTC 是普通任务:用 xQueueSend()超时填 0
3 param->read.value / scan_rst.ble_adv 指针存起来慢慢用 协议栈缓冲区被覆盖,读到乱码 当场 memcpy / 当场解析成整数
4 *(int16_t *)value 强转解析 非对齐访问 + 违反严格别名,UB 逐字节小端拼:`v[0]
5 温度按 uint16 解析 零下温度变成 650 ℃ ESS 温度是 sint16 ,必须转 int16_t
6 esp_ble_gattc_open() 地址类型写死 PUBLIC 对方用随机地址,连接永远失败且无报错 scan_rst.ble_addr_type 原样传回去
7 esp_ble_scan_params_t 定义成局部变量 函数返回后栈销毁,协议栈读到垃圾 必须 static/全局,官方文档明确要求
8 规则用单阈值 if (h < 30) 开 else 关 阈值附近继电器"哒哒哒"狂跳,触点报废 滞回(开 30% / 关 35%)+ 最短保持时间
9 传感器失联时"维持现状" 传感器没电那天,加湿器一直开着 数据超时(如 5 min)→ 回到安全默认状态
10 规则任务用 portMAX_DELAY 等队列 没数据时任务永久阻塞,超时保护永不触发 带超时地等(如 1 s),每轮都跑一次规则
11 上报和联动写在同一个循环里 断网时联动被网络阻塞拖死 队列解耦,规则任务绝不碰网络
12 多个任务读同一个队列想"都收到" 数据被随机分走,各收一半 队列是消费即出队;要广播就串联转发或每人一个队列
13 共享结构体只加 volatile 不加锁 读到"更新到一半"的状态 volatile ≠ 原子 ≠ 一致性,多字段要用互斥量
14 GPIO 直接驱动继电器线圈 电流/电压都不够,烧 IO 或不动作 MOSFET/光耦模块 + 续流二极管 + 栅极下拉
15 执行器接在 strapping 引脚(0/2/5/12/15) 上电启动模式被带偏,芯片起不来 选 GPIO25/26 这类普通脚,避开 611、1617
16 忘了 CONFIG_ESP_COEX_SW_COEXIST_ENABLE Wi-Fi 和 BLE 互相踩,丢包严重 打开共存开关,并把 BT/Wi-Fi 任务分到不同核
17 自己写 MQTT 重连逻辑 和内置自动重连打架,状态更乱 默认已开自动重连,只需调 reconnect_timeout_ms
18 在 MQTT 事件回调里 stop()/destroy() 官方明确禁止,等于让任务自杀 置标志位,让别的任务去做

九、动手练一练

不用一次性做完整个网关,按下面的顺序推进,每一步都能单独验证。第 2、5 步是故意搞破坏,别跳过------把东西弄坏一次,比读十遍原理记得牢。

练习 1(基础):先当一个"BLE 望远镜"

只跑 3.4 + 3.5 的代码,不连接、不解析,把扫到的每个设备的 MAC、RSSI、广播里的名字(ESP_BLE_AD_TYPE_NAME_CMPL)打出来。然后拿手机开个蓝牙耳机、开个 nRF Connect 广播,看它们出现在你的日志里。

✅ 通过标准:日志能持续刷新,30 秒扫描到期后能自动重新开扫(说明状态机闭环了)。

练习 2(故意搞破坏):把蓝牙搞死一次

ESP_GAP_BLE_SCAN_RESULT_EVT 的分支里加一行 vTaskDelay(pdMS_TO_TICKS(500));,重新烧录。

观察:日志频率骤降 → 大量设备扫不到 → 有时直接不再刷新。

然后把 500 改成 5000,看是不是彻底不动了。

再把这行删掉,确认恢复。

✅ 你要能用自己的话解释:"为什么在这里睡觉,会让整个蓝牙停摆?" 答不上来就回去看 3.2。

(进阶:在这行 delay 存在时,用 uxTaskGetStackHighWaterMark(NULL) 打印一下当前任务栈余量,顺便确认你确实身处 BTC 任务而不是自己的任务。)

练习 3(核心):把两个数读出来

补上 3.6 的 GATTC 流程,读到温湿度并打印。手头没有支持 ESS 的传感器?用第二块 ESP32 跑官方 gatt_server 例程改一个 ESS 服务 (把 UUID 换成 0x181A / 0x2A6E / 0x2A6F,值填 12345678),自己给自己造数据源------这也是真实开发里最常用的调试手段:先把对端做成你完全可控的东西。

✅ 通过标准:网关打印 12.34 ℃ / 56.78 %RH。把服务端的温度改成 -500,网关要能正确显示 -5.00 ℃(这一步验证的是有符号解析,很多人在这里翻车)。

练习 4(联动):让继电器动起来

接一个光耦继电器模块 + 12 V 小风扇(先别碰 220 V ),把规则表里 fan 的阈值改到你手能摸到的范围,比如 on = 2800(28 ℃)、off = 2700(27 ℃)。用手捏住传感器加热,看风扇启停。

✅ 通过标准:捏住 → 风扇开;松手降到 27 ℃ 以下 → 风扇关;在 27~28 ℃ 之间反复晃动,风扇不应该来回跳

练习 5(故意搞破坏 ×2):验证你的架构真的抗断网

破坏动作 你应该看到 如果没看到,说明哪里错了
拔掉路由器电源 / 改错 broker IP MQTT 打印重连日志,联动照常工作 联动被网络拖住了 → 回去看坑 3,任务没解耦
min_hold_ms 改成 0,阈值改成同一个数(如 3000/3000) 继电器在阈值附近"哒哒哒" 没听到?说明温度变化太慢,用手快速加热/吹气
关掉传感器电源,等 5 分钟 执行器自动回到关闭状态,日志打印 no fresh data, force OFF 一直保持开着 → 失联保护没生效
在规则任务里把 xQueueReceive 超时改成 portMAX_DELAY,再关掉传感器 失联保护永远不触发 这就是坑 10 的现场,看完记得改回来

练习 6(选做,简历加分):接进 Home Assistant

在电脑或树莓派上装 Mosquitto + Home Assistant,让网关的数据出现在 HA 面板里,再用 HA 做一个"手机不在家就关加湿器"的云端规则。这时你手上就有了一套完整的双层联动:本地保底 + 云端增强,这个架构讲给面试官听是很有说服力的。


小结

这篇做的东西,用一句话说就是:一块常供电的 ESP32,把家里割裂的 BLE 传感器聚合起来,既往 MQTT 上报,又在本地独立做联动决策。

技术上真正值得带走的是这几条:

  • 上下文意识 :BLE 回调跑在 Bluedroid 的 BTC 任务里(栈只有 CONFIG_BT_BTC_TASK_STACK_SIZE 默认 3072 字节),MQTT 事件回调跑在 esp-mqtt 的内部任务里。在别人的任务里,只许干快活,不许等。 这个意识一旦建立,你以后看任何 SDK 的回调都会先问一句"这跑在谁身上"。
  • 队列是解耦的最小单位:BLE→规则→上报串成单向流,每一段都能独立卡住而不拖垮别人。断网时联动仍在 1 秒内响应,靠的就是这个结构,不是靠什么高深算法。
  • 控制逻辑要有滞回、有超时、有安全默认:滞回防抖动,最短保持时间防频繁切换,数据超时防"传感器死了执行器还傻开着"。这三样是所有自动控制的通用套路。
  • volatile 不是锁:多字段共享状态要用互斥量;能用队列传值就别共享内存。
  • GPIO 是信号,不是电源:40 mA 是绝对上限不是设计值,负载一律交给 MOSFET 或光耦模块,续流二极管和栅极下拉不能省。

和第 01 篇放在一起看,你手上就有了两个方向相反、正好互补的项目:

text 复制代码
   项目实战_01  电池气象节点        项目实战_02  常供电环境网关
   ─────────────────────────       ──────────────────────────
   约束:电                        约束:并发
   手段:Deep Sleep + 唤醒源        手段:多任务 + 队列 + 互斥量
   卖点:算得出能跑多久             卖点:断网也不失控
   面试官会追问:功耗怎么测的?      面试官会追问:回调里能不能阻塞?
                 OTA 断电会不会砖?             两个任务抢一个变量怎么办?

一个证明你懂"省电",一个证明你懂"并发",这是嵌入式面试里最常被追的两条线。等你能把这两个项目从需求讲到取舍、从代码讲到踩坑,简历上"熟悉 FreeRTOS/BLE/MQTT"这行字才算真的立得住------因为它背后有两个你能画出架构图、说得出每个数字来历的东西。

文中的 BLE 扫描/连接、MQTT 和 GPIO 配置都对照了 ESP-IDF v5.3 官方文档,想深挖的按函数名去翻即可。


相关推荐
随风而飘1861 小时前
安立(Anritsu)MT8820C无线综合测试仪(手机综测仪)
网络·功能测试·测试工具·5g·智能手机
SWAGGY..1 小时前
【C++ 初阶】:(12)深入理解C++ stack:容器适配器、底层容器与算法实战
网络·网络协议·rpc
zlinear数据采集卡2 小时前
数据采集卡从入门到精通(38):上位机开发实战——Python/QT/LabVIEW的技术选型与分层架构
python·单片机·嵌入式硬件·qt·fpga开发·开源·labview
嵌入式阿蔡2 小时前
面试高频考点 01:volatile / 中断 / 堆栈 八股精讲
java·面试·职场和发展·嵌入式实时数据库
猫咪宝妖2 小时前
信息安全工程师-网络信息安全
网络
Brilliantwxx2 小时前
【Linux】 进程(3)深度解析:从查看进程到进程状态
linux·服务器·网络·c++
我星期八休息2 小时前
Linux—五种IO模型与非阻塞IO
linux·运维·服务器·网络·数据库·网络协议
oooo_z2 小时前
WebSocket实时行情推送API实战:用iTick搭一个盯盘小工具
网络·websocket·网络协议
自小吃多2 小时前
Capture软件原理图添加差分属性笔记
笔记·嵌入式硬件