fix blue server
This commit is contained in:
parent
ea5050309e
commit
02ae116488
489
BLE_JSON_通讯模块开发计划.md
Normal file
489
BLE_JSON_通讯模块开发计划.md
Normal file
@ -0,0 +1,489 @@
|
||||
# BLE JSON 通讯模块开发计划
|
||||
|
||||
> 新增功能,保留原有 BluFi 配网等全部功能不变。
|
||||
|
||||
---
|
||||
|
||||
## 一、功能定位
|
||||
|
||||
在现有 BluFi 蓝牙配网基础上,**新增**一个自定义 BLE GATT Service,使用 JSON 格式进行设备与 App 之间的双向通讯。
|
||||
|
||||
- BluFi 配网模块(`bluetooth_provisioning.*`):**完全保留,不做任何修改**
|
||||
- 新 BLE JSON 通讯模块(`ble_service.*`):独立注册 GATTS App,与 BluFi 共存
|
||||
|
||||
### 共存原理
|
||||
|
||||
```
|
||||
Bluedroid GATTS 栈(已配置 max_profiles=8)
|
||||
├── App 0: BluFi Service (UUID: 0xFFFF) ← 已有,不动
|
||||
└── App 1: JSON Service (UUID: 0xAB00) ← 新增
|
||||
```
|
||||
|
||||
两个 App 独立回调、独立 handle,共享同一个 Bluedroid 栈和 BLE 连接。
|
||||
|
||||
---
|
||||
|
||||
## 二、底层传输参数约束
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 默认 MTU | 23 bytes | BLE 标准默认值 |
|
||||
| 协商目标 MTU | **512 bytes** | `esp_ble_gatt_set_local_mtu(512)` |
|
||||
| ATT 协议头开销 | 3 bytes | 固定开销 |
|
||||
| **单包最大有效载荷** | **509 bytes** | 512 - 3 |
|
||||
| 广播包最大长度 | 31 + 31 bytes | ADV + SCAN RSP |
|
||||
| BLE 协议版本 | 4.2 | BLE 5.0 已关闭(BluFi 兼容) |
|
||||
| 最大连接数 | 4 (CONFIG_BT_ACL_CONNECTIONS) | 当前 BluFi 占 1 |
|
||||
|
||||
---
|
||||
|
||||
## 三、GATT Service 设计
|
||||
|
||||
```
|
||||
Custom JSON Communication Service
|
||||
│
|
||||
├── Service UUID: 0xAB00 (Primary Service)
|
||||
│
|
||||
├── Characteristic 1: JSON_WRITE (App → 设备)
|
||||
│ ├── UUID: 0xAB01
|
||||
│ ├── Properties: WRITE
|
||||
│ ├── Permissions: ESP_GATT_PERM_WRITE
|
||||
│ └── Max Value: 512 bytes
|
||||
│
|
||||
├── Characteristic 2: JSON_NOTIFY (设备 → App)
|
||||
│ ├── UUID: 0xAB02
|
||||
│ ├── Properties: NOTIFY | READ
|
||||
│ ├── Permissions: ESP_GATT_PERM_READ
|
||||
│ ├── Max Value: 512 bytes
|
||||
│ └── Descriptor: CCCD (0x2902, 2 bytes, 用于开启/关闭 NOTIFY)
|
||||
│
|
||||
└── Characteristic 3: JSON_STATUS (设备状态被动读取,可选)
|
||||
├── UUID: 0xAB03
|
||||
├── Properties: READ
|
||||
├── Permissions: ESP_GATT_PERM_READ
|
||||
└── Max Value: 512 bytes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、JSON 消息格式
|
||||
|
||||
### 4.1 公共格式
|
||||
|
||||
**请求(App → 设备):**
|
||||
```json
|
||||
{"cmd":"xxx","id":1,"data":{...}}
|
||||
```
|
||||
|
||||
**响应(设备 → App):**
|
||||
```json
|
||||
{"cmd":"xxx","id":1,"code":0,"data":{...}}
|
||||
```
|
||||
|
||||
**主动推送(设备 → App,无 id):**
|
||||
```json
|
||||
{"cmd":"event","data":{"type":"xxx",...}}
|
||||
```
|
||||
|
||||
### 4.2 固定开销
|
||||
|
||||
| 字段 | 占用 | 说明 |
|
||||
|------|------|------|
|
||||
| `{"cmd":""}` | 10 bytes | 命令名空壳 |
|
||||
| `,"id":1` | 7 bytes | 消息 ID(1~999) |
|
||||
| `,"data":{}` | 10 bytes | 数据域空壳 |
|
||||
| `,"code":0` | 9 bytes | 响应码(仅响应) |
|
||||
| **请求固定开销** | **~27 bytes** | 留给 data 约 482 bytes |
|
||||
| **响应固定开销** | **~36 bytes** | 留给 data 约 473 bytes |
|
||||
|
||||
---
|
||||
|
||||
## 五、逐条命令参数与大小计算
|
||||
|
||||
### 5.1 set_wifi — WiFi 配置
|
||||
|
||||
**方向:** App → 设备
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{"cmd":"set_wifi","id":1,"data":{"ssid":"MyHomeWiFi_5G","pass":"myP@ssw0rd123"}}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 最大长度 | 来源 |
|
||||
|------|------|---------|------|
|
||||
| ssid | string | 32 bytes | IEEE 802.11 标准 |
|
||||
| pass | string | 64 bytes | WPA2 标准 |
|
||||
|
||||
**最大请求大小:** 55(框架) + 32(ssid) + 64(pass) = **151 bytes** → 单包 ✅
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{"cmd":"set_wifi","id":1,"code":0,"data":{"status":"connecting"}}
|
||||
```
|
||||
**大小:** ~60 bytes → 单包 ✅
|
||||
|
||||
---
|
||||
|
||||
### 5.2 wifi_list — 获取 WiFi 列表
|
||||
|
||||
**方向:** App → 设备
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{"cmd":"wifi_list","id":2}
|
||||
```
|
||||
**大小:** 24 bytes → 单包 ✅
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{"cmd":"wifi_list","id":2,"code":0,"data":{"list":[{"s":"MyWiFi","r":-40},{"s":"Office","r":-55}]}}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 最大长度 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| s | string | 32 bytes | SSID 名称 |
|
||||
| r | number | 4 bytes | RSSI 值 (-100~0) |
|
||||
|
||||
**单条记录最大:** ~46 bytes(SSID 32字符时)
|
||||
|
||||
**容量计算(可用空间 = 509 - 58 = 451 bytes):**
|
||||
|
||||
| 场景 | 每条大小 | 单包可容纳 |
|
||||
|------|---------|-----------|
|
||||
| 典型(SSID ~10字符) | ~25 bytes | ~17 条 |
|
||||
| 最坏(SSID 32字符) | ~46 bytes | ~9 条 |
|
||||
| **设计限制** | — | **最多返回 8 条** |
|
||||
|
||||
**最大响应大小:** 58(框架) + 8 × 46 = **426 bytes** → 单包 ✅
|
||||
|
||||
---
|
||||
|
||||
### 5.3 dev_info — 获取设备信息
|
||||
|
||||
**方向:** App → 设备
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{"cmd":"dev_info","id":3}
|
||||
```
|
||||
**大小:** 23 bytes → 单包 ✅
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{"cmd":"dev_info","id":3,"code":0,"data":{"model":"Kapi_Rtc","fw":"1.0.0","mac":"AA:BB:CC:DD:EE:FF","board":"movecall-moji-esp32s3","uuid":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 最大长度 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| model | string | 20 bytes | 产品型号 |
|
||||
| fw | string | 12 bytes | 固件版本 x.x.x |
|
||||
| mac | string | 17 bytes | MAC 地址 AA:BB:CC:DD:EE:FF |
|
||||
| board | string | 30 bytes | 板型名称 |
|
||||
| uuid | string | 36 bytes | 设备 UUID |
|
||||
|
||||
**最大响应大小:** ~180 bytes → 单包 ✅
|
||||
|
||||
---
|
||||
|
||||
### 5.4 status — 获取设备运行状态
|
||||
|
||||
**方向:** App → 设备
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{"cmd":"status","id":4}
|
||||
```
|
||||
**大小:** 21 bytes → 单包 ✅
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{"cmd":"status","id":4,"code":0,"data":{"state":"idle","bat":85,"chg":false,"rssi":-45,"vol":70}}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 最大长度 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| state | string | 11 bytes | 设备状态(见状态枚举表) |
|
||||
| bat | number | 3 bytes | 电池电量 0~100 |
|
||||
| chg | boolean | 5 bytes | 是否充电中 |
|
||||
| rssi | number | 4 bytes | WiFi 信号 -100~0 |
|
||||
| vol | number | 3 bytes | 音量 0~100 |
|
||||
|
||||
**设备状态枚举对照:**
|
||||
|
||||
| DeviceState 枚举 | JSON 值 | 字节数 |
|
||||
|------------------|---------|--------|
|
||||
| kDeviceStateUnknown | `"unknown"` | 7 |
|
||||
| kDeviceStateStarting | `"starting"` | 8 |
|
||||
| kDeviceStateWifiConfiguring | `"wifi_config"` | 11 |
|
||||
| kDeviceStateIdle | `"idle"` | 4 |
|
||||
| kDeviceStateConnecting | `"connecting"` | 10 |
|
||||
| kDeviceStateListening | `"listening"` | 9 |
|
||||
| kDeviceStateSpeaking | `"speaking"` | 8 |
|
||||
| kDeviceStateDialog | `"dialog"` | 6 |
|
||||
| kDeviceStateUpgrading | `"upgrading"` | 9 |
|
||||
| kDeviceStateActivating | `"activating"` | 10 |
|
||||
| kDeviceStateFatalError | `"error"` | 5 |
|
||||
|
||||
**最大响应大小:** ~105 bytes → 单包 ✅
|
||||
|
||||
---
|
||||
|
||||
### 5.5 set_vol — 设置音量
|
||||
|
||||
**方向:** App → 设备
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{"cmd":"set_vol","id":5,"data":{"vol":80}}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 范围 | 说明 |
|
||||
|------|------|------|------|
|
||||
| vol | number | 0~100 | 音量百分比 |
|
||||
|
||||
**最大请求大小:** 37 bytes → 单包 ✅
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{"cmd":"set_vol","id":5,"code":0}
|
||||
```
|
||||
**大小:** 30 bytes → 单包 ✅
|
||||
|
||||
---
|
||||
|
||||
### 5.6 iot — 控制 IoT 设备属性
|
||||
|
||||
**方向:** App → 设备
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{"cmd":"iot","id":6,"data":{"thing":"lamp","prop":"brightness","val":50}}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 最大长度 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| thing | string | 20 bytes | IoT 设备名 (speaker/lamp/screen 等) |
|
||||
| prop | string | 20 bytes | 属性名 |
|
||||
| val | number/string/bool | 20 bytes | 属性值 |
|
||||
|
||||
**最大请求大小:** ~95 bytes → 单包 ✅
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{"cmd":"iot","id":6,"code":0}
|
||||
```
|
||||
**大小:** 27 bytes → 单包 ✅
|
||||
|
||||
---
|
||||
|
||||
### 5.7 reboot — 重启设备
|
||||
|
||||
**方向:** App → 设备
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{"cmd":"reboot","id":7}
|
||||
```
|
||||
**大小:** 22 bytes → 单包 ✅
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{"cmd":"reboot","id":7,"code":0}
|
||||
```
|
||||
**大小:** 29 bytes → 单包 ✅
|
||||
|
||||
---
|
||||
|
||||
### 5.8 ota — 检查固件更新
|
||||
|
||||
**方向:** App → 设备
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{"cmd":"ota","id":8}
|
||||
```
|
||||
**大小:** 19 bytes → 单包 ✅
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{"cmd":"ota","id":8,"code":0,"data":{"cur":"1.0.0","new":"1.1.0","has_update":true}}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 最大长度 | 说明 |
|
||||
|------|------|---------|------|
|
||||
| cur | string | 12 bytes | 当前版本 |
|
||||
| new | string | 12 bytes | 最新版本 |
|
||||
| has_update | boolean | 5 bytes | 是否有更新 |
|
||||
|
||||
**最大响应大小:** ~78 bytes → 单包 ✅
|
||||
|
||||
---
|
||||
|
||||
### 5.9 ping — 心跳保活
|
||||
|
||||
**方向:** 双向
|
||||
|
||||
**请求:**
|
||||
```json
|
||||
{"cmd":"ping","id":9}
|
||||
```
|
||||
**大小:** 20 bytes → 单包 ✅
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{"cmd":"ping","id":9,"code":0}
|
||||
```
|
||||
**大小:** 28 bytes → 单包 ✅
|
||||
|
||||
---
|
||||
|
||||
### 5.10 event — 设备主动推送
|
||||
|
||||
**方向:** 设备 → App(通过 NOTIFY,无 id)
|
||||
|
||||
**WiFi 连接成功:**
|
||||
```json
|
||||
{"cmd":"event","data":{"type":"wifi_connected","ssid":"Home","ip":"192.168.1.100","rssi":-40}}
|
||||
```
|
||||
**最大大小:** ~125 bytes → 单包 ✅
|
||||
|
||||
**WiFi 断开:**
|
||||
```json
|
||||
{"cmd":"event","data":{"type":"wifi_disconnected","reason":201}}
|
||||
```
|
||||
**大小:** ~58 bytes → 单包 ✅
|
||||
|
||||
**电池低电量:**
|
||||
```json
|
||||
{"cmd":"event","data":{"type":"low_battery","bat":10}}
|
||||
```
|
||||
**大小:** ~51 bytes → 单包 ✅
|
||||
|
||||
**设备状态变化:**
|
||||
```json
|
||||
{"cmd":"event","data":{"type":"state_changed","state":"listening"}}
|
||||
```
|
||||
**大小:** ~62 bytes → 单包 ✅
|
||||
|
||||
---
|
||||
|
||||
## 六、总览表
|
||||
|
||||
| # | 命令 | 方向 | 请求最大 | 响应最大 | 单包? |
|
||||
|---|------|------|---------|---------|-------|
|
||||
| 1 | `set_wifi` | App→设备 | **151 B** | 60 B | ✅ |
|
||||
| 2 | `wifi_list` | App→设备 | 24 B | **426 B** (限8条) | ✅ |
|
||||
| 3 | `dev_info` | App→设备 | 23 B | **180 B** | ✅ |
|
||||
| 4 | `status` | App→设备 | 21 B | **105 B** | ✅ |
|
||||
| 5 | `set_vol` | App→设备 | 37 B | 30 B | ✅ |
|
||||
| 6 | `iot` | App→设备 | 95 B | 27 B | ✅ |
|
||||
| 7 | `reboot` | App→设备 | 22 B | 29 B | ✅ |
|
||||
| 8 | `ota` | App→设备 | 19 B | 78 B | ✅ |
|
||||
| 9 | `ping` | 双向 | 20 B | 28 B | ✅ |
|
||||
| 10 | `event` | 设备→App | — | **≤125 B** | ✅ |
|
||||
|
||||
**结论:MTU=512 时,所有命令均可单包传输,不需要分包机制。**
|
||||
|
||||
---
|
||||
|
||||
## 七、错误码定义
|
||||
|
||||
| code | 含义 | 示例场景 |
|
||||
|------|------|---------|
|
||||
| 0 | 成功 | 所有正常响应 |
|
||||
| 1 | 参数错误 | JSON 格式错误 / 缺少必要字段 |
|
||||
| 2 | 命令不支持 | 未知的 cmd |
|
||||
| 3 | 设备忙 | 正在 OTA / 正在配网 |
|
||||
| 4 | WiFi 连接失败 | SSID 不存在 / 密码错误 |
|
||||
| 5 | 超时 | 操作超时 |
|
||||
| 6 | 内部错误 | 设备内部异常 |
|
||||
|
||||
**错误响应示例:**
|
||||
```json
|
||||
{"cmd":"set_wifi","id":1,"code":4,"msg":"wrong password"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、通讯时序示例
|
||||
|
||||
### 场景:App 配置 WiFi
|
||||
|
||||
```
|
||||
App 设备
|
||||
│ │
|
||||
│──── BLE Connect ──────────────────────────>│
|
||||
│<─── MTU Exchange (512) ───────────────────>│
|
||||
│──── Enable NOTIFY (write CCCD=0x0001) ───>│
|
||||
│ │
|
||||
│──── WRITE: {"cmd":"status","id":1} │
|
||||
│<─── NOTIFY: {"cmd":"status","id":1, │
|
||||
│ "code":0,"data":{"state":"idle", │
|
||||
│ "bat":85,"chg":false,"rssi":0, │
|
||||
│ "vol":70}} │
|
||||
│ │
|
||||
│──── WRITE: {"cmd":"wifi_list","id":2} │
|
||||
│ [设备扫描WiFi]
|
||||
│<─── NOTIFY: {"cmd":"wifi_list","id":2, │
|
||||
│ "code":0,"data":{"list":[ │
|
||||
│ {"s":"Home","r":-40}, │
|
||||
│ {"s":"Office","r":-55}]}} │
|
||||
│ │
|
||||
│──── WRITE: {"cmd":"set_wifi","id":3, │
|
||||
│ "data":{"ssid":"Home", │
|
||||
│ "pass":"123456"}} │
|
||||
│<─── NOTIFY: {"cmd":"set_wifi","id":3, │
|
||||
│ "code":0,"data":{ │
|
||||
│ "status":"connecting"}} │
|
||||
│ [设备连接WiFi]
|
||||
│<─── NOTIFY: {"cmd":"event","data": │
|
||||
│ {"type":"wifi_connected", │
|
||||
│ "ssid":"Home", │
|
||||
│ "ip":"192.168.1.100","rssi":-40}} │
|
||||
│ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、实现文件清单
|
||||
|
||||
| 文件 | 动作 | 说明 |
|
||||
|------|------|------|
|
||||
| `main/ble_service.h` | **新增** | BleJsonService 类定义 |
|
||||
| `main/ble_service.cc` | **新增** | GATT Server 实现 + JSON 收发 |
|
||||
| `main/ble_service_config.h` | **新增** | UUID / MTU / 超时等配置宏 |
|
||||
| `main/application.cc` | **修改** | 集成 BleJsonService,注册命令处理回调 |
|
||||
| `main/application.h` | **修改** | 添加 BleJsonService 成员指针 |
|
||||
| `main/CMakeLists.txt` | **修改** | 添加 ble_service.cc 到编译列表 |
|
||||
| `sdkconfig.defaults` | **可能修改** | 若需调整 GATT profile 数量 |
|
||||
| `bluetooth_provisioning.*` | **不动** | BluFi 配网保持原样 |
|
||||
| `bluetooth_provisioning_config.h` | **不动** | BluFi 配置保持原样 |
|
||||
|
||||
---
|
||||
|
||||
## 十、备用分包协议(当前不需要实现)
|
||||
|
||||
若未来某条消息超过 509 字节,可启用以下分包协议:
|
||||
|
||||
```
|
||||
分包头 (2 bytes):
|
||||
Byte 0: [7:4] 总包数(1~15), [3:0] 当前包号(0~14)
|
||||
Byte 1: 0x00=中间包, 0x01=最后一包
|
||||
Byte 2~N: JSON 片段
|
||||
|
||||
单包判断: 首字节为 '{' (0x7B) → 完整 JSON,无分包头
|
||||
首字节非 '{' → 分包数据,需重组
|
||||
```
|
||||
|
||||
当前设计下 WiFi 列表限制 8 条,所有消息均 ≤509 字节,**无需实现分包**。
|
||||
|
||||
---
|
||||
|
||||
## 十一、依赖与约束
|
||||
|
||||
- **JSON 库:** cJSON(项目已有,无需引入新依赖)
|
||||
- **BLE 栈:** Bluedroid(已启用,与 BluFi 共用)
|
||||
- **GATT 资源:** max_profiles=8(BluFi 占 1,新模块占 1,富余 6 个)
|
||||
- **内存:** 8MB PSRAM + 320KB DRAM(JSON 解析开销可忽略)
|
||||
- **输出格式:** 使用 `cJSON_PrintUnformatted()` 紧凑输出,无空格无换行
|
||||
- **BLE 回调线程安全:** GATTS 回调中不直接解析 JSON,通过 FreeRTOS 队列转发到应用任务处理
|
||||
@ -19,6 +19,7 @@ set(SOURCES "audio_codecs/audio_codec.cc"
|
||||
"settings.cc"
|
||||
"background_task.cc"
|
||||
"bluetooth_provisioning.cc" # 蓝牙配网实现
|
||||
"ble_service.cc" # BLE JSON 通讯服务
|
||||
"weather_api.cc"
|
||||
"main.cc"
|
||||
)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
#include "application.h"
|
||||
#include "ble_service_config.h"
|
||||
#include "board.h"
|
||||
#include "wifi_board.h"
|
||||
#include "display.h"
|
||||
@ -1572,6 +1573,10 @@ void Application::Start() {
|
||||
#endif
|
||||
|
||||
SetDeviceState(kDeviceStateIdle);
|
||||
|
||||
// 初始化 BLE JSON 通讯服务
|
||||
InitBleJsonService();
|
||||
|
||||
// 每次设备开机后idle状态下测试 自动检测并设置当前位置打印
|
||||
//此逻辑为冗余操作,当前NVS中没有城市信息时会自动调用 位置查询API
|
||||
// Schedule([]() {
|
||||
@ -3034,3 +3039,222 @@ void Application::InitializeWebsocketProtocol() {
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
// ============================================================
|
||||
// BLE JSON 通讯服务集成
|
||||
// ============================================================
|
||||
|
||||
const char* Application::DeviceStateToString(DeviceState state) {
|
||||
int idx = static_cast<int>(state);
|
||||
if (idx >= 0 && idx < static_cast<int>(sizeof(STATE_STRINGS) / sizeof(STATE_STRINGS[0]))) {
|
||||
return STATE_STRINGS[idx];
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
void Application::InitBleJsonService() {
|
||||
ESP_LOGI(TAG, "初始化 BLE JSON 通讯服务...");
|
||||
|
||||
if (!ble_json_service_.Initialize()) {
|
||||
ESP_LOGE(TAG, "BLE JSON 服务初始化失败");
|
||||
return;
|
||||
}
|
||||
|
||||
ble_json_service_.SetCommandCallback(
|
||||
[this](const std::string& cmd, int msg_id, cJSON* data) {
|
||||
HandleBleJsonCommand(cmd, msg_id, data);
|
||||
});
|
||||
|
||||
if (!ble_json_service_.Start()) {
|
||||
ESP_LOGE(TAG, "BLE JSON 服务启动失败");
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "BLE JSON 通讯服务启动成功");
|
||||
}
|
||||
|
||||
void Application::HandleBleJsonCommand(const std::string& cmd, int msg_id, cJSON* data) {
|
||||
auto& board = Board::GetInstance();
|
||||
|
||||
// ---- ping ----
|
||||
if (cmd == "ping") {
|
||||
ble_json_service_.SendResponse(cmd, msg_id, 0, "pong");
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- status: 返回设备运行状态 ----
|
||||
if (cmd == "status") {
|
||||
cJSON* resp = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(resp, "s", DeviceStateToString(device_state_));
|
||||
|
||||
int battery_level = 0;
|
||||
bool charging = false, discharging = false;
|
||||
if (board.GetBatteryLevel(battery_level, charging, discharging)) {
|
||||
cJSON_AddNumberToObject(resp, "bat", battery_level);
|
||||
cJSON_AddBoolToObject(resp, "chg", charging);
|
||||
}
|
||||
|
||||
auto* codec = board.GetAudioCodec();
|
||||
if (codec) {
|
||||
cJSON_AddNumberToObject(resp, "vol", codec->output_volume());
|
||||
}
|
||||
|
||||
// 当前 WiFi 信息
|
||||
wifi_ap_record_t ap{};
|
||||
if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
|
||||
cJSON_AddStringToObject(resp, "ssid", reinterpret_cast<const char*>(ap.ssid));
|
||||
cJSON_AddNumberToObject(resp, "rssi", ap.rssi);
|
||||
}
|
||||
|
||||
ble_json_service_.SendResponse(cmd, msg_id, 0, "ok", resp);
|
||||
cJSON_Delete(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- dev_info: 返回设备信息 ----
|
||||
if (cmd == "dev_info") {
|
||||
cJSON* resp = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(resp, "mac", SystemInfo::GetMacAddress().c_str());
|
||||
cJSON_AddStringToObject(resp, "board", BOARD_NAME);
|
||||
auto app_desc = esp_app_get_description();
|
||||
cJSON_AddStringToObject(resp, "fw", app_desc->version);
|
||||
cJSON_AddStringToObject(resp, "chip", SystemInfo::GetChipModelName().c_str());
|
||||
cJSON_AddStringToObject(resp, "idf", app_desc->idf_ver);
|
||||
|
||||
ble_json_service_.SendResponse(cmd, msg_id, 0, "ok", resp);
|
||||
cJSON_Delete(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- set_wifi: 设置 WiFi 凭证 ----
|
||||
if (cmd == "set_wifi") {
|
||||
cJSON* ssid_item = cJSON_GetObjectItem(data, "ssid");
|
||||
cJSON* pwd_item = cJSON_GetObjectItem(data, "pwd");
|
||||
if (!ssid_item || !cJSON_IsString(ssid_item) || strlen(ssid_item->valuestring) == 0) {
|
||||
ble_json_service_.SendResponse(cmd, msg_id, -1, "missing ssid");
|
||||
return;
|
||||
}
|
||||
|
||||
wifi_config_t wifi_config = {};
|
||||
strncpy(reinterpret_cast<char*>(wifi_config.sta.ssid),
|
||||
ssid_item->valuestring, sizeof(wifi_config.sta.ssid) - 1);
|
||||
if (pwd_item && cJSON_IsString(pwd_item)) {
|
||||
strncpy(reinterpret_cast<char*>(wifi_config.sta.password),
|
||||
pwd_item->valuestring, sizeof(wifi_config.sta.password) - 1);
|
||||
}
|
||||
|
||||
esp_err_t ret = esp_wifi_set_config(WIFI_IF_STA, &wifi_config);
|
||||
if (ret != ESP_OK) {
|
||||
ble_json_service_.SendResponse(cmd, msg_id, -2, "set config failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// 断开当前连接并重新连接
|
||||
esp_wifi_disconnect();
|
||||
ret = esp_wifi_connect();
|
||||
ble_json_service_.SendResponse(cmd, msg_id, 0,
|
||||
ret == ESP_OK ? "connecting" : "connect failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- wifi_list: 扫描 WiFi 列表 ----
|
||||
if (cmd == "wifi_list") {
|
||||
wifi_scan_config_t scan_config = {};
|
||||
scan_config.show_hidden = false;
|
||||
esp_err_t ret = esp_wifi_scan_start(&scan_config, true); // 阻塞扫描
|
||||
if (ret != ESP_OK) {
|
||||
ble_json_service_.SendResponse(cmd, msg_id, -1, "scan failed");
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t ap_count = 0;
|
||||
esp_wifi_scan_get_ap_num(&ap_count);
|
||||
if (ap_count > BLE_JSON_WIFI_LIST_MAX) {
|
||||
ap_count = BLE_JSON_WIFI_LIST_MAX;
|
||||
}
|
||||
|
||||
wifi_ap_record_t* ap_list = nullptr;
|
||||
cJSON* resp = cJSON_CreateObject();
|
||||
cJSON* arr = cJSON_AddArrayToObject(resp, "list");
|
||||
|
||||
if (ap_count > 0) {
|
||||
ap_list = static_cast<wifi_ap_record_t*>(malloc(sizeof(wifi_ap_record_t) * ap_count));
|
||||
if (ap_list && esp_wifi_scan_get_ap_records(&ap_count, ap_list) == ESP_OK) {
|
||||
for (int i = 0; i < ap_count; i++) {
|
||||
cJSON* item = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(item, "ssid",
|
||||
reinterpret_cast<const char*>(ap_list[i].ssid));
|
||||
cJSON_AddNumberToObject(item, "rssi", ap_list[i].rssi);
|
||||
cJSON_AddNumberToObject(item, "auth", ap_list[i].authmode);
|
||||
cJSON_AddItemToArray(arr, item);
|
||||
}
|
||||
}
|
||||
free(ap_list);
|
||||
}
|
||||
|
||||
ble_json_service_.SendResponse(cmd, msg_id, 0, "ok", resp);
|
||||
cJSON_Delete(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- set_vol: 设置音量 ----
|
||||
if (cmd == "set_vol") {
|
||||
cJSON* vol_item = cJSON_GetObjectItem(data, "vol");
|
||||
if (!vol_item || !cJSON_IsNumber(vol_item)) {
|
||||
ble_json_service_.SendResponse(cmd, msg_id, -1, "missing vol");
|
||||
return;
|
||||
}
|
||||
int vol = vol_item->valueint;
|
||||
if (vol < 0) vol = 0;
|
||||
if (vol > 100) vol = 100;
|
||||
|
||||
auto* codec = board.GetAudioCodec();
|
||||
if (codec) {
|
||||
codec->SetOutputVolume(vol);
|
||||
// 同时持久化到 NVS
|
||||
Settings s("audio", true);
|
||||
s.SetInt("output_volume", vol);
|
||||
}
|
||||
|
||||
ble_json_service_.SendResponse(cmd, msg_id, 0, "ok");
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- reboot: 重启设备 ----
|
||||
if (cmd == "reboot") {
|
||||
ble_json_service_.SendResponse(cmd, msg_id, 0, "rebooting");
|
||||
vTaskDelay(pdMS_TO_TICKS(500)); // 等待响应发出
|
||||
Reboot();
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- ota: 触发 OTA 升级 ----
|
||||
if (cmd == "ota") {
|
||||
if (device_state_ == kDeviceStateUpgrading) {
|
||||
ble_json_service_.SendResponse(cmd, msg_id, -1, "already upgrading");
|
||||
return;
|
||||
}
|
||||
ble_json_service_.SendResponse(cmd, msg_id, 0, "start ota");
|
||||
Schedule([this]() {
|
||||
CheckNewVersion();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- iot: 转发 IoT 命令 ----
|
||||
if (cmd == "iot") {
|
||||
auto& thing_manager = iot::ThingManager::GetInstance();
|
||||
std::string states;
|
||||
if (thing_manager.GetStatesJson(states, true)) {
|
||||
cJSON* resp = cJSON_Parse(states.c_str());
|
||||
ble_json_service_.SendResponse(cmd, msg_id, 0, "ok", resp);
|
||||
if (resp) cJSON_Delete(resp);
|
||||
} else {
|
||||
ble_json_service_.SendResponse(cmd, msg_id, 0, "ok");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 未知命令 ----
|
||||
ble_json_service_.SendResponse(cmd, msg_id, -99, "unknown cmd");
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
#include "ota.h"
|
||||
#include "background_task.h"
|
||||
#include "audio/simple_pipeline.h"
|
||||
#include "ble_service.h"
|
||||
|
||||
#if CONFIG_USE_WAKE_WORD_DETECT
|
||||
#include "wake_word_detect.h"
|
||||
@ -191,6 +192,12 @@ private:
|
||||
bool suppress_next_idle_sound_ = false;// 标志:是否抑制下一个空闲状态的声音播放
|
||||
void StartDialogWatchdog();// 启动对话看门狗
|
||||
void StopDialogWatchdog(); // 停止对话看门狗
|
||||
|
||||
// BLE JSON 通讯服务
|
||||
BleJsonService ble_json_service_;
|
||||
void InitBleJsonService(); // 初始化 BLE JSON 通讯
|
||||
void HandleBleJsonCommand(const std::string& cmd, int msg_id, cJSON* data); // 处理 BLE 命令
|
||||
const char* DeviceStateToString(DeviceState state); // 状态枚举转字符串
|
||||
};
|
||||
|
||||
#endif // _APPLICATION_H_
|
||||
|
||||
696
main/ble_service.cc
Normal file
696
main/ble_service.cc
Normal file
@ -0,0 +1,696 @@
|
||||
#include "ble_service.h"
|
||||
#include "ble_service_config.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <esp_log.h>
|
||||
#include <esp_bt.h>
|
||||
#include <esp_bt_main.h>
|
||||
#include <esp_gap_ble_api.h>
|
||||
#include <esp_gatts_api.h>
|
||||
#include <esp_gatt_common_api.h>
|
||||
#include <cJSON.h>
|
||||
|
||||
static const char* TAG = BLE_JSON_TAG;
|
||||
|
||||
BleJsonService* BleJsonService::instance_ = nullptr;
|
||||
|
||||
// ============================================================
|
||||
// 命令队列消息结构
|
||||
// ============================================================
|
||||
struct BleJsonCmdMsg {
|
||||
char* json_str; // 动态分配的 JSON 字符串,需要 free
|
||||
uint16_t len;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// UUID 定义
|
||||
// ============================================================
|
||||
static esp_bt_uuid_t service_uuid = {
|
||||
.len = ESP_UUID_LEN_16,
|
||||
.uuid = {.uuid16 = BLE_JSON_SERVICE_UUID},
|
||||
};
|
||||
|
||||
static esp_bt_uuid_t write_char_uuid = {
|
||||
.len = ESP_UUID_LEN_16,
|
||||
.uuid = {.uuid16 = BLE_JSON_CHAR_WRITE_UUID},
|
||||
};
|
||||
|
||||
static esp_bt_uuid_t notify_char_uuid = {
|
||||
.len = ESP_UUID_LEN_16,
|
||||
.uuid = {.uuid16 = BLE_JSON_CHAR_NOTIFY_UUID},
|
||||
};
|
||||
|
||||
static esp_bt_uuid_t status_char_uuid = {
|
||||
.len = ESP_UUID_LEN_16,
|
||||
.uuid = {.uuid16 = BLE_JSON_CHAR_STATUS_UUID},
|
||||
};
|
||||
|
||||
// CCCD 描述符 UUID (标准 0x2902)
|
||||
static esp_bt_uuid_t cccd_uuid = {
|
||||
.len = ESP_UUID_LEN_16,
|
||||
.uuid = {.uuid16 = ESP_GATT_UUID_CHAR_CLIENT_CONFIG},
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Characteristic 属性值缓冲区 (Auto Response 使用)
|
||||
// ============================================================
|
||||
static uint8_t write_char_val[BLE_JSON_CHAR_VAL_MAX_LEN] = {0};
|
||||
static uint8_t notify_char_val[BLE_JSON_CHAR_VAL_MAX_LEN] = {0};
|
||||
static uint8_t status_char_val[BLE_JSON_CHAR_VAL_MAX_LEN] = {0};
|
||||
static uint8_t cccd_val[2] = {0x00, 0x00};
|
||||
|
||||
static esp_attr_value_t write_char_attr = {
|
||||
.attr_max_len = BLE_JSON_CHAR_VAL_MAX_LEN,
|
||||
.attr_len = 0,
|
||||
.attr_value = write_char_val,
|
||||
};
|
||||
|
||||
static esp_attr_value_t notify_char_attr = {
|
||||
.attr_max_len = BLE_JSON_CHAR_VAL_MAX_LEN,
|
||||
.attr_len = 0,
|
||||
.attr_value = notify_char_val,
|
||||
};
|
||||
|
||||
static esp_attr_value_t status_char_attr = {
|
||||
.attr_max_len = BLE_JSON_CHAR_VAL_MAX_LEN,
|
||||
.attr_len = 0,
|
||||
.attr_value = status_char_val,
|
||||
};
|
||||
|
||||
static esp_attr_value_t cccd_attr = {
|
||||
.attr_max_len = 2,
|
||||
.attr_len = 2,
|
||||
.attr_value = cccd_val,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 广播数据
|
||||
// ============================================================
|
||||
static esp_ble_adv_params_t ble_json_adv_params = {
|
||||
.adv_int_min = BLE_JSON_ADV_INT_MIN,
|
||||
.adv_int_max = BLE_JSON_ADV_INT_MAX,
|
||||
.adv_type = ADV_TYPE_IND,
|
||||
.own_addr_type = BLE_ADDR_TYPE_PUBLIC,
|
||||
.peer_addr = {0},
|
||||
.peer_addr_type = BLE_ADDR_TYPE_PUBLIC,
|
||||
.channel_map = ADV_CHNL_ALL,
|
||||
.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 构造 / 析构
|
||||
// ============================================================
|
||||
|
||||
BleJsonService::BleJsonService() {
|
||||
instance_ = this;
|
||||
}
|
||||
|
||||
BleJsonService::~BleJsonService() {
|
||||
Stop();
|
||||
if (instance_ == this) {
|
||||
instance_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Initialize — 注册 GATTS App
|
||||
// ============================================================
|
||||
|
||||
bool BleJsonService::Initialize() {
|
||||
if (initialized_) {
|
||||
ESP_LOGW(TAG, "Already initialized");
|
||||
return true;
|
||||
}
|
||||
|
||||
esp_err_t ret;
|
||||
|
||||
// 检查 Bluedroid 栈是否已启动 (可能由 BluFi 配网模块启动过)
|
||||
// 如果未启动,则自行初始化整个 BLE 栈
|
||||
esp_bluedroid_status_t bt_status = esp_bluedroid_get_status();
|
||||
if (bt_status != ESP_BLUEDROID_STATUS_ENABLED) {
|
||||
ESP_LOGI(TAG, "Bluedroid not enabled, initializing BLE stack...");
|
||||
|
||||
// 释放经典蓝牙内存
|
||||
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT);
|
||||
|
||||
// 初始化 BT Controller
|
||||
esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
|
||||
ret = esp_bt_controller_init(&bt_cfg);
|
||||
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGE(TAG, "BT controller init failed: %s", esp_err_to_name(ret));
|
||||
return false;
|
||||
}
|
||||
|
||||
ret = esp_bt_controller_enable(ESP_BT_MODE_BLE);
|
||||
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGE(TAG, "BT controller enable failed: %s", esp_err_to_name(ret));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 初始化 Bluedroid
|
||||
ret = esp_bluedroid_init();
|
||||
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGE(TAG, "Bluedroid init failed: %s", esp_err_to_name(ret));
|
||||
return false;
|
||||
}
|
||||
|
||||
ret = esp_bluedroid_enable();
|
||||
if (ret != ESP_OK && ret != ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGE(TAG, "Bluedroid enable failed: %s", esp_err_to_name(ret));
|
||||
return false;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "BLE stack initialized successfully");
|
||||
}
|
||||
|
||||
// 设置 MTU
|
||||
ret = esp_ble_gatt_set_local_mtu(BLE_JSON_LOCAL_MTU);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Set MTU failed: %s (may already be set)", esp_err_to_name(ret));
|
||||
}
|
||||
|
||||
// 注册 GAP 回调 (广播事件需要此回调才能启动)
|
||||
// 注意: ESP-IDF 仅支持一个全局 GAP 回调,此处会覆盖 BluFi 的回调
|
||||
// 但 BluFi 配网流程在 StartNetwork() 中已完成,不影响后续使用
|
||||
ret = esp_ble_gap_register_callback(GapEventHandler);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "GAP callback register: %s", esp_err_to_name(ret));
|
||||
}
|
||||
|
||||
// 注册 GATTS 回调 (全局只能有一个,但 Bluedroid 支持按 gatts_if 分发)
|
||||
// BluFi 已经注册了自己的回调,我们通过 app_register 获得独立的 gatts_if
|
||||
ret = esp_ble_gatts_register_callback(GattsEventHandler);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "GATTS callback register: %s (may share with BluFi)", esp_err_to_name(ret));
|
||||
}
|
||||
|
||||
// 注册独立的 GATTS App,获得自己的 gatts_if
|
||||
ret = esp_ble_gatts_app_register(BLE_JSON_APP_ID);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "GATTS app register failed: %s", esp_err_to_name(ret));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 创建命令处理队列和任务
|
||||
cmd_queue_ = xQueueCreate(BLE_JSON_CMD_QUEUE_SIZE, sizeof(BleJsonCmdMsg));
|
||||
if (!cmd_queue_) {
|
||||
ESP_LOGE(TAG, "Failed to create cmd queue");
|
||||
return false;
|
||||
}
|
||||
|
||||
xTaskCreate(CmdProcessTask, "ble_json_cmd", 6144, this, 5, &cmd_task_handle_);
|
||||
|
||||
initialized_ = true;
|
||||
ESP_LOGI(TAG, "Initialized successfully (App ID=%d)", BLE_JSON_APP_ID);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Start — 启动广播
|
||||
// ============================================================
|
||||
|
||||
bool BleJsonService::Start(const char* device_name) {
|
||||
if (!initialized_) {
|
||||
ESP_LOGE(TAG, "Not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
device_name_ = device_name ? device_name : BLE_JSON_DEVICE_NAME;
|
||||
|
||||
// 设置设备名称
|
||||
esp_ble_gap_set_device_name(device_name_.c_str());
|
||||
|
||||
StartAdvertising();
|
||||
ESP_LOGI(TAG, "Started, device name: %s", device_name_.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Stop
|
||||
// ============================================================
|
||||
|
||||
void BleJsonService::Stop() {
|
||||
if (!initialized_) return;
|
||||
|
||||
esp_ble_gap_stop_advertising();
|
||||
|
||||
if (cmd_task_handle_) {
|
||||
vTaskDelete(cmd_task_handle_);
|
||||
cmd_task_handle_ = nullptr;
|
||||
}
|
||||
if (cmd_queue_) {
|
||||
// 清空队列中残留的消息
|
||||
BleJsonCmdMsg msg;
|
||||
while (xQueueReceive(cmd_queue_, &msg, 0) == pdTRUE) {
|
||||
free(msg.json_str);
|
||||
}
|
||||
vQueueDelete(cmd_queue_);
|
||||
cmd_queue_ = nullptr;
|
||||
}
|
||||
|
||||
connected_ = false;
|
||||
notify_enabled_ = false;
|
||||
initialized_ = false;
|
||||
ESP_LOGI(TAG, "Stopped");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SendResponse — 构建 JSON 响应并通过 NOTIFY 发送
|
||||
// ============================================================
|
||||
|
||||
bool BleJsonService::SendResponse(const std::string& cmd, int msg_id, int code,
|
||||
const char* msg, cJSON* data) {
|
||||
if (!connected_ || !notify_enabled_) {
|
||||
ESP_LOGW(TAG, "Cannot send: connected=%d notify=%d", connected_, notify_enabled_);
|
||||
return false;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(root, "cmd", cmd.c_str());
|
||||
cJSON_AddNumberToObject(root, "id", msg_id);
|
||||
cJSON_AddNumberToObject(root, "code", code);
|
||||
if (msg) {
|
||||
cJSON_AddStringToObject(root, "msg", msg);
|
||||
}
|
||||
if (data) {
|
||||
cJSON_AddItemReferenceToObject(root, "data", data);
|
||||
}
|
||||
|
||||
char* json_str = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
|
||||
if (!json_str) {
|
||||
ESP_LOGE(TAG, "JSON print failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t len = strlen(json_str);
|
||||
ESP_LOGI(TAG, "TX(%d): %s", len, json_str);
|
||||
|
||||
bool ok = SendNotify(json_str, len);
|
||||
free(json_str);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SendEvent — 构建主动推送事件
|
||||
// ============================================================
|
||||
|
||||
bool BleJsonService::SendEvent(const std::string& event_type, cJSON* data) {
|
||||
if (!connected_ || !notify_enabled_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(root, "cmd", "event");
|
||||
|
||||
cJSON* event_data = data ? cJSON_Duplicate(data, true) : cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(event_data, "type", event_type.c_str());
|
||||
cJSON_AddItemToObject(root, "data", event_data);
|
||||
|
||||
char* json_str = cJSON_PrintUnformatted(root);
|
||||
cJSON_Delete(root);
|
||||
|
||||
if (!json_str) return false;
|
||||
|
||||
uint16_t len = strlen(json_str);
|
||||
ESP_LOGI(TAG, "TX event(%d): %s", len, json_str);
|
||||
|
||||
bool ok = SendNotify(json_str, len);
|
||||
free(json_str);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SendNotify — 底层 NOTIFY 发送
|
||||
// ============================================================
|
||||
|
||||
bool BleJsonService::SendNotify(const char* json_str, uint16_t len) {
|
||||
if (gatts_if_ == ESP_GATT_IF_NONE || !connected_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否超过 MTU
|
||||
uint16_t max_payload = mtu_ - 3;
|
||||
if (len > max_payload) {
|
||||
ESP_LOGW(TAG, "Data len %d exceeds MTU payload %d, truncating", len, max_payload);
|
||||
len = max_payload;
|
||||
}
|
||||
|
||||
esp_err_t ret = esp_ble_gatts_send_indicate(
|
||||
gatts_if_, conn_id_, notify_char_handle_,
|
||||
len, (uint8_t*)json_str, false // false = notification, true = indication
|
||||
);
|
||||
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Send notify failed: %s", esp_err_to_name(ret));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// StartAdvertising
|
||||
// ============================================================
|
||||
|
||||
void BleJsonService::StartAdvertising() {
|
||||
// 构建广播数据
|
||||
esp_ble_adv_data_t adv_data = {};
|
||||
adv_data.set_scan_rsp = false;
|
||||
adv_data.include_name = true;
|
||||
adv_data.include_txpower = true;
|
||||
adv_data.min_interval = 0x0006;
|
||||
adv_data.max_interval = 0x0010;
|
||||
adv_data.appearance = 0x00;
|
||||
adv_data.manufacturer_len = 0;
|
||||
adv_data.p_manufacturer_data = nullptr;
|
||||
adv_data.service_data_len = 0;
|
||||
adv_data.p_service_data = nullptr;
|
||||
adv_data.service_uuid_len = sizeof(uint16_t);
|
||||
adv_data.p_service_uuid = (uint8_t*)&service_uuid.uuid.uuid16;
|
||||
adv_data.flag = (ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT);
|
||||
|
||||
esp_ble_gap_config_adv_data(&adv_data);
|
||||
|
||||
// Scan response 中也加设备名
|
||||
esp_ble_adv_data_t scan_rsp = {};
|
||||
scan_rsp.set_scan_rsp = true;
|
||||
scan_rsp.include_name = true;
|
||||
scan_rsp.include_txpower = false;
|
||||
esp_ble_gap_config_adv_data(&scan_rsp);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GAP 事件回调
|
||||
// ============================================================
|
||||
|
||||
void BleJsonService::GapEventHandler(esp_gap_ble_cb_event_t event,
|
||||
esp_ble_gap_cb_param_t* param) {
|
||||
switch (event) {
|
||||
case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT:
|
||||
// 广播数据设置完成,等扫描应答数据也完成后再启动广播
|
||||
break;
|
||||
case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT:
|
||||
// 扫描应答数据设置完成,启动广播
|
||||
esp_ble_gap_start_advertising(&ble_json_adv_params);
|
||||
break;
|
||||
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT:
|
||||
if (param->adv_start_cmpl.status != ESP_BT_STATUS_SUCCESS) {
|
||||
ESP_LOGE(TAG, "Advertising start failed: %d", param->adv_start_cmpl.status);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Advertising started");
|
||||
}
|
||||
break;
|
||||
case ESP_GAP_BLE_ADV_STOP_COMPLETE_EVT:
|
||||
ESP_LOGI(TAG, "Advertising stopped");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GATTS 事件回调 (static 分发)
|
||||
// ============================================================
|
||||
|
||||
void BleJsonService::GattsEventHandler(esp_gatts_cb_event_t event,
|
||||
esp_gatt_if_t gatts_if,
|
||||
esp_ble_gatts_cb_param_t* param) {
|
||||
// 只处理属于本 App 的事件 (通过 gatts_if 或 REG 事件匹配)
|
||||
if (event == ESP_GATTS_REG_EVT) {
|
||||
if (param->reg.app_id != BLE_JSON_APP_ID) {
|
||||
return; // 不是我们的 App 注册事件
|
||||
}
|
||||
} else {
|
||||
// 非 REG 事件: 检查 gatts_if 是否属于我们
|
||||
if (!instance_ || gatts_if != instance_->gatts_if_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (instance_) {
|
||||
instance_->HandleGattsEvent(event, gatts_if, param);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HandleGattsEvent — 实例内处理
|
||||
// ============================================================
|
||||
|
||||
void BleJsonService::HandleGattsEvent(esp_gatts_cb_event_t event,
|
||||
esp_gatt_if_t gatts_if,
|
||||
esp_ble_gatts_cb_param_t* param) {
|
||||
switch (event) {
|
||||
|
||||
// ---- App 注册完成,保存 gatts_if,创建 Service ----
|
||||
case ESP_GATTS_REG_EVT:
|
||||
if (param->reg.status == ESP_GATT_OK) {
|
||||
gatts_if_ = gatts_if;
|
||||
ESP_LOGI(TAG, "GATTS app registered, gatts_if=%d", gatts_if);
|
||||
CreateService(gatts_if);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "GATTS app register failed, status=%d", param->reg.status);
|
||||
}
|
||||
break;
|
||||
|
||||
// ---- Service 创建完成,开始添加 Characteristic ----
|
||||
case ESP_GATTS_CREATE_EVT:
|
||||
if (param->create.status == ESP_GATT_OK) {
|
||||
service_handle_ = param->create.service_handle;
|
||||
ESP_LOGI(TAG, "Service created, handle=%d", service_handle_);
|
||||
|
||||
chars_added_ = 0;
|
||||
|
||||
// 1. 添加 WRITE Characteristic (App -> 设备)
|
||||
esp_gatt_char_prop_t write_prop = ESP_GATT_CHAR_PROP_BIT_WRITE;
|
||||
esp_ble_gatts_add_char(service_handle_, &write_char_uuid,
|
||||
ESP_GATT_PERM_WRITE,
|
||||
write_prop, &write_char_attr, nullptr);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Service create failed: %d", param->create.status);
|
||||
}
|
||||
break;
|
||||
|
||||
// ---- Characteristic 添加完成 ----
|
||||
case ESP_GATTS_ADD_CHAR_EVT:
|
||||
if (param->add_char.status != ESP_GATT_OK) {
|
||||
ESP_LOGE(TAG, "Add char failed: uuid=0x%04x status=%d",
|
||||
param->add_char.char_uuid.uuid.uuid16, param->add_char.status);
|
||||
break;
|
||||
}
|
||||
|
||||
if (param->add_char.char_uuid.uuid.uuid16 == BLE_JSON_CHAR_WRITE_UUID) {
|
||||
write_char_handle_ = param->add_char.attr_handle;
|
||||
ESP_LOGI(TAG, "WRITE char added, handle=%d", write_char_handle_);
|
||||
|
||||
// 2. 添加 NOTIFY Characteristic (设备 -> App)
|
||||
esp_gatt_char_prop_t notify_prop = ESP_GATT_CHAR_PROP_BIT_NOTIFY | ESP_GATT_CHAR_PROP_BIT_READ;
|
||||
esp_ble_gatts_add_char(service_handle_, ¬ify_char_uuid,
|
||||
ESP_GATT_PERM_READ,
|
||||
notify_prop, ¬ify_char_attr, nullptr);
|
||||
|
||||
} else if (param->add_char.char_uuid.uuid.uuid16 == BLE_JSON_CHAR_NOTIFY_UUID) {
|
||||
notify_char_handle_ = param->add_char.attr_handle;
|
||||
ESP_LOGI(TAG, "NOTIFY char added, handle=%d", notify_char_handle_);
|
||||
|
||||
// 为 NOTIFY char 添加 CCCD 描述符
|
||||
esp_ble_gatts_add_char_descr(service_handle_, &cccd_uuid,
|
||||
ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
|
||||
&cccd_attr, nullptr);
|
||||
|
||||
} else if (param->add_char.char_uuid.uuid.uuid16 == BLE_JSON_CHAR_STATUS_UUID) {
|
||||
status_char_handle_ = param->add_char.attr_handle;
|
||||
ESP_LOGI(TAG, "STATUS char added, handle=%d", status_char_handle_);
|
||||
|
||||
// 所有 Characteristic 添加完毕,启动 Service
|
||||
esp_ble_gatts_start_service(service_handle_);
|
||||
}
|
||||
break;
|
||||
|
||||
// ---- CCCD 描述符添加完成 ----
|
||||
case ESP_GATTS_ADD_CHAR_DESCR_EVT:
|
||||
if (param->add_char_descr.status == ESP_GATT_OK) {
|
||||
notify_cccd_handle_ = param->add_char_descr.attr_handle;
|
||||
ESP_LOGI(TAG, "CCCD added, handle=%d", notify_cccd_handle_);
|
||||
|
||||
// 3. 添加 STATUS Characteristic (READ only)
|
||||
esp_gatt_char_prop_t status_prop = ESP_GATT_CHAR_PROP_BIT_READ;
|
||||
esp_ble_gatts_add_char(service_handle_, &status_char_uuid,
|
||||
ESP_GATT_PERM_READ,
|
||||
status_prop, &status_char_attr, nullptr);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Add CCCD failed: %d", param->add_char_descr.status);
|
||||
}
|
||||
break;
|
||||
|
||||
// ---- Service 启动完成 ----
|
||||
case ESP_GATTS_START_EVT:
|
||||
if (param->start.status == ESP_GATT_OK) {
|
||||
ESP_LOGI(TAG, "Service started");
|
||||
}
|
||||
break;
|
||||
|
||||
// ---- 客户端连接 ----
|
||||
case ESP_GATTS_CONNECT_EVT:
|
||||
conn_id_ = param->connect.conn_id;
|
||||
connected_ = true;
|
||||
notify_enabled_ = false;
|
||||
mtu_ = 23; // 默认值,等待 MTU exchange
|
||||
|
||||
ESP_LOGI(TAG, "Client connected, conn_id=%d, addr=%02x:%02x:%02x:%02x:%02x:%02x",
|
||||
conn_id_,
|
||||
param->connect.remote_bda[0], param->connect.remote_bda[1],
|
||||
param->connect.remote_bda[2], param->connect.remote_bda[3],
|
||||
param->connect.remote_bda[4], param->connect.remote_bda[5]);
|
||||
|
||||
// 请求更新连接参数
|
||||
esp_ble_conn_update_params_t conn_params = {};
|
||||
memcpy(conn_params.bda, param->connect.remote_bda, sizeof(esp_bd_addr_t));
|
||||
conn_params.latency = 0;
|
||||
conn_params.max_int = 0x20; // 40ms
|
||||
conn_params.min_int = 0x10; // 20ms
|
||||
conn_params.timeout = 400; // 4s
|
||||
esp_ble_gap_update_conn_params(&conn_params);
|
||||
|
||||
// 连接后停止广播 (BLE 4.2 单连接时自动停止,但显式调用更安全)
|
||||
esp_ble_gap_stop_advertising();
|
||||
break;
|
||||
|
||||
// ---- 客户端断开 ----
|
||||
case ESP_GATTS_DISCONNECT_EVT:
|
||||
ESP_LOGI(TAG, "Client disconnected, reason=0x%x", param->disconnect.reason);
|
||||
connected_ = false;
|
||||
notify_enabled_ = false;
|
||||
mtu_ = 23;
|
||||
|
||||
// 重新启动广播
|
||||
StartAdvertising();
|
||||
break;
|
||||
|
||||
// ---- MTU 协商完成 ----
|
||||
case ESP_GATTS_MTU_EVT:
|
||||
mtu_ = param->mtu.mtu;
|
||||
ESP_LOGI(TAG, "MTU updated: %d", mtu_);
|
||||
break;
|
||||
|
||||
// ---- WRITE 事件 ----
|
||||
case ESP_GATTS_WRITE_EVT:
|
||||
if (param->write.handle == write_char_handle_) {
|
||||
// JSON 命令数据
|
||||
ProcessWriteData(param->write.value, param->write.len);
|
||||
} else if (param->write.handle == notify_cccd_handle_ && param->write.len == 2) {
|
||||
// CCCD 写入: 开启/关闭 NOTIFY
|
||||
uint16_t cccd_value = param->write.value[0] | (param->write.value[1] << 8);
|
||||
notify_enabled_ = (cccd_value == 0x0001);
|
||||
ESP_LOGI(TAG, "NOTIFY %s", notify_enabled_ ? "enabled" : "disabled");
|
||||
}
|
||||
|
||||
// 如果需要响应
|
||||
if (param->write.need_rsp) {
|
||||
esp_ble_gatts_send_response(gatts_if_, param->write.conn_id,
|
||||
param->write.trans_id, ESP_GATT_OK, nullptr);
|
||||
}
|
||||
break;
|
||||
|
||||
// ---- READ 事件 (STATUS char) ----
|
||||
case ESP_GATTS_READ_EVT:
|
||||
// Auto response 模式下无需手动处理
|
||||
// 如果需要动态数据,可在此更新 status_char_val
|
||||
ESP_LOGD(TAG, "Read event, handle=%d", param->read.handle);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CreateService
|
||||
// ============================================================
|
||||
|
||||
void BleJsonService::CreateService(esp_gatt_if_t gatts_if) {
|
||||
esp_gatt_srvc_id_t service_id = {};
|
||||
service_id.is_primary = true;
|
||||
service_id.id.inst_id = 0;
|
||||
service_id.id.uuid.len = ESP_UUID_LEN_16;
|
||||
service_id.id.uuid.uuid.uuid16 = BLE_JSON_SERVICE_UUID;
|
||||
|
||||
esp_ble_gatts_create_service(gatts_if, &service_id, BLE_JSON_HANDLE_NUM);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ProcessWriteData — 收到 WRITE 数据,放入队列
|
||||
// ============================================================
|
||||
|
||||
void BleJsonService::ProcessWriteData(const uint8_t* data, uint16_t len) {
|
||||
if (!data || len == 0 || !cmd_queue_) return;
|
||||
|
||||
// 拷贝数据到堆上 (确保 null 结尾)
|
||||
char* json_str = (char*)malloc(len + 1);
|
||||
if (!json_str) {
|
||||
ESP_LOGE(TAG, "Malloc failed for cmd data");
|
||||
return;
|
||||
}
|
||||
memcpy(json_str, data, len);
|
||||
json_str[len] = '\0';
|
||||
|
||||
ESP_LOGI(TAG, "RX(%d): %s", len, json_str);
|
||||
|
||||
BleJsonCmdMsg msg = {json_str, len};
|
||||
if (xQueueSend(cmd_queue_, &msg, pdMS_TO_TICKS(100)) != pdTRUE) {
|
||||
ESP_LOGW(TAG, "Cmd queue full, dropping");
|
||||
free(json_str);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CmdProcessTask — 从队列取出 JSON 并分发到回调
|
||||
// ============================================================
|
||||
|
||||
void BleJsonService::CmdProcessTask(void* param) {
|
||||
BleJsonService* self = static_cast<BleJsonService*>(param);
|
||||
BleJsonCmdMsg msg;
|
||||
|
||||
while (true) {
|
||||
if (xQueueReceive(self->cmd_queue_, &msg, portMAX_DELAY) == pdTRUE) {
|
||||
// 解析 JSON
|
||||
cJSON* root = cJSON_Parse(msg.json_str);
|
||||
if (!root) {
|
||||
ESP_LOGW(TAG, "JSON parse failed: %s", msg.json_str);
|
||||
// 发送错误响应
|
||||
if (self->connected_ && self->notify_enabled_) {
|
||||
self->SendResponse("error", 0, 1, "invalid json");
|
||||
}
|
||||
free(msg.json_str);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 提取 cmd 和 id
|
||||
cJSON* cmd_item = cJSON_GetObjectItem(root, "cmd");
|
||||
cJSON* id_item = cJSON_GetObjectItem(root, "id");
|
||||
cJSON* data_item = cJSON_GetObjectItem(root, "data");
|
||||
|
||||
if (!cmd_item || !cJSON_IsString(cmd_item)) {
|
||||
ESP_LOGW(TAG, "Missing 'cmd' field");
|
||||
self->SendResponse("error", 0, 1, "missing cmd");
|
||||
cJSON_Delete(root);
|
||||
free(msg.json_str);
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string cmd = cmd_item->valuestring;
|
||||
int msg_id = (id_item && cJSON_IsNumber(id_item)) ? id_item->valueint : 0;
|
||||
|
||||
// 分发到用户回调
|
||||
if (self->command_callback_) {
|
||||
self->command_callback_(cmd, msg_id, data_item);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "No command callback, cmd=%s ignored", cmd.c_str());
|
||||
self->SendResponse(cmd, msg_id, 2, "no handler");
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
free(msg.json_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
115
main/ble_service.h
Normal file
115
main/ble_service.h
Normal file
@ -0,0 +1,115 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <esp_gatts_api.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/queue.h>
|
||||
#endif
|
||||
|
||||
struct cJSON;
|
||||
|
||||
class BleJsonService {
|
||||
public:
|
||||
// 收到 JSON 命令时的回调: cmd, msg_id, data(cJSON*)
|
||||
using CommandCallback = std::function<void(const std::string& cmd, int msg_id, cJSON* data)>;
|
||||
|
||||
BleJsonService();
|
||||
~BleJsonService();
|
||||
|
||||
// 初始化 GATT 服务 (需在 Bluedroid 栈已启动后调用)
|
||||
bool Initialize();
|
||||
|
||||
// 启动广播和服务
|
||||
bool Start(const char* device_name = nullptr);
|
||||
|
||||
// 停止服务和广播
|
||||
void Stop();
|
||||
|
||||
// 发送 JSON 响应 (通过 NOTIFY)
|
||||
bool SendResponse(const std::string& cmd, int msg_id, int code,
|
||||
const char* msg = nullptr, cJSON* data = nullptr);
|
||||
|
||||
// 发送主动事件推送 (通过 NOTIFY)
|
||||
bool SendEvent(const std::string& event_type, cJSON* data = nullptr);
|
||||
|
||||
// 注册命令处理回调
|
||||
void SetCommandCallback(CommandCallback callback) { command_callback_ = callback; }
|
||||
|
||||
// 状态查询
|
||||
bool IsConnected() const { return connected_; }
|
||||
bool IsNotifyEnabled() const { return notify_enabled_; }
|
||||
uint16_t GetMtu() const { return mtu_; }
|
||||
|
||||
private:
|
||||
// 禁用拷贝
|
||||
BleJsonService(const BleJsonService&) = delete;
|
||||
BleJsonService& operator=(const BleJsonService&) = delete;
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
// GATTS 事件回调 (static -> instance)
|
||||
static void GattsEventHandler(esp_gatts_cb_event_t event,
|
||||
esp_gatt_if_t gatts_if,
|
||||
esp_ble_gatts_cb_param_t* param);
|
||||
|
||||
// GAP 事件回调
|
||||
static void GapEventHandler(esp_gap_ble_cb_event_t event,
|
||||
esp_ble_gap_cb_param_t* param);
|
||||
|
||||
// 内部事件处理
|
||||
void HandleGattsEvent(esp_gatts_cb_event_t event,
|
||||
esp_gatt_if_t gatts_if,
|
||||
esp_ble_gatts_cb_param_t* param);
|
||||
|
||||
// 创建 Service 和 Characteristic
|
||||
void CreateService(esp_gatt_if_t gatts_if);
|
||||
|
||||
// 配置并启动广播
|
||||
void StartAdvertising();
|
||||
|
||||
// 处理 WRITE 收到的数据
|
||||
void ProcessWriteData(const uint8_t* data, uint16_t len);
|
||||
|
||||
// 通过 NOTIFY 发送数据
|
||||
bool SendNotify(const char* json_str, uint16_t len);
|
||||
|
||||
// 命令处理任务 (从队列中取出 JSON 命令并分发)
|
||||
static void CmdProcessTask(void* param);
|
||||
|
||||
// GATT handles
|
||||
uint16_t service_handle_ = 0;
|
||||
uint16_t write_char_handle_ = 0;
|
||||
uint16_t notify_char_handle_ = 0;
|
||||
uint16_t notify_cccd_handle_ = 0;
|
||||
uint16_t status_char_handle_ = 0;
|
||||
|
||||
// 接口信息
|
||||
esp_gatt_if_t gatts_if_ = ESP_GATT_IF_NONE;
|
||||
uint16_t conn_id_ = 0;
|
||||
|
||||
// 命令处理队列
|
||||
QueueHandle_t cmd_queue_ = nullptr;
|
||||
TaskHandle_t cmd_task_handle_ = nullptr;
|
||||
#endif
|
||||
|
||||
// 状态
|
||||
bool initialized_ = false;
|
||||
bool connected_ = false;
|
||||
bool notify_enabled_ = false;
|
||||
uint16_t mtu_ = 23;
|
||||
|
||||
// 服务创建阶段计数 (跟踪 add_char 完成数)
|
||||
int chars_added_ = 0;
|
||||
|
||||
// 回调
|
||||
CommandCallback command_callback_;
|
||||
|
||||
// 设备名
|
||||
std::string device_name_;
|
||||
|
||||
// 单例指针供 static 回调使用
|
||||
static BleJsonService* instance_;
|
||||
};
|
||||
49
main/ble_service_config.h
Normal file
49
main/ble_service_config.h
Normal file
@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// ============================================================
|
||||
// BLE JSON 通讯服务 - 配置参数
|
||||
// ============================================================
|
||||
|
||||
// GATT App ID (BluFi 使用 0,本模块使用 1)
|
||||
#define BLE_JSON_APP_ID 1
|
||||
|
||||
// Service UUID (16-bit)
|
||||
#define BLE_JSON_SERVICE_UUID 0xAB00
|
||||
|
||||
// Characteristic UUIDs
|
||||
#define BLE_JSON_CHAR_WRITE_UUID 0xAB01 // App -> 设备 (WRITE)
|
||||
#define BLE_JSON_CHAR_NOTIFY_UUID 0xAB02 // 设备 -> App (NOTIFY)
|
||||
#define BLE_JSON_CHAR_STATUS_UUID 0xAB03 // 设备状态 (READ)
|
||||
|
||||
// Service handle 数量 (service + chars + descriptors,预留足够)
|
||||
#define BLE_JSON_HANDLE_NUM 10
|
||||
|
||||
// MTU
|
||||
#define BLE_JSON_LOCAL_MTU 512
|
||||
|
||||
// Characteristic 最大值长度
|
||||
#define BLE_JSON_CHAR_VAL_MAX_LEN 512
|
||||
|
||||
// 广播设备名称
|
||||
#define BLE_JSON_DEVICE_NAME "Kapi_BLE"
|
||||
|
||||
// 广播参数
|
||||
#define BLE_JSON_ADV_INT_MIN 0x40 // 40ms
|
||||
#define BLE_JSON_ADV_INT_MAX 0x80 // 80ms
|
||||
|
||||
// 命令处理队列深度
|
||||
#define BLE_JSON_CMD_QUEUE_SIZE 8
|
||||
|
||||
// WiFi 列表最大返回条数
|
||||
#define BLE_JSON_WIFI_LIST_MAX 8
|
||||
|
||||
// 日志标签
|
||||
#define BLE_JSON_TAG "BleJsonSvc"
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
490
tests/ble_json_test.py
Normal file
490
tests/ble_json_test.py
Normal file
@ -0,0 +1,490 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
BLE JSON 通讯模块测试脚本
|
||||
|
||||
用途: 模拟 APP 端,通过 BLE 与 ESP32 设备通信,验证所有 JSON 命令的收发。
|
||||
依赖: pip install bleak
|
||||
运行: python tests/ble_json_test.py [--device DEVICE_NAME] [--timeout SECONDS]
|
||||
|
||||
测试覆盖:
|
||||
1. 设备扫描与连接
|
||||
2. GATT Service/Characteristic 发现
|
||||
3. NOTIFY 启用
|
||||
4. 所有 JSON 命令的请求-响应验证
|
||||
5. 错误处理 (非法 JSON、缺少参数、未知命令)
|
||||
6. msg_id 关联性验证
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from bleak import BleakClient, BleakScanner
|
||||
from bleak.backends.characteristic import BleakGATTCharacteristic
|
||||
except ImportError:
|
||||
print("错误: 缺少 bleak 库,请执行: pip install bleak")
|
||||
sys.exit(1)
|
||||
|
||||
# ============================================================
|
||||
# BLE 参数定义 (与 ble_service_config.h 一致)
|
||||
# ============================================================
|
||||
|
||||
SERVICE_UUID = "0000ab00-0000-1000-8000-00805f9b34fb"
|
||||
CHAR_WRITE_UUID = "0000ab01-0000-1000-8000-00805f9b34fb"
|
||||
CHAR_NOTIFY_UUID = "0000ab02-0000-1000-8000-00805f9b34fb"
|
||||
CHAR_STATUS_UUID = "0000ab03-0000-1000-8000-00805f9b34fb"
|
||||
DEFAULT_DEVICE_NAME = "Kapi_BLE"
|
||||
|
||||
# ============================================================
|
||||
# 测试框架
|
||||
# ============================================================
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
name: str
|
||||
passed: bool
|
||||
detail: str = ""
|
||||
duration_ms: float = 0.0
|
||||
|
||||
|
||||
class BleJsonTester:
|
||||
"""BLE JSON 通讯测试器"""
|
||||
|
||||
def __init__(self, device_name: str, timeout: float = 5.0):
|
||||
self.device_name = device_name
|
||||
self.timeout = timeout
|
||||
self.client: Optional[BleakClient] = None
|
||||
self.responses: list = []
|
||||
self._response_event = asyncio.Event()
|
||||
self._msg_id_counter = 0
|
||||
self.results: list[TestResult] = []
|
||||
|
||||
def _next_msg_id(self) -> int:
|
||||
self._msg_id_counter += 1
|
||||
return self._msg_id_counter
|
||||
|
||||
def _on_notify(self, sender: BleakGATTCharacteristic, data: bytearray):
|
||||
"""NOTIFY 回调: 接收设备返回的 JSON 数据"""
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
parsed = json.loads(text)
|
||||
self.responses.append(parsed)
|
||||
self._response_event.set()
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||
self.responses.append({"_raw": data.hex(), "_error": str(e)})
|
||||
self._response_event.set()
|
||||
|
||||
async def _send_cmd(self, cmd: str, data: Optional[dict] = None,
|
||||
msg_id: Optional[int] = None, raw: Optional[str] = None) -> Optional[dict]:
|
||||
"""
|
||||
发送 JSON 命令并等待响应。
|
||||
|
||||
Args:
|
||||
cmd: 命令名
|
||||
data: 命令数据 (可选)
|
||||
msg_id: 消息 ID (可选,自动生成)
|
||||
raw: 直接发送原始字符串 (跳过 JSON 构建)
|
||||
|
||||
Returns:
|
||||
解析后的 JSON 响应,超时返回 None
|
||||
"""
|
||||
if msg_id is None:
|
||||
msg_id = self._next_msg_id()
|
||||
|
||||
# 清空之前的响应
|
||||
self.responses.clear()
|
||||
self._response_event.clear()
|
||||
|
||||
# 构建并发送
|
||||
if raw is not None:
|
||||
payload = raw.encode("utf-8")
|
||||
else:
|
||||
request = {"cmd": cmd, "id": msg_id}
|
||||
if data is not None:
|
||||
request["data"] = data
|
||||
payload = json.dumps(request, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
await self.client.write_gatt_char(CHAR_WRITE_UUID, payload, response=True)
|
||||
|
||||
# 等待响应
|
||||
try:
|
||||
await asyncio.wait_for(self._response_event.wait(), timeout=self.timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
|
||||
return self.responses[-1] if self.responses else None
|
||||
|
||||
def _record(self, name: str, passed: bool, detail: str = "", duration_ms: float = 0.0):
|
||||
self.results.append(TestResult(name, passed, detail, duration_ms))
|
||||
status = "PASS" if passed else "FAIL"
|
||||
symbol = "+" if passed else "x"
|
||||
print(f" [{symbol}] {name}: {status} {detail}")
|
||||
|
||||
# ============================================================
|
||||
# 测试用例
|
||||
# ============================================================
|
||||
|
||||
async def test_scan_device(self):
|
||||
"""测试 1: 扫描 BLE 设备"""
|
||||
t0 = time.monotonic()
|
||||
print(f"\n正在扫描设备 '{self.device_name}'...")
|
||||
device = await BleakScanner.find_device_by_name(
|
||||
self.device_name, timeout=10.0
|
||||
)
|
||||
dt = (time.monotonic() - t0) * 1000
|
||||
|
||||
if device:
|
||||
self._record("test_scan_device", True,
|
||||
f"找到设备 addr={device.address}", dt)
|
||||
return device
|
||||
else:
|
||||
self._record("test_scan_device", False,
|
||||
"未找到设备,请确认设备已开机且蓝牙广播中", dt)
|
||||
return None
|
||||
|
||||
async def test_discover_service(self):
|
||||
"""测试 2: 发现 GATT Service 和 Characteristic"""
|
||||
t0 = time.monotonic()
|
||||
services = self.client.services
|
||||
|
||||
svc = services.get_service(SERVICE_UUID)
|
||||
if not svc:
|
||||
self._record("test_discover_service", False,
|
||||
f"未发现 Service {SERVICE_UUID}")
|
||||
return False
|
||||
|
||||
chars_found = []
|
||||
for uuid in [CHAR_WRITE_UUID, CHAR_NOTIFY_UUID, CHAR_STATUS_UUID]:
|
||||
char = svc.get_characteristic(uuid)
|
||||
if char:
|
||||
chars_found.append(uuid.split("-")[0][-4:].upper())
|
||||
else:
|
||||
self._record("test_discover_service", False,
|
||||
f"缺少 Characteristic {uuid}")
|
||||
return False
|
||||
|
||||
dt = (time.monotonic() - t0) * 1000
|
||||
self._record("test_discover_service", True,
|
||||
f"Service 0xAB00, Chars: {chars_found}", dt)
|
||||
return True
|
||||
|
||||
async def test_enable_notify(self):
|
||||
"""测试 3: 启用 NOTIFY"""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
await self.client.start_notify(CHAR_NOTIFY_UUID, self._on_notify)
|
||||
dt = (time.monotonic() - t0) * 1000
|
||||
self._record("test_enable_notify", True,
|
||||
"NOTIFY 已启用 (CCCD 写入成功)", dt)
|
||||
return True
|
||||
except Exception as e:
|
||||
dt = (time.monotonic() - t0) * 1000
|
||||
self._record("test_enable_notify", False, str(e), dt)
|
||||
return False
|
||||
|
||||
async def test_ping(self):
|
||||
"""测试 4: ping/pong 连通性测试"""
|
||||
t0 = time.monotonic()
|
||||
msg_id = self._next_msg_id()
|
||||
resp = await self._send_cmd("ping", msg_id=msg_id)
|
||||
dt = (time.monotonic() - t0) * 1000
|
||||
|
||||
if resp is None:
|
||||
self._record("test_ping", False, "超时未收到响应", dt)
|
||||
return
|
||||
|
||||
ok = (resp.get("cmd") == "ping"
|
||||
and resp.get("code") == 0
|
||||
and resp.get("msg") == "pong"
|
||||
and resp.get("id") == msg_id)
|
||||
|
||||
self._record("test_ping", ok,
|
||||
f"响应: {json.dumps(resp, ensure_ascii=False)}", dt)
|
||||
|
||||
async def test_status(self):
|
||||
"""测试 5: 查询设备状态"""
|
||||
t0 = time.monotonic()
|
||||
resp = await self._send_cmd("status")
|
||||
dt = (time.monotonic() - t0) * 1000
|
||||
|
||||
if resp is None:
|
||||
self._record("test_status", False, "超时未收到响应", dt)
|
||||
return
|
||||
|
||||
data = resp.get("data", {})
|
||||
has_state = "s" in data
|
||||
code_ok = resp.get("code") == 0
|
||||
valid_states = ["unknown", "starting", "configuring", "idle",
|
||||
"connecting", "listening", "speaking", "dialog",
|
||||
"upgrading", "activating", "fatal_error"]
|
||||
state_ok = data.get("s") in valid_states
|
||||
|
||||
ok = code_ok and has_state and state_ok
|
||||
detail = f"state={data.get('s')}, bat={data.get('bat')}, vol={data.get('vol')}"
|
||||
self._record("test_status", ok, detail, dt)
|
||||
|
||||
async def test_dev_info(self):
|
||||
"""测试 6: 查询设备信息"""
|
||||
t0 = time.monotonic()
|
||||
resp = await self._send_cmd("dev_info")
|
||||
dt = (time.monotonic() - t0) * 1000
|
||||
|
||||
if resp is None:
|
||||
self._record("test_dev_info", False, "超时未收到响应", dt)
|
||||
return
|
||||
|
||||
data = resp.get("data", {})
|
||||
required_fields = ["mac", "board", "fw", "chip"]
|
||||
missing = [f for f in required_fields if f not in data]
|
||||
|
||||
ok = resp.get("code") == 0 and len(missing) == 0
|
||||
if ok:
|
||||
detail = f"mac={data['mac']}, board={data['board']}, fw={data['fw']}, chip={data['chip']}"
|
||||
else:
|
||||
detail = f"缺少字段: {missing}, 响应: {json.dumps(resp, ensure_ascii=False)}"
|
||||
self._record("test_dev_info", ok, detail, dt)
|
||||
|
||||
async def test_wifi_list(self):
|
||||
"""测试 7: WiFi 扫描列表"""
|
||||
t0 = time.monotonic()
|
||||
# WiFi 扫描是阻塞的,给更长超时
|
||||
old_timeout = self.timeout
|
||||
self.timeout = 15.0
|
||||
resp = await self._send_cmd("wifi_list")
|
||||
self.timeout = old_timeout
|
||||
dt = (time.monotonic() - t0) * 1000
|
||||
|
||||
if resp is None:
|
||||
self._record("test_wifi_list", False, "超时未收到响应 (WiFi扫描可能耗时较长)", dt)
|
||||
return
|
||||
|
||||
data = resp.get("data", {})
|
||||
wifi_list = data.get("list", [])
|
||||
code_ok = resp.get("code") == 0
|
||||
is_list = isinstance(wifi_list, list)
|
||||
|
||||
ok = code_ok and is_list
|
||||
if ok and len(wifi_list) > 0:
|
||||
first = wifi_list[0]
|
||||
has_fields = "ssid" in first and "rssi" in first
|
||||
ok = ok and has_fields
|
||||
detail = f"扫描到 {len(wifi_list)} 个网络"
|
||||
if len(wifi_list) > 0:
|
||||
detail += f", 第1个: ssid={first.get('ssid')}, rssi={first.get('rssi')}"
|
||||
else:
|
||||
detail = f"扫描到 {len(wifi_list)} 个网络"
|
||||
|
||||
self._record("test_wifi_list", ok, detail, dt)
|
||||
|
||||
async def test_set_vol(self):
|
||||
"""测试 8: 设置音量并验证"""
|
||||
# 先获取当前音量
|
||||
resp0 = await self._send_cmd("status")
|
||||
original_vol = resp0.get("data", {}).get("vol", 50) if resp0 else 50
|
||||
|
||||
# 设置新音量
|
||||
target_vol = 35 if original_vol != 35 else 65
|
||||
t0 = time.monotonic()
|
||||
resp = await self._send_cmd("set_vol", {"vol": target_vol})
|
||||
dt = (time.monotonic() - t0) * 1000
|
||||
|
||||
if resp is None:
|
||||
self._record("test_set_vol", False, "超时未收到响应", dt)
|
||||
return
|
||||
|
||||
set_ok = resp.get("code") == 0
|
||||
|
||||
# 验证: 再查一次 status 确认音量变化
|
||||
await asyncio.sleep(0.3)
|
||||
resp2 = await self._send_cmd("status")
|
||||
new_vol = resp2.get("data", {}).get("vol") if resp2 else None
|
||||
verify_ok = new_vol == target_vol
|
||||
|
||||
ok = set_ok and verify_ok
|
||||
detail = f"设置 vol={target_vol}, set_ok={set_ok}, 验证 vol={new_vol}"
|
||||
self._record("test_set_vol", ok, detail, dt)
|
||||
|
||||
# 恢复原音量
|
||||
await self._send_cmd("set_vol", {"vol": original_vol})
|
||||
|
||||
async def test_set_wifi_missing_ssid(self):
|
||||
"""测试 9: set_wifi 缺少 ssid 参数"""
|
||||
t0 = time.monotonic()
|
||||
resp = await self._send_cmd("set_wifi", {"pwd": "12345678"})
|
||||
dt = (time.monotonic() - t0) * 1000
|
||||
|
||||
if resp is None:
|
||||
self._record("test_set_wifi_missing_ssid", False, "超时未收到响应", dt)
|
||||
return
|
||||
|
||||
ok = resp.get("code") == -1 and "ssid" in resp.get("msg", "").lower()
|
||||
self._record("test_set_wifi_missing_ssid", ok,
|
||||
f"code={resp.get('code')}, msg={resp.get('msg')}", dt)
|
||||
|
||||
async def test_unknown_cmd(self):
|
||||
"""测试 10: 未知命令"""
|
||||
t0 = time.monotonic()
|
||||
resp = await self._send_cmd("this_cmd_does_not_exist")
|
||||
dt = (time.monotonic() - t0) * 1000
|
||||
|
||||
if resp is None:
|
||||
self._record("test_unknown_cmd", False, "超时未收到响应", dt)
|
||||
return
|
||||
|
||||
ok = resp.get("code") == -99
|
||||
self._record("test_unknown_cmd", ok,
|
||||
f"code={resp.get('code')}, msg={resp.get('msg')}", dt)
|
||||
|
||||
async def test_invalid_json(self):
|
||||
"""测试 11: 发送非法 JSON 数据"""
|
||||
t0 = time.monotonic()
|
||||
resp = await self._send_cmd("", raw="{this is not valid json!}")
|
||||
dt = (time.monotonic() - t0) * 1000
|
||||
|
||||
if resp is None:
|
||||
self._record("test_invalid_json", False, "超时未收到响应 (可能设备未发送错误通知)", dt)
|
||||
return
|
||||
|
||||
# 设备应返回 error 响应
|
||||
ok = resp.get("code", 0) != 0 or resp.get("cmd") == "error"
|
||||
self._record("test_invalid_json", ok,
|
||||
f"响应: {json.dumps(resp, ensure_ascii=False)}", dt)
|
||||
|
||||
async def test_msg_id_correlation(self):
|
||||
"""测试 12: msg_id 关联性验证 — 连续发送多个命令,验证每个响应的 id 正确"""
|
||||
t0 = time.monotonic()
|
||||
ids = [100, 200, 300]
|
||||
results = []
|
||||
|
||||
for mid in ids:
|
||||
self.responses.clear()
|
||||
self._response_event.clear()
|
||||
|
||||
request = {"cmd": "ping", "id": mid}
|
||||
payload = json.dumps(request, separators=(",", ":")).encode("utf-8")
|
||||
await self.client.write_gatt_char(CHAR_WRITE_UUID, payload, response=True)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(self._response_event.wait(), timeout=self.timeout)
|
||||
resp = self.responses[-1] if self.responses else None
|
||||
except asyncio.TimeoutError:
|
||||
resp = None
|
||||
|
||||
if resp:
|
||||
results.append((mid, resp.get("id")))
|
||||
else:
|
||||
results.append((mid, None))
|
||||
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
dt = (time.monotonic() - t0) * 1000
|
||||
all_match = all(sent == recv for sent, recv in results)
|
||||
detail = ", ".join(f"sent={s}->recv={r}" for s, r in results)
|
||||
self._record("test_msg_id_correlation", all_match, detail, dt)
|
||||
|
||||
# ============================================================
|
||||
# 主运行流程
|
||||
# ============================================================
|
||||
|
||||
async def run(self):
|
||||
"""执行全部测试"""
|
||||
print("=" * 60)
|
||||
print(" BLE JSON 通讯模块 - 自动化测试")
|
||||
print("=" * 60)
|
||||
|
||||
# Step 1: 扫描
|
||||
device = await self.test_scan_device()
|
||||
if not device:
|
||||
self._print_summary()
|
||||
return
|
||||
|
||||
# Step 2: 连接
|
||||
print(f"\n正在连接 {device.address}...")
|
||||
try:
|
||||
self.client = BleakClient(device, timeout=10.0)
|
||||
await self.client.connect()
|
||||
print(f" 已连接, MTU={self.client.mtu_size}")
|
||||
except Exception as e:
|
||||
print(f" 连接失败: {e}")
|
||||
self._print_summary()
|
||||
return
|
||||
|
||||
try:
|
||||
# Step 3-12: 运行测试用例
|
||||
print("\n--- 服务发现 ---")
|
||||
if not await self.test_discover_service():
|
||||
return
|
||||
|
||||
print("\n--- NOTIFY 启用 ---")
|
||||
if not await self.test_enable_notify():
|
||||
return
|
||||
|
||||
# 等待 CCCD 写入生效
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
print("\n--- 功能测试 ---")
|
||||
await self.test_ping()
|
||||
await self.test_status()
|
||||
await self.test_dev_info()
|
||||
await self.test_wifi_list()
|
||||
await self.test_set_vol()
|
||||
|
||||
print("\n--- 错误处理测试 ---")
|
||||
await self.test_set_wifi_missing_ssid()
|
||||
await self.test_unknown_cmd()
|
||||
await self.test_invalid_json()
|
||||
|
||||
print("\n--- 关联性测试 ---")
|
||||
await self.test_msg_id_correlation()
|
||||
|
||||
finally:
|
||||
print("\n断开连接...")
|
||||
try:
|
||||
await self.client.stop_notify(CHAR_NOTIFY_UUID)
|
||||
except Exception:
|
||||
pass
|
||||
await self.client.disconnect()
|
||||
|
||||
self._print_summary()
|
||||
|
||||
def _print_summary(self):
|
||||
"""打印测试总结"""
|
||||
print("\n" + "=" * 60)
|
||||
total = len(self.results)
|
||||
passed = sum(1 for r in self.results if r.passed)
|
||||
failed = total - passed
|
||||
|
||||
print(f" 测试结果: {passed}/{total} 通过, {failed} 失败")
|
||||
print("=" * 60)
|
||||
|
||||
if failed > 0:
|
||||
print("\n失败用例:")
|
||||
for r in self.results:
|
||||
if not r.passed:
|
||||
print(f" [x] {r.name}: {r.detail}")
|
||||
print()
|
||||
|
||||
# 返回退出码
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="BLE JSON 通讯模块测试")
|
||||
parser.add_argument("--device", default=DEFAULT_DEVICE_NAME,
|
||||
help=f"BLE 设备名称 (默认: {DEFAULT_DEVICE_NAME})")
|
||||
parser.add_argument("--timeout", type=float, default=5.0,
|
||||
help="命令响应超时秒数 (默认: 5.0)")
|
||||
args = parser.parse_args()
|
||||
|
||||
tester = BleJsonTester(device_name=args.device, timeout=args.timeout)
|
||||
exit_code = asyncio.run(tester.run())
|
||||
sys.exit(exit_code or 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
x
Reference in New Issue
Block a user