拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ESP32-C3蓝牙网关实战:小米温湿度计接入Home Assistant

ESP32-C3蓝牙网关实战:小米温湿度计接入Home Assistant

用一块ESP32-C3做蓝牙网关,把小米温湿度计的数字通过MQTT自动送进Home Assistant,再把完整代码贴出来,大概是这个项目最吸引人的地方。我折腾了两周,现在它在家里7x24小时跑着,稳定得很。整个过程不依赖米家云,手机断网也不影响本地自动化,适合手里正好有ESP32-C3开发板、又想把米家传感器拉进Home Assistant生态的玩家参考。

如果你只是想偶尔看一眼温度,直接用米家App就够了,但想要温度数据参与联动报警、历史图表、自动化规则,就必须让HA能拿到数据。小米温湿度计本身只有蓝牙广播,没有Wi-Fi,所以需要一个“翻译官”:ESP32-C3监听传感器广播包,解析出温度、湿度和电池电量,再通过MQTT协议送给HA。这个项目的核心就是用几百行代码实现一个轻量级蓝牙网关。

1. 项目逻辑梳理:先搞清楚数据怎么走

1.1 为什么需要一块独立的BLE网关

小米温湿度计常见型号是LYWSD03MMC,它工作在低功耗蓝牙广播模式下,不会主动连接任何设备,只是每隔几秒向外喊一句话,内容包含温度、湿度、电池状态。这种设计省电,一颗纽扣电池能用大半年,但缺点也很明显:普通电脑的蓝牙往往接收距离短、稳定性差,而且需要额外软件才能解析广播,长期挂机不太现实。

HA主机如果是树莓派或者NUC,内置蓝牙也能直连传感器,但蓝牙天线位置受限,距离一远就丢包,家里多几堵墙基本就断断续续。用ESP32-C3做独立网关,可以把这个“收音机”放到离传感器最近的地方,再通过Wi-Fi把数据传出去,信号覆盖更灵活。与此同时,C3开发板价格只有十几到二十几块,功耗也比一台树莓派低得多,适合做这种常驻型小网关。

1.2 数据链路全流程

整个链路可以用一句话概括:小米温湿度计发出BLE广播,ESP32-C3扫描到广播后解析出数值,再通过MQTT发布到Topic,Home Assistant订阅这些Topic并生成实体,最终在面板上显示。

拆成节点来看是这样:

  • 传感器节点:LYWSD03MMC按照自己的广播周期(通常2~10秒)发出蓝牙广播包。
  • 网关节点:ESP32-C3开启BLE扫描,捕获空气中的广播包,过滤出小米设备,再解析出温度、湿度、电量。
  • 消息节点:网关把数据打包成JSON,通过MQTT发布到home/传感器名/state这类Topic。
  • 平台节点:Home Assistant通过MQTT集成订阅Topic,自动创建出三个实体(温度、湿度、电量)。
  • 展示节点:把实体添加到仪表盘,就能看到实时数据和历史曲线。

这个链路里最容易踩坑的是第一步和第二步,也就是“传感器广播是否明文”以及“网关能否正确解析”。后面会单独展开。

1.3 为什么选择ESP32-C3而不是其他方案

有人会问,用ESP8266能不能做?ESP8266没有BLE硬件,只能靠外挂模块,通信链路复杂,不太推荐。用树莓派做又显得大材小用。ESP32-C3是乐鑫推出的高性价比芯片,单核RISC-V、支持Wi-Fi和BLE 5.0,最重要是Arduino生态成熟,开发门槛低,国内各种开发板选择很多,社区资料丰富。

而且C3的BLE接收灵敏度不错,在室内隔一堵墙的距离实测也能稳定捕获传感器广播。再加上它自带Wi-Fi,不需要额外配网模块,一个芯片就能同时搞定蓝牙和网络两个任务。虽然同系列ESP32经典款也能做,但C3功耗更低、价格便宜、体积更小,做网关更合适。

2. 硬件选型与准备

2.1 开发板怎么选

市面上常见的ESP32-C3开发板有两类:一类是合宙ESP32-C3,板子很小、引脚引出较少,适合模块化嵌入;另一类是带排针的全功能开发板,比如ESP32-C3-DevKitM-1,方便直接插面包板调试。

我的建议:如果只是做网关放在插座旁边,选哪种都可以,关键是看供电方式和串口芯片。开发板一般带USB口,插上充电头或者路由器USB口就能长期供电。需要注意有些板子的USB转串口芯片是CH340,有些是CP2102,两者都需要安装驱动。买板子的时候先问清型号,避免到手后电脑识别不了。

