无线与 USB 拓展 / 通信实践发布 内容更新

用真实 SHT40 读数做 BLE 广播:版本、单位和手机解析

从真实SHT40读取到31字节自定义广播,说明signed温度、无效标志、随机地址与接收端陈旧;提供手机HEX解析流程。

读完能做什么
构建完整广播节点,用明确版本/单位和错误标志解释手机复制的字节,区分收到包、样本更新与RSSI变化。
开始之前
先完成所列前置,能用桥接串口下载IDF工程、断电追线;本页仅指定低压台架。
所需器材
官方S3 v1.1 N8R8、桥USB数据线、万用表、Adafruit PID4885 SHT40(排针已可靠焊接)、短I²C线;可选预装低压SDA信号SPST、BLE Android手机;参考官方nRF Connect4.29.1,实际手机版本需记录
版本组合
固定板:ESP32-S3-DevKitC-1 v1.1 / WROOM-1-N8R8框架 / Python:ESP-IDF5.5.5 / Python3.12.13实际交叉编译器:xtensa-esp-elf-gcc14.2.0 / esp-14.2.0_20260121NimBLE gitlink:685675c0128deafdd201c9eb82e61d227364646cSHT4x驱动:Sensirion embedded-i2c-sht4x1.1.0 / 8e16a39098725bd53284b9a68268567f1315a1b4器件资料:SHT4x V6.4 / Adafruit PID4885手机操作参考:nRF Connect Android4.29.1;未实际安装/运行
适用范围

仅官方S3 DevKitC1 v1.1 N8R8 + PID4885;IDF5.5.5/NimBLE固定gitlink,传统非连接31B广播;未实板、未手机扫描。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/ble-usb-sources.json、ble-usb.md;具体章节已读取并核对本机SDK/managed。版本:固定IDF / 子模块 / 厂家资料验证日期:
构建 / 配置已通过
docs/esp32-evidence/ble-usb-builds.json;实际完整原生构建Exit0,输入/配置/固件与原始log SHA留存。对象:ESP32-S3 / N8R8版本:ESP-IDF5.5.5验证日期:
仿真已通过
docs/esp32-evidence/ble-usb-host.json;纯C应用模型和格式检查,不运行物理BLE/USB。对象:macOS arm64同源主机夹具版本:Apple clang21 ASan/UBSan / Python3.12.13验证日期:
本站实板待验证
examples/esp32/esp32-ble-advertising-basics/observations.csv仅表头;未接实板,不声称手机配对/无线接收/键盘效果。对象:指定板/模块/手机或USB主机

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 通信实践

先把“测到了”与“广播被接收”分开

本例让指定SHT40每轮真实读取温湿度,经驱动检查CRC后放进自定义广播。固件没有演示温度常量、随机温度或失败时沿用旧值的分支;电脑夹具的合成值仅用于验证字节布局。本站尚未接板或运行手机,因此没有可展示的真实温湿度、RSSI或距离数据。

节点只做传统非可连接广播,无GATT、配对或收件ACK;手机扫描即可查看。一次adv_start返回0意味着协议栈接受开始,不证明手机看见、样本正确或无线链路稳定。把这几个证据层次分开,后面的故障才有可定位的依据。

参考:[4] Sensirion固定驱动:真实高精度读取/整数近似[7] NimBLE固定广播API:31B与广播参数[9] IDF5.5.5 NimBLE Kconfig:角色

02 / 通信实践

断电核对 PID4885 的四个端点

硬件限定排针已可靠焊接的Adafruit PID4885 SHT40;未焊时先由有条件者完成焊接并检查连锡/虚焊,不能松插随附排针代替焊接。不能把裸SHT40或其他匿名模块套用同一电源表。VIN接主控3V3,GND共地,SDA8/SCL9,初始短线和100kHz;模块自身有电平转换及各10kΩ上拉,先不再并联上拉。模块标3V是稳压输出,保持留空,不与VIN作为两路输入。

完全拔电后逐根追线,用万用表查3V3/GND无短路,再只接桥USB下载与观察UART0 115200。0x44是此模块地址,不能靠改软件让它变成0x45。传感器离开发板发热源;编码两位小数不等于测量精度两位小数。

主控 / 排针PID4885本例约束
3V3 / J1-1VIN同3.3V逻辑环境
GND / J1-22GND共地
GPIO8 / J1-12SDA100kHz;模块已有上拉
GPIO9 / J1-15SCL7位地址0x44
不接3V稳压输出,不再注入电源
做到这里,你应该看到
GPIO信号号和排针序号分别核对;电源只走USB供电组。

