外观
3.10 BLE GATT 蓝牙通信
这节要搞懂:手机怎么通过蓝牙给设备下发配置和 WiFi、设备怎么把状态推回手机。 需要基础:2.8、3.3
一、为什么要有蓝牙这条通道
设备已经有 MQTT 可以远程控制了(3.11),为什么还要蓝牙?
因为 MQTT 必须联网,蓝牙不用。
| 场景 | MQTT | BLE |
|---|---|---|
| 设备刚拆封,还没配网 | ❌ 用不了 | ✅ 能用 |
| 路由器坏了 / 断网 | ❌ 用不了 | ✅ 能用 |
| 人在外地 | ✅ 能用 | ❌ 距离不够 |
BLE 是"没网时的兜底通道" —— 这是它在这个项目里最大的价值。它甚至可以直接给设备配 WiFi(第五节)。
二、GATT 结构回顾
概念在 2.8 讲透了,一句话复习:
服务(Service)是文件夹,特征值(Characteristic)是文件夹里的数据 —— 所有读写都是在读写特征值。
本工程用的是 NimBLE,不是 2.8 那套 Bluedroid
2.8 的入门例子用 Bluedroid(事件回调一条条走,好懂);本工程用 NimBLE,因为它省一半内存 —— 这个工程还要同时跑 WiFi、MQTT、HTTP 服务器和屏幕刷新,内存很紧张。
换的只是函数名和 GATT 的声明方式,2.8 学的概念全部照用:服务和特征值的层级、UUID、Read / Write / Notify、必须先订阅才能收到推送、MTU 协商和分包 —— 一条都没变。
所以这一节只讲这个工程定义了什么、能发什么命令,不再重复 BLE 的基础。
三、本工程的三个特征值 ⭐
设备广播的名字是 TianqiClock,手机扫描时找这个名字。
📁 天气时钟服务(TianqiClock)
├── 📄 配置特征值 手机【写】配置 JSON,也可以【读】当前配置
├── 📄 状态特征值 手机【读】设备状态,或【订阅】让设备主动推
└── 📄 WiFi 配网特征值 手机【写】ssid / password,让设备连路由器| 特征值 | 支持的操作 | 写什么进去 | 能干什么 |
|---|---|---|---|
| 配置 | 写 / 读 | 配置 JSON | 改闹钟、城市、亮度、音量、报警阈值(共 12 项,见下表) |
| 状态 | 读 / 订阅 Notify | — | 看设备当前时间、温湿度、天气等 |
| WiFi 配网 | 写 | {"ssid":...,"password":...} | 让设备连上路由器 |
为什么配网要单独一个特征值
WiFi 账号密码和普通配置性质不一样:
- 它是敏感信息,单独放便于以后加密或加权限
- 写完之后设备要去连网、可能要重启,处理流程和改个亮度完全不同
分开更清楚,也更安全。
四、配置特征值能改什么 ⭐
全部可配置项一览
| 字段 | 类型 | 取值范围 | 说明 |
|---|---|---|---|
alarm | 字符串 | "HH:MM" 或 "close" | 闹钟时间;close = 关闭 |
city | 字符串 | 城市拼音 | 天气城市 |
auto_light | 布尔 | true / false | 自动亮度开关(跟着光敏走) |
light | 整数 | 1 ~ 100 | 屏幕亮度(%),关掉自动亮度时生效 |
auto_light_min | 整数 | 1 ~ 100 | 自动亮度下限 |
auto_light_max | 整数 | 1 ~ 100 | 自动亮度上限 |
volume | 整数 | 0 ~ 100 | 音量(%),0 = 静音 |
refresh_minutes | 整数 | 分钟 | 天气刷新间隔 |
maxtemp_set | 小数 | ℃ | 温度报警上限 |
minitemp_set | 小数 | ℃ | 温度报警下限 |
maxhum_set | 整数 | % | 湿度报警上限 |
minihum_set | 整数 | % | 湿度报警下限 |
常用命令速查表
直接复制到调试工具的文本输入框就能用(工具见第十节):
| 想干什么 | 写这个 |
|---|---|
| 设闹钟为 7 点 30 分 | {"alarm":"07:30"} |
| 关闭闹钟 | {"alarm":"close"} |
| 天气城市改成深圳 | {"city":"shenzhen"} |
| 开启自动亮度 | {"auto_light":true} |
| 关闭自动亮度,亮度固定 60% | {"auto_light":false,"light":60} |
| 自动亮度范围设为 15% ~ 90% | {"auto_light_min":15,"auto_light_max":90} |
| 音量设为 70% | {"volume":70} |
| 天气每 10 分钟刷新一次 | {"refresh_minutes":10} |
| 温度报警 5℃ ~ 35℃ | {"maxtemp_set":35.0,"minitemp_set":5.0} |
| 湿度报警 20% ~ 80% | {"maxhum_set":80,"minihum_set":20} |
一次写完整配置
所有字段可以一次性发过去:
json
{
"city": "shenzhen",
"auto_light": true,
"light": 80,
"auto_light_min": 15,
"auto_light_max": 90,
"volume": 70,
"alarm": "07:30",
"refresh_minutes": 10,
"maxtemp_set": 35.0,
"minitemp_set": 5.0,
"maxhum_set": 80,
"minihum_set": 20
}这 12 项只有 BLE 全支持
MQTT 只开放其中 8 项 —— alarm、refresh_minutes、auto_light_min、auto_light_max 目前只能用蓝牙改。
对照表见 3.11 第五节。
只写想改的字段就行
不用每次都发完整 JSON。只带上要改的字段,其他保持原样:
json
{"volume":50}设备解析时只更新它认识且收到的字段。
亮度是 1 ~ 100,不是 0
light、auto_light_min、auto_light_max 会被限制在 1 ~ 100,填 0 会被改成 1。
因为亮度为 0 = 屏幕全黑,用户会以为设备坏了 —— 留 1% 至少还能看见。
只有 volume 是 0 ~ 100,音量为 0 就是静音,这是合理需求。
亮度字段名认准 light
工程里为了兼容,还认这些别名:
| 正式字段 | 兼容的别名 |
|---|---|
light | brightness、ray |
auto_light | auto_brightness |
auto_light_min | auto_brightness_min |
auto_light_max | auto_brightness_max |
但别用别名 —— 统一用左边那一列最清楚,也和文档、MQTT 保持一致。
refresh_minutes 别设太小
免费天气 API 有每日调用次数限制,设成 1 分钟很快就超额。10 分钟是个合适的值。
五、WiFi 配网特征值
往 WiFi 配网特征值写:
| 想干什么 | 写这个 |
|---|---|
| 连接有密码的 WiFi | {"ssid":"你的WiFi名称","password":"你的WiFi密码"} |
| 连接无密码的 WiFi | {"ssid":"你的WiFi名称"} |
这是第二条配网通道
3.8 讲的是网页配网(连热点 ESP32-Clock-Setup、浏览器填表)。
蓝牙配网是另一条路:不用连热点,手机连蓝牙直接写进去。
| 网页配网 | 蓝牙配网 | |
|---|---|---|
| 设备要变成热点吗 | ✅ 要 | ❌ 不用 |
| 手机要做什么 | 连热点 + 开浏览器 | 连蓝牙 + 写特征值 |
| 适合 | 普通用户 | 开发调试更快 |
两条都留着,各有各的场合。
六、读状态特征值
手机可以主动读状态特征值,也可以订阅 Notify 让设备主动推。
返回的内容是一段设备状态文本,方便在手机上一眼看全,包含:
时间、室内温湿度、天气、城市、
自动亮度、当前亮度、光照、音量、闹钟、刷新间隔不是标准 JSON
状态特征值返回的是给人看的文本,不是给程序解析的 JSON。
好处:用调试工具一读就懂,不用再解析。 代价:如果以后要写自己的手机 App,改成 JSON 会更好处理。
Notify 的注意点
手机没订阅,推了也白推
Notify 必须手机先订阅才有效(2.8 讲过的 CCCD)。
调试时先在工具里点一下"订阅 / Enable Notification",否则你推一百次也看不到。
别推太勤
每秒推一次会明显增加功耗,手机端也处理不过来。
状态真的变了才推 —— 温度变化 0.1 度不用推。
七、数据包有多大:分包处理
短 JSON 一包就够,长 JSON 会自动分包
BLE 默认的小包传输,一包大约只有 20 字节(2.8 讲过 MTU 的限制)。
{"alarm":"07:30"} 是 17 字节,一包正好装下。但第四节那个完整配置有两百多字节,一包肯定装不下。
本工程已经做了分包处理:
| 方向 | 处理方式 | 上限 |
|---|---|---|
| 手机 → 设备(配置) | 设备端把多个包拼接起来再解析 | 约 512 字节 |
| 设备 → 手机(Notify) | 按当前 MTU 自动分包发送 | 单包最多 244 字节 |
所以长 JSON 是能发的,只是它不是"一次塞进去",而是分成几包传完再处理。
学的时候先用短的
刚上手时先用 {"alarm":"07:30"} 这种短命令把流程跑通,确认能收能解析。
等短的没问题了,再试完整配置那种长 JSON —— 这样出问题时好定位是分包的锅还是解析的锅。
八、收到之后怎么处理
BLE 写回调里做三件事:取数据 → 解析 JSON → 交给统一入口。
c
static int config_char_write_cb(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg)
{
char buf[512] = {0}; // ⭐ 配置缓冲区 512 字节,容得下长 JSON
uint16_t len = 0;
// 1. 取出这一包数据(分包时会累积到缓冲区里)
int rc = ble_hs_mbuf_to_flat(ctxt->om, buf, sizeof(buf) - 1, &len);
if (rc != 0 || len == 0) {
return BLE_ATT_ERR_UNLIKELY;
}
// 2. 交给 JSON 解析入口
app_config_update_from_json(buf);
return 0;
}app_config_update_from_json() 负责把 JSON 里认识的字段挑出来,填进配置结构体,最后调用 app_config_update() 保存并生效。
回调里别干重活
BLE 回调跑在协议栈自己的任务上。在里面刷屏、写卡、等锁,都会拖慢甚至卡死整个蓝牙。
只解析 + 更新配置,然后立刻返回(和中断服务函数是同一个原则)。
九、三类配置,各走各的链路 ⭐
设备的"设置"其实分三类,不要混为一谈:
【第一类:运行配置】城市、亮度、音量、闹钟、报警阈值……
BLE 配置特征值 ──► app_config_update_from_json() ──┐
├──► app_config_update()
MQTT 属性下发 ─────────────────────────────────────┘ │
▼
写入 NVS(配置)
│
▼
配置变化回调
│
▼
s_config_event_queue
│
▼
config_apply_task
│
▼
改硬件
【第二类:WiFi 凭据】ssid / password
HTTP 网页配网 ──► app_wifi_provision() / save_wifi() ──► NVS(wifi_cfg 命名空间)
BLE WiFi 特征值 ─┘
【第三类:SD 卡文件操作】
HTTP 网页 ──► app_sd / app_audio_player / app_image_viewer别把三类混为一谈
它们走的是完全不同的链路,存的也是不同的 NVS 命名空间。
| 存到哪 | 经过谁 | |
|---|---|---|
| 运行配置 | 配置命名空间 | app_config_update() → 配置回调 → config_apply_task |
| WiFi 凭据 | wifi_cfg 命名空间 | app_wifi_provision() / save_wifi() |
| SD 卡操作 | 不存配置,直接操作文件 | app_sd 等模块 |
WiFi 账号密码不走 app_config_update() —— 它有自己的保存路径。混在一起讲,学员会以为改 WiFi 和改亮度是同一条链路。
但"运行配置"这一类确实汇合了
看第一类:BLE 和 MQTT 两条协议,最后都落到 app_config_update()。
BLE 多走一步 app_config_update_from_json() 解析 JSON,MQTT 在自己的回调里解析完直接调 —— 汇合之后,写 NVS、通知、应用到硬件这一整套只有一份代码。
以后再加一种控制方式(语音、红外遥控),只要它最终调到 app_config_update(),下游一行都不用动。
十、调试工具:微信小程序
微信里搜小程序 「BLE 蓝牙调试助手」(同类的还有几个,名字里带 "BLE 调试" 的都能用)。
免安装,iOS 和安卓都能用 —— 扫描设备、查看服务和特征值、手动写 JSON、订阅 Notify 都能做。
调试步骤
① 打开小程序,点"搜索设备"
↓
② 在列表里找到 TianqiClock,点连接
↓
③ 展开服务,能看到三个特征值
↓
④ 找到【配置特征值】,选"写入",
输入框切到【文本 / UTF-8】模式,
打 {"alarm":"07:30"} 发送
↓
⑤ 看设备屏幕上闹钟有没有变输入模式一定要选"文本"
调试工具默认往往是 HEX(十六进制) 模式 —— 那样输入 {"alarm":"07:30"} 会被当成十六进制解析,设备收到的是一堆乱码。
必须切到"文本 / 字符串 / UTF-8"模式,这是新手最常卡住的地方。
它是排查问题的"标准答案"
自己写手机 App 时如果发现指令不生效,先用小程序试一遍同样的指令:
- 小程序能改成功 → 设备端没问题,去查你的 App
- 小程序也不行 → 问题在设备端,去看串口日志
这一步能省掉大量瞎猜的时间。
本节要点
- BLE 是没网时的兜底通道,开机流程里排在 WiFi 之前
- 设备名
TianqiClock - ⭐ 三个特征值:配置(写/读)、状态(读/Notify)、WiFi 配网(写)
- 配置用 JSON,共 12 个可配置项(第四节有一览表和速查表)
- 只写想改的字段就行;亮度字段统一用
light - 闹钟
"HH:MM"设置、"close"关闭 - ⭐ 配置做了分包拼接,最大约 512 字节;Notify 按 MTU 分包,单包最多 244 字节
- 状态特征值返回的是给人看的状态文本,不是标准 JSON
- ⭐ BLE / MQTT / 网页最终都汇合到
app_config_update() - 调试用微信小程序 「BLE 蓝牙调试助手」,输入模式记得切到"文本"
操作过程
目标:用微信小程序连上设备,改一个配置,看屏幕跟着变。
第 1 步:准备小程序
微信搜小程序 「BLE 蓝牙调试助手」,打开备用。
第 2 步:编译烧录
串口应该出现:
I (1200) ble: advertising as TianqiClock第 3 步:扫描并连接
- 小程序点"搜索设备"
- 列表里找到
TianqiClock - 点连接
串口:
I (30000) ble: connection established第 4 步:找到配置特征值
连上后展开服务,能看到三个特征值。找到支持"写"的那个(配置特征值)。
分不清哪个是哪个
小程序会标出每个特征值支持的操作:
- 只有"写" → 配置特征值 或 WiFi 配网特征值
- 有"读 + 通知" → 状态特征值
第 5 步:发第一条命令
点写入,输入模式切到「文本 / UTF-8」,输入:
json
{"volume":30}千万别用 HEX 模式
默认往往是 HEX(十六进制),那样这串 JSON 会被当成十六进制解析,设备收到的是乱码。
这是新手最常卡住的地方。
第 6 步:应该看到什么
串口:
I (45000) ble: config write: {"volume":30}
I (45010) config: volume updated: 70 -> 30第 7 步:试一条能看到现象的
音量看不见,换个直观的:
json
{"auto_light":false,"light":20}屏幕应该立刻变暗。 再发:
json
{"auto_light":false,"light":100}屏幕变亮。 这说明整条链路通了:小程序 → BLE → 解析 → 存 NVS → 应用到硬件。
第 8 步:试试订阅状态
找到状态特征值,点订阅 / Enable Notification,然后读一次 —— 应该能看到设备当前的时间、温湿度、天气等信息。
第 9 步:试试蓝牙配网
往 WiFi 配网特征值写:
json
{"ssid":"你家WiFi","password":"密码"}设备应该开始连网。
动手试试
- 用微信小程序「BLE 蓝牙调试助手」连上
TianqiClock,找到三个特征值。 - 往配置特征值写
{"alarm":"07:30"},看屏幕上闹钟有没有变。 - 再写
{"alarm":"close"}关掉它。 - 写
{"auto_light":false,"light":30},观察屏幕变暗。 - 订阅状态特征值,读一次设备状态。
- 先写短 JSON 再写第四节那个完整配置,体会分包处理的区别。
- 断开 WiFi,验证蓝牙依然能改配置 —— 这就是它存在的意义。