温度计本身不需要改硬件,LYWSD03MMC是纽扣电池供电的蓝牙设备,买来就能用。不过要注意的是,我下面这套方案默认传感器已经刷成第三方固件,也就是ATC或PVVX固件,具体原因见第3章。

2.2 开发环境搭建

我用的是Arduino IDE开发环境,简单直接。安装和配置分三步:

第一步,安装Arduino IDE,建议2.x版本,界面更干净,库管理也方便。

第二步,安装ESP32开发板支持。在“文件 -> 首选项 -> 附加开发板管理器地址”里填入乐鑫官方JSON地址:https://espressif.github.io/arduino-esp32/package_esp32_index.json。然后在“开发板管理器”中搜索ESP32,安装2.0.x版本。我这里实测用的是2.0.17,稳定,没有遇到编译问题。

第三步,在库管理器里安装PubSubClient和ArduinoJson。前者负责MQTT通信,后者负责生成和解析JSON配置。这两个库都很常用,直接搜索安装即可。

如果不想用Arduino IDE,也可以换成PlatformIO,在platformio.ini里写:

[env:esp32-c3-devkitm-1] platform = espressif32 board = esp32-c3-devkitm-1 framework = arduino lib_deps = knolleary/PubSubClient@^2.8 bblanchon/ArduinoJson@^6.21.5

两种方式编译出来的固件效果一样,看个人使用习惯。

2.3 硬件接线与供电

这个项目几乎不需要接线,USB插上就能跑。唯一要注意供电稳定性,ESP32-C3在Wi-Fi和BLE同时启动时,瞬时电流可能到200mA以上,建议用5V/1A以上的充电头,不要用电脑的USB口长期供电,插拔容易断。

如果要把温度计单独放远处,网关放在中间位置即可。C3的蓝牙接收半径实测大概在10~15米,环境越空旷越好。传感器和网关之间尽量避免厚重墙体遮挡。

3. 关键一步:让小米温湿度计输出明文广播

3.1 原厂广播是加密的,直接解析行不通

小米温湿度计出厂时运行的是米家固件,广播内容是经过加密的,第三方设备(包括ESP32)只能看到一堆密文,解不出温度湿度。米家App能显示数据,是因为它在配对时和传感器协商了密钥,并且使用随机MAC地址,这给本地解析带来了不小麻烦。

网上有一些方案可以在ESP32里实现MiBeacon解密,但需要提前从米家App里提取绑定密钥,流程繁琐,而且传感器会定期更换随机地址,维护成本很高。所以我的建议是别在原厂固件上硬刚,直接给传感器刷第三方固件。

3.2 ATC固件与PVVX固件的区别

第三方固件里最常用的是ATC和PVVX两个版本。它们共同点是关闭广播加密,把温度、湿度、电量明文打进广播包里,且支持更高的广播频率(默认大约2秒一次),非常适合网关解析。

两种固件的差别主要在于广播包结构和一些附加功能。我使用的是ATC固件,它的广播包以43 4D 54开头,也就是ASCII字符“CMT”,解析起来非常方便。PVVX固件也有类似设计,但偏移量略有差异。

刷机方法很简单,在手机上用Chrome或Edge浏览器打开ATC固件在线刷写页面,靠近传感器,浏览器会通过Web Bluetooth读取到设备并烧录。整个过程不需要拆机,大概几十秒完成。刷完后传感器在米家App里会失效,也就是说它再也回不了米家智能联动,这是需要提前接受的一点。

3.3 刷机后的确认

刷机后可以先用“BLE调试助手”之类的手机App扫描一下,如果看到设备名称像是LYWSD03MMC或者广播数据里有一段43 4D 54开头的内容,就说明刷机成功了。后面ESP32-C3解析的正是这串数据。

这里特别提醒一句:如果你手头的传感器已经接入米家并绑定了账号,需要先彻底解绑,再刷第三方固件,否则浏览器可能连接不上设备。我一开始没解绑,反复扫描都找不到,亏得重启了好几次才反应过来。

4. ESP32-C3完整代码与逐段解析

4.1 代码运行的总体思路

代码主要分为四块:Wi-Fi连接、MQTT连接、BLE扫描解析、数据发布。启动后先连Wi-Fi和MQTT,然后开启BLE扫描,后台不断监听传感器广播。每次解析出新数据就存到全局变量,主循环定时把数据推送到HA。

我采用的是Arduino框架自带的BLE库,接口在ESP32-C3上兼容性很好。下面给出的代码是完整版,可以直接复制到Arduino IDE里使用。