参考:[1] 官方DevKitC1 v1.1固定文档:排针/USB/供电[2] Sensirion SHT4x V6.4:p3/10–13命令、CRC、换算[3] Adafruit PID4885 Pinouts:VIN、3V输出、10k上拉和0x44

03 / 通信实践

锁驱动与广播角色,先完成不插板的编译

下载包保留Sensirion1.1.0固定提交的9个原文件和BSD许可,MIT HAL用IDF5.5.5新I²C master API。高精度函数发0xFD、等待10ms、读取两数据字并检查CRC;整数近似换算后应用再量化,既不启用加热器,也不把通信失败包装成样本。

按E01准备同版IDF与Python环境后,在解压工程根执行下列命令;将串口名换成实际桥口。dependencies.lock和组件清单随包,已有SDK复用即可。生成sdkconfig会覆盖默认值的优先级:修改defaults后用新的配置文件/构建目录,并核最终BROADCASTER=y、其余三个角色n。

本轮实际Python3.12.13/GCC14.2.0构建Exit0,应用bin417168B;这只是编译产物大小,不是内存峰值或功耗。无.git安装SDK的版本警告原样留档,以固定源码和实际配置核版本。

代码 / 配置
idf.py --version
python --version
idf.py -B build -D SDKCONFIG=sdkconfig.local build
idf.py -B build -D SDKCONFIG=sdkconfig.local -p /dev/cu.YOUR_BRIDGE_PORT flash monitor

参考:[4] Sensirion固定驱动:真实高精度读取/整数近似[9] IDF5.5.5 NimBLE Kconfig:角色

04 / 通信实践

把31字节预算与无效规则写成协议

传统广播总长31B:Flags3B,AD长度/type2B,自定义128位UUID16B,余下10B应用负载。UUID是36fb49a7-8c8a-4c92-9d55-6cf2b877d701;线上小端为01 D7 77 B8 F2 6C 55 9D 92 4C 8A 8C A7 49 FB 36,手机常把UUID单独格式化显示。此包未留设备名,扫描时不要只按名称筛选。

flags位0有效、位1湿度裁边、位2读取错误、位3陈旧。有效温度int16二补码/100得°C,湿度uint16/100得%RH;milli整数/10向零截断,RH裁到0..100并另置标志。换算容许温度−45..130°C是编码/公式边界,不是推荐工作范围或精度保证。失败/过期则VALID=0、T/RH=0、ageFFFF;这两个0仅占位。

样本年龄取完整64位单调时间,超过15000ms或时间倒退无效。实际每轮读取后立即编码,age通常0;若广播更新卡住,这个0也会冻结。接收者必须另以自己的单调时钟检查seq是否在15秒内变化;不断收到相同包的新RSSI或接收时间,不能使旧样本恢复新鲜。

负载偏移 / 长度字段解释
0 / 1Bversion=1未知版本拒绝
1 / 1Bflags高4位保留0
2 / 2Bseq uint16LE含失败尝试递增;回绕/重启分组
4 / 2BT int16LE0.01°C;无效不用
6 / 2BRH uint16LE0.01%RH;裁边单独标志
8 / 2Bage uint16LE编码年龄秒;无效FFFF

参考:[2] Sensirion SHT4x V6.4:p3/10–13命令、CRC、换算[4] Sensirion固定驱动:真实高精度读取/整数近似[7] NimBLE固定广播API:31B与广播参数[8] NimBLE固定Service Data AD定义[12] IDF5.5.5 esp_timer:64位单调时钟

05 / 通信实践

完整主程序:先真实读取,再尝试更新广播

下面main.c与下载包逐字一致;adv_model、HAL、固定Sensirion源及CMake/分区/锁一并下载,不能只复制main就假设驱动存在。程序每轮初始化输出变量,按真实返回码决定read_ok;错误字段的0不会被当成有效温湿度。若I²C初始化失败,修好接线后RESET重建总线。

主机同步时生成静态随机地址。固定API实现中gen_rnd(0)表示static,1才是NRPA;官方示例个别注释存在差异,本例按头文件和实现核对。这里不是周期RPA隐私,也不能把地址当永恒设备ID。读取后stop→set31B→start更新,名义广播间隔1秒另有协议随机延迟;采样还包含测量与调度耗时,不承诺精确5秒。

