From 02ae116488bbf4229489704479d92128cb8e085d Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Tue, 10 Feb 2026 10:54:42 +0800 Subject: [PATCH] fix blue server --- BLE_JSON_通讯模块开发计划.md | 489 ++++++++++++++++++++++++ main/CMakeLists.txt | 1 + main/application.cc | 224 +++++++++++ main/application.h | 7 + main/ble_service.cc | 696 +++++++++++++++++++++++++++++++++++ main/ble_service.h | 115 ++++++ main/ble_service_config.h | 49 +++ tests/ble_json_test.py | 490 ++++++++++++++++++++++++ 8 files changed, 2071 insertions(+) create mode 100644 BLE_JSON_通讯模块开发计划.md create mode 100644 main/ble_service.cc create mode 100644 main/ble_service.h create mode 100644 main/ble_service_config.h create mode 100644 tests/ble_json_test.py diff --git a/BLE_JSON_通讯模块开发计划.md b/BLE_JSON_通讯模块开发计划.md new file mode 100644 index 0000000..ab4804f --- /dev/null +++ b/BLE_JSON_通讯模块开发计划.md @@ -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 队列转发到应用任务处理 diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index d864cc6..b7b89d3 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -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" ) diff --git a/main/application.cc b/main/application.cc index 25ed6ce..91c35b6 100644 --- a/main/application.cc +++ b/main/application.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(state); + if (idx >= 0 && idx < static_cast(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(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(wifi_config.sta.ssid), + ssid_item->valuestring, sizeof(wifi_config.sta.ssid) - 1); + if (pwd_item && cJSON_IsString(pwd_item)) { + strncpy(reinterpret_cast(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(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(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"); +} diff --git a/main/application.h b/main/application.h index 4e3c655..b9ee208 100644 --- a/main/application.h +++ b/main/application.h @@ -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_ diff --git a/main/ble_service.cc b/main/ble_service.cc new file mode 100644 index 0000000..694cde4 --- /dev/null +++ b/main/ble_service.cc @@ -0,0 +1,696 @@ +#include "ble_service.h" +#include "ble_service_config.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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(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); + } + } +} diff --git a/main/ble_service.h b/main/ble_service.h new file mode 100644 index 0000000..f9bb00e --- /dev/null +++ b/main/ble_service.h @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include + +#ifdef ESP_PLATFORM +#include +#include +#include +#endif + +struct cJSON; + +class BleJsonService { +public: + // 收到 JSON 命令时的回调: cmd, msg_id, data(cJSON*) + using CommandCallback = std::function; + + 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_; +}; diff --git a/main/ble_service_config.h b/main/ble_service_config.h new file mode 100644 index 0000000..74eeddf --- /dev/null +++ b/main/ble_service_config.h @@ -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 diff --git a/tests/ble_json_test.py b/tests/ble_json_test.py new file mode 100644 index 0000000..33a9de9 --- /dev/null +++ b/tests/ble_json_test.py @@ -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()