#include <WiFi.h> #include <PubSubClient.h> #include <ArduinoJson.h> #include <BLEDevice.h> #include <BLEScan.h> #include <BLEAdvertisedDevice.h> // ===== 需要修改的配置 ===== const char* WIFI_SSID = "your_wifi_ssid"; const char* WIFI_PASS = "your_wifi_password"; const char* MQTT_HOST = "192.168.1.100"; // HA的IP const uint16_t MQTT_PORT = 1883; const char* MQTT_USER = ""; // 如果MQTT没开认证就留空 const char* MQTT_PASS = ""; // 传感器MAC地址,留空则自动抓取第一个名称匹配的设备 const char* SENSOR_MAC = "a4:c1:38:xx:xx:xx"; const char* SENSOR_NAME = "livingroom"; // 用于HA实体命名,建议用英文 // ===== 全局状态 ===== float temperature = 0; float humidity = 0; int batteryPercent = 0; bool dataUpdated = false; unsigned long lastPublish = 0; const unsigned long PUBLISH_INTERVAL = 30000; // 30秒发布一次,可按需调整 WiFiClient wifiClient; PubSubClient mqttClient(wifiClient); class ScanCallbacks : public BLEAdvertisedDeviceCallbacks { void onResult(BLEAdvertisedDevice advertisedDevice) override { // 用MAC过滤时只解析指定设备 if (strlen(SENSOR_MAC) > 0) { String addr = advertisedDevice.getAddress().toString().c_str(); addr.toLowerCase(); if (addr != SENSOR_MAC) return; } else { // 没有MAC时根据设备名过滤,兼容随机地址设备 std::string devName = advertisedDevice.getName(); if (devName.find("LYWSD03") == std::string::npos && devName.find("ATC") == std::string::npos) return; } // 取广播中的厂家数据,ATC固件以 "CMT" 开头 std::string mfg = advertisedDevice.getManufacturerData(); if (mfg.length() < 14) return; const uint8_t* d = (const uint8_t*)mfg.data(); if (d[0] != 0x43 || d[1] != 0x4D || d[2] != 0x54) return; // CMT // ATC固件数据格式:温度小端有符号,除以10;湿度小端,除以10 int16_t tempRaw = (int16_t)((d[7] << 8) | d[6]); uint16_t humRaw = (d[9] << 8) | d[8]; uint16_t battMv = (d[11] << 8) | d[10]; uint8_t battPct = d[12]; temperature = tempRaw / 10.0f; humidity = humRaw / 10.0f; batteryPercent = battPct; dataUpdated = true; Serial.printf("Temp: %.1f C, Hum: %.1f %%, Batt: %d%% (%dmV)\n", temperature, humidity, batteryPercent, battMv); } }; ScanCallbacks scanCallbacks; void connectWiFi() { WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS); Serial.print("Connecting WiFi"); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(" OK"); } void connectMQTT() { int retryCount = 0; while (!mqttClient.connected() && retryCount < 20) { Serial.print("Connecting MQTT..."); if (mqttClient.connect("ESP32C3_GW", MQTT_USER, MQTT_PASS)) { Serial.println(" OK"); return; } else { Serial.print(" failed, rc="); Serial.println(mqttClient.state()); retryCount++; delay(2000); } } } void publishDiscovery() { String baseTopic = String("homeassistant/sensor/") + SENSOR_NAME + "_"; String tempConfig = String("{\"name\":\"") + SENSOR_NAME + "_temperature\"," "\"device_class\":\"temperature\"," "\"state_topic\":\"home/" + SENSOR_NAME + "/state\"," "\"value_template\":\"{{ value_json.temperature }}\"," "\"unit_of_measurement\":\"°C\"," "\"unique_id\":\"" + SENSOR_NAME + "_temperature\"}"; mqttClient.publish((baseTopic + "temperature/config").c_str(), tempConfig.c_str()); String humConfig = String("{\"name\":\"") + SENSOR_NAME + "_humidity\"," "\"device_class\":\"humidity\"," "\"state_topic\":\"home/" + SENSOR_NAME + "/state\"," "\"value_template\":\"{{ value_json.humidity }}\"," "\"unit_of_measurement\":\"%\"," "\"unique_id\":\"" + SENSOR_NAME + "_humidity\"}"; mqttClient.publish((baseTopic + "humidity/config").c_str(), humConfig.c_str()); String battConfig = String("{\"name\":\"") + SENSOR_NAME + "_battery\"," "\"device_class\":\"battery\"," "\"state_topic\":\"home/" + SENSOR_NAME + "/state\"," "\"value_template\":\"{{ value_json.battery }}\"," "\"unit_of_measurement\":\"%\"," "\"unique_id\":\"" + SENSOR_NAME + "_battery\"}"; mqttClient.publish((baseTopic + "battery/config").c_str(), battConfig.c_str()); } void publishState() { DynamicJsonDocument doc(128); doc["temperature"] = temperature; doc["humidity"] = humidity; doc["battery"] = batteryPercent; char buffer[128]; serializeJson(doc, buffer); String topic = String("home/") + SENSOR_NAME + "/state"; mqttClient.publish(topic.c_str(), buffer); Serial.println("State published"); } void setup() { Serial.begin(115200); connectWiFi(); mqttClient.setServer(MQTT_HOST, MQTT_PORT); connectMQTT(); publishDiscovery(); BLEDevice::init("ESP32C3_GW"); BLEScan* pBLEScan = BLEDevice::getScan(); pBLEScan->setAdvertisedDeviceCallbacks(&scanCallbacks, false); pBLEScan->setActiveScan(true); pBLEScan->setInterval(100); pBLEScan->setWindow(99); pBLEScan->start(0); // 持续扫描,不阻塞主循环 } void loop() { if (!mqttClient.connected()) { connectMQTT(); } mqttClient.loop(); unsigned long now = millis(); if (dataUpdated && (now - lastPublish >= PUBLISH_INTERVAL)) { publishState(); dataUpdated = false; lastPublish = now; } }

4.2 代码里的关键细节

pBLEScan->start(0)这一行是整个程序的核心,它让BLE扫描始终运行。不同版本的ESP32 Arduino库对start(0)的行为略有差异,如果你发现主循环被卡住或者MQTT数据发不出去,可以把这种持续扫描改成周期扫描,也就是每10秒调用一次stop()然后start(5, false),让扫描让出CPU时间。

数据解析部分最关键的是温度的正负号处理。ATC固件里温度是带符号的16位整数,低位在前,所以代码中用int16_t接收(d[7] << 8) | d[6],再除以10。如果直接声明成uint16_t,冬天零下温度就会显示成65535之类的异常值。湿度没有负数,直接用无符号处理。

发布频率我设的是30秒一次。传感器本身广播间隔大概2秒,数据其实变化不快,30秒足够。如果你要做实时性要求高的联动,可以改成5秒甚至10秒,但要注意MQTT消息量和HA端数据库写入速度。

4.3 为什么代码里包含自动发现

Home Assistant的MQTT集成默认开启自动发现,只要设备向homeassistant/sensor/xxx/config这类主题发布一段标准JSON配置,HA就会自动创建实体,不需要手动写YAML、重启HA。代码里的publishDiscovery()就是干这个事。

这种方式的优势非常明显:网关代码自描述,烧录后不用改HA配置,自动就能出现传感器卡片。如果自动发现偶尔失败,比如HA晚于网关启动,最简单的方法是重启一次网关,它会重新发布配置。

5. Home Assistant接入:MQTT与自动发现

5.1 在HA里部署MQTT Broker

HA侧首先要有一个MQTT服务器。如果你用的是Home Assistant OS,直接在加载项商店里搜索Mosquitto broker并安装,然后创建一个用户。记住用户名密码,填入ESP32代码的MQTT_USER和MQTT_PASS。

如果和我一样在Docker里跑HA,也可以用官方的Mosquitto容器,配置一次性,后面基本不用管。MQTT端口默认1883,HA所在的IP地址就是代码里的MQTT_HOST。

5.2 自动发现的工作流程

代码烧录后,ESP32-C3一开机就会发布三条自动发现配置:温度、湿度、电池。HA收到配置后自动生成三个实体,实体名会带上SENSOR_NAME作为前缀,方便区分不同房间。

如果你想手动配置,也可以不依赖自动发现,直接修改HA的configuration.yaml:

mqtt: sensor: - name: "客厅温度" state_topic: "home/livingroom/state" value_template: "{{ value_json.temperature }}" unit_of_measurement: "°C" device_class: temperature - name: "客厅湿度" state_topic: "home/livingroom/state" value_template: "{{ value_json.humidity }}" unit_of_measurement: "%" device_class: humidity - name: "客厅温湿度计电量" state_topic: "home/livingroom/state" value_template: "{{ value_json.battery }}" unit_of_measurement: "%" device_class: battery

手动方式最大的好处是调试时能直观看见解析过程,不容易被自动发现的报错绕晕。

5.3 用仪表盘展示数据

实体创建后,去HA“仪表盘”里添加“传感器卡片”,选出刚才生成的三个实体。一个卡片可以同时显示温度和湿度,电池电量可以放在旁边小卡片上。HA会自动记录历史数据,温度曲线、温湿度对比图这些功能都能直接用。

6. 常见问题:烧录、扫描、数据解析避坑实录

6.1 烧录失败:Failed to connect to ESP32-C3

这是最常见的拦路虎,我第一次拿到C3开发板时就遇到了。报错信息大致是A fatal error occurred: Failed to connect to ESP32-C3: No serial data received。

排查看三处:

  • 驱动是否安装。很多C3开发板的USB转串口芯片是CH340,Windows系统往往不会自动安装驱动,需要去芯片官网下载。识别不到COM口,先装驱动。
  • 开发板型号是否选对。Arduino IDE里要选“ESP32C3 Dev Module”,不要选成ESP32。
  • 是否进入下载模式。连接好USB后按住开发板上的BOOT键不放,点击烧录,等串口出现“Connecting”再松开BOOT键。

另外烧录时不要开其他占用串口的软件,比如串口监视器,会抢占COM口导致烧录中断。

6.2 扫描不到传感器

如果C3能正常跑,但串口一直没打印传感器数据,优先检查三件事。

第一,传感器是否真的刷好了ATC固件。如果还是原厂固件,广播内容是加密的,C3扫到也过滤不掉,因为设备名可能不包含“LYWSD03”,厂家数据也不是“CMT”开头。

第二,距离是否太远。LYWSD03MMC的广播功率很低,实测超过15米或者隔两堵墙就会明显丢包。把传感器拿到C3旁边测试一下,能收到再布点。

第三,广播间隔。ATC固件默认广播间隔大约2秒,但有些版本可能支持配置为更长时间,耐心等待10秒以上再说。

6.3 温度数值明显不对

数值不对,优先怀疑解析偏移量。ATC固件数据格式在历代版本中基本一致,但如果你用的是PVVX固件或冷门改版,偏移会不同。排查方法是在回调函数里把原始的ManufacturerData按照十六进制打印出来,手工对照改偏移。

还有一个容易被忽略的点:数据单位。ATC固件里温度原始值要除以10,也就是25.6度会存成256。如果忘记除以10,HA上就会显示256度。湿度同理。

6.4 MQTT连接成功但HA不显示实体

先检查Topic。自动发现主题必须严格以homeassistant/开头,而且必须发布到/config结尾。我一开始把主题写成了homeassistant/sensor/xxx/state,HA完全没反应。

再用MQTT客户端订阅homeassistant/sensor/#和home/#看看消息是否正常。如果自动发现始终不生效,直接改用5.2节的手动YAML方式,排错更快。

6.5 关于功耗的实测

ESP32-C3跑这个固件,Wi-Fi保持连接、BLE持续扫描,实测静态电流大约100mA,瞬时峰值能到200mA以上。用USB供电完全没压力,但如果想用电池供电做便携网关,就必须改成低功耗方案:用深度睡眠,每隔10分钟唤醒一次扫描30秒,发完数据重新睡,平均电流能降到10mA以下。这种模式适合阳台、车库等不方便插电的位置。

7. 后续扩展:多传感器、低功耗与更多玩法

这个网关架构可以轻松扩展到多个传感器。把代码里的单MAC过滤改成数组,为每个传感器分配独立名称,就能同时监控客厅、卧室、厨房等多个点位。我家现在是三个温度计在跑,代码核心逻辑没变,只是在回调里增加了一个设备名到Topic的映射表。

低功耗方向也值得研究。C3的RTC唤醒机制很成熟,定时唤醒后扫描、连Wi-Fi、发MQTT、再睡,单次工作流可以控制在几秒内。我在调试这类方案时发现一个经验:Wi-Fi连接时功耗最高,所以唤醒后尽量先扫描传感器,再连Wi-Fi发送,而不是先连Wi-Fi再扫描。这样扫描阶段的低功耗状态就能充分利用起来。

另外,这套代码稍加改造可以支持更多米家BLE设备,比如门窗传感器、人体传感器。只要了解它们的广播协议,在回调里增加解析分支即可。整个项目最关键的价值在于打通了“米家蓝牙设备 -> 本地网关 -> Home Assistant”这条离线链路,后续无论接Node-RED还是做语音联动,都只是在消费这条链路上的数据罢了。

返回列表