代码 / 配置
/* SPDX-License-Identifier: MIT */
#include <string.h>
#include <stdatomic.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "nvs_flash.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_hs.h"
#include "host/ble_hs_id.h"
#include "host/util/util.h"
#include "sensirion_i2c_hal.h"
#include "sensirion_hal_idf.h"
#include "sht4x_i2c.h"
#include "adv_model.h"
static const char *TAG="W05";static atomic_bool synchronized=false;
static int gap_event(struct ble_gap_event *event,void *arg){(void)arg;if(event->type==BLE_GAP_EVENT_ADV_COMPLETE)ESP_LOGI(TAG,"ADV complete reason=%d; no phone receipt claim",event->adv_complete.reason);return 0;}
static void reset(int reason){atomic_store(&synchronized,false);ESP_LOGW(TAG,"NimBLE reset=%d; wait for new sync",reason);}
static void sync_ready(void){
 ble_addr_t address;int rc=ble_hs_id_gen_rnd(0,&address); /* 0 means static random; not NRPA. */
 if(!rc)rc=ble_hs_id_set_rnd(address.val);
 if(!rc)rc=ble_hs_util_ensure_addr(1);
 if(rc){ESP_LOGE(TAG,"address setup rc=%d",rc);return;}
 ESP_LOGI(TAG,"random STATIC address this boot %02x:%02x:%02x:%02x:%02x:%02x; not periodic RPA",address.val[5],address.val[4],address.val[3],address.val[2],address.val[1],address.val[0]);
 atomic_store(&synchronized,true);
}
static void host_task(void *arg){(void)arg;nimble_port_run();nimble_port_freertos_deinit();}
static void advertise(const uint8_t packet[W05_PACKET_SIZE]){
 if(!atomic_load(&synchronized))return;
 int rc=0;if(ble_gap_adv_active())rc=ble_gap_adv_stop();
 if(rc){ESP_LOGW(TAG,"adv_stop rc=%d; no updated packet claim",rc);return;}
 rc=ble_gap_adv_set_data(packet,W05_PACKET_SIZE);
 if(rc){ESP_LOGW(TAG,"adv_set_data rc=%d",rc);return;}
 struct ble_gap_adv_params p={0};p.conn_mode=BLE_GAP_CONN_MODE_NON;p.disc_mode=BLE_GAP_DISC_MODE_GEN;p.itvl_min=1600;p.itvl_max=1600;
 rc=ble_gap_adv_start(BLE_OWN_ADDR_RANDOM,NULL,BLE_HS_FOREVER,&p,gap_event,NULL);
 ESP_LOGI(TAG,"adv_start rc=%d; configured nominal1s interval, not a phone receipt",rc);
}
void app_main(void){
 esp_err_t e=nvs_flash_init();if(e!=ESP_OK){ESP_LOGE(TAG,"NVS init=%s; preserve data, no automatic erase",esp_err_to_name(e));return;}
 sensirion_i2c_hal_init();sht4x_init(0x44);if(demo_i2c_ready()){int16_t rc=sht4x_soft_reset();ESP_LOGI(TAG,"SHT40 soft_reset=%d; real reads follow",rc);}
 e=nimble_port_init();if(e!=ESP_OK){ESP_LOGE(TAG,"NimBLE init=%s",esp_err_to_name(e));return;}
 ble_hs_cfg.reset_cb=reset;ble_hs_cfg.sync_cb=sync_ready;nimble_port_freertos_init(host_task);
 uint16_t sequence=0;
 for(;;){
  int32_t t=0,rh=0;int16_t rc=demo_i2c_ready()?sht4x_measure_high_precision(&t,&rh):-1;
  w05_sample_t sample={.sequence=++sequence,.read_ok=rc==0,.t_milli=t,.rh_milli=rh,.sampled_ms=(uint64_t)esp_timer_get_time()/1000ULL};
  uint8_t payload[W05_PAYLOAD_SIZE],packet[W05_PACKET_SIZE];w05_payload(&sample,(uint64_t)esp_timer_get_time()/1000ULL,payload);w05_packet(payload,packet);
  ESP_LOGI(TAG,"ACTUAL_READ seq=%u rc=%d raw_t_milli=%"PRId32" raw_rh_milli=%"PRId32" flags=0x%02x; invalid fields are not measured zero",sample.sequence,rc,t,rh,payload[1]);
  ESP_LOG_BUFFER_HEX_LEVEL(TAG,packet,sizeof(packet),ESP_LOG_INFO);advertise(packet);vTaskDelay(pdMS_TO_TICKS(5000));
 }
}

参考:[4] Sensirion固定驱动:真实高精度读取/整数近似[5] NimBLE固定身份API:nrpa参数定义[6] NimBLE固定实现:随机地址实际处理[7] NimBLE固定广播API:31B与广播参数[12] IDF5.5.5 esp_timer:64位单调时钟

06 / 通信实践

手机从RAW复制字节,按自己的时间验新鲜

操作参考官方nRF Connect Android4.29.1,本站尚未安装/运行手机。记录实际App/系统/手机型号;Android12+按“附近设备”扫描/连接授权,较旧系统还涉及位置权限/位置服务,按实际提示处理。固定旧App文档的权限段不能覆盖所有现代系统情况。

