fix blue server

This commit is contained in:
zyc 2026-02-10 10:54:42 +08:00
parent ea5050309e
commit 02ae116488
8 changed files with 2071 additions and 0 deletions

View 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 | 消息 ID1~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 bytesSSID 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=8BluFi 占 1新模块占 1富余 6 个)
- **内存:** 8MB PSRAM + 320KB DRAMJSON 解析开销可忽略)
- **输出格式:** 使用 `cJSON_PrintUnformatted()` 紧凑输出,无空格无换行
- **BLE 回调线程安全:** GATTS 回调中不直接解析 JSON通过 FreeRTOS 队列转发到应用任务处理

View File

@ -19,6 +19,7 @@ set(SOURCES "audio_codecs/audio_codec.cc"
"settings.cc" "settings.cc"
"background_task.cc" "background_task.cc"
"bluetooth_provisioning.cc" # "bluetooth_provisioning.cc" #
"ble_service.cc" # BLE JSON
"weather_api.cc" "weather_api.cc"
"main.cc" "main.cc"
) )

View File

@ -1,4 +1,5 @@
#include "application.h" #include "application.h"
#include "ble_service_config.h"
#include "board.h" #include "board.h"
#include "wifi_board.h" #include "wifi_board.h"
#include "display.h" #include "display.h"
@ -1572,6 +1573,10 @@ void Application::Start() {
#endif #endif
SetDeviceState(kDeviceStateIdle); SetDeviceState(kDeviceStateIdle);
// 初始化 BLE JSON 通讯服务
InitBleJsonService();
// 每次设备开机后idle状态下测试 自动检测并设置当前位置打印 // 每次设备开机后idle状态下测试 自动检测并设置当前位置打印
//此逻辑为冗余操作当前NVS中没有城市信息时会自动调用 位置查询API //此逻辑为冗余操作当前NVS中没有城市信息时会自动调用 位置查询API
// Schedule([]() { // 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");
}

View File

@ -20,6 +20,7 @@
#include "ota.h" #include "ota.h"
#include "background_task.h" #include "background_task.h"
#include "audio/simple_pipeline.h" #include "audio/simple_pipeline.h"
#include "ble_service.h"
#if CONFIG_USE_WAKE_WORD_DETECT #if CONFIG_USE_WAKE_WORD_DETECT
#include "wake_word_detect.h" #include "wake_word_detect.h"
@ -191,6 +192,12 @@ private:
bool suppress_next_idle_sound_ = false;// 标志:是否抑制下一个空闲状态的声音播放 bool suppress_next_idle_sound_ = false;// 标志:是否抑制下一个空闲状态的声音播放
void StartDialogWatchdog();// 启动对话看门狗 void StartDialogWatchdog();// 启动对话看门狗
void StopDialogWatchdog(); // 停止对话看门狗 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_ #endif // _APPLICATION_H_

696
main/ble_service.cc Normal file
View 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_, &notify_char_uuid,
ESP_GATT_PERM_READ,
notify_prop, &notify_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
View 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
View 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
View 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()