自定义Service Data不保证被自动识别为温湿度。解析工具只接收复制的HEX;--payload是UUID分离后的10B,--packet是本例完整31B。不要将MAC、RSSI或界面标签一并拼入参数。

  1. 先从桥口确认真实ACTUAL_READ rc/seq与adv_start rc;Scanner启动前去掉名称/RSSI过滤,按Service Data/UUID或RAW内容找本例。
  2. 展开一行→RAW核AD长度和0x21类型→复制UUID后10B。无需CONNECT;不可连接为预期。
  3. 在工程根运行python host/decode_adv.py --payload,参数换真实10B HEX;完整31B才用--packet。记录flags、温度符号/单位和seq,对照同轮UART。
  4. 至少观察两个seq变化;接收单调时间超过15秒无变化则按接收规则判陈旧,分开记录重启/uint16回绕。
  5. 固定位置/姿态后记录RSSI;遮挡、多径、功率和扫描调度都影响它,不把dBm直接标成米数。
做到这里,你应该看到
能明确说出“看见广播”“收到新seq”“VALID样本”三者的区别。

参考:[10] nRF Connect4.29.1:Scanner/RAW/客户端[11] Android官方:Android12+与旧系统BLE权限

07 / 通信实践

做已有样本断线与冷启动缺模块两种实验

冷启动缺模块:断电取下模块后重启,预期每轮真实读取失败,广播VALID=0,不展示假0°C。恢复时断电复接四线再RESET,记录首个真实有效seq。没有做过的实验结果留空,不把预期写进已观察栏。

已有样本后断线:断电预装SDA支路低压SPST,正常出现有效样本后只切该信号开关,不带电挪杜邦线/拔电源。等下一轮读取错误,记录最后有效seq、错误flags、闭合后的恢复或所需RESET。冻结同一广播的场景还需检查接收端seq超时;单凭某次没扫到不能定位为确定RF丢包。

现象先检查恢复/记录
Scanner空广播rc、权限、前台扫描、过滤器、数据线去过滤后再扫,保留真实系统/配置
READ_ERRORVIN/3V、8/9、共地、CRC/I²C返回断电修线;必要RESET,记首个恢复seq
重复seq但RSSI更新广播缓存/更新失败、接收时钟按>15秒陈旧,不能刷新旧样本时间
新defaults不生效旧sdkconfig优先新配置目录重建并保留旧日志

参考:[1] 官方DevKitC1 v1.1固定文档:排针/USB/供电[3] Adafruit PID4885 Pinouts:VIN、3V输出、10k上拉和0x44[7] NimBLE固定广播API:31B与广播参数

08 / 通信实践

软件检查已完成,手机与物理记录等你执行

下载工程含README、完整依赖、原许可、同源C测试、独立Python解码器和仅表头observations.csv。本轮ASan/UBSan同源模型覆盖31B布局、UUIDLE、signed截断、裁边、无效掩码、seq回绕与完整64位陈旧;Python独立解析C产生的合成向量并拒绝畸形帧。另将原样Sensirion驱动接合成HAL字节,实际检查BEEF CRC、0xFD/10ms、两数据字CRC错误、读写失败与整数端点;这也不运行真实传感器或蓝牙。

原生构建与源/配置/bin/ELF/map/log SHA列在ble-usb-builds.json,固定源逐字核查在ble-usb-sources.json。真实手机扫描、模块精度、RSSI、断线恢复仍pending;真实完成后填CSV并保留失败行,不把合成向量当温湿度。下一篇在另一个工程中增加GATT配置与认证,不直接给此公开广播塞入口令。

参考:[4] Sensirion固定驱动:真实高精度读取/整数近似[7] NimBLE固定广播API:31B与广播参数

BACK TO THE SOURCE

资料来源与版本说明

按固定官方实现、厂家引脚和实际构建核查;主机夹具仅证明软件边界,指定板/手机/USB实体运行仍未执行。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. 官方DevKitC1 v1.1固定文档:排针/USB/供电
  2. Sensirion SHT4x V6.4:p3/10–13命令、CRC、换算
  3. Adafruit PID4885 Pinouts:VIN、3V输出、10k上拉和0x44
  4. Sensirion固定驱动:真实高精度读取/整数近似
  5. NimBLE固定身份API:nrpa参数定义
  6. NimBLE固定实现:随机地址实际处理
  7. NimBLE固定广播API:31B与广播参数
  8. NimBLE固定Service Data AD定义
  9. IDF5.5.5 NimBLE Kconfig:角色
  10. nRF Connect4.29.1:Scanner/RAW/客户端
  11. Android官方:Android12+与旧系统BLE权限
  12. IDF5.5.5 esp_timer:64位单调时钟