From 3e87d333cb1d5518107fec5e217896c98ffe63c7 Mon Sep 17 00:00:00 2001 From: "evan.liu" <583229071@qq.com> Date: Sat, 5 Sep 2026 06:45:04 +0800 Subject: [PATCH] App: adapt to firmware V1.00.23 BLE protocol + field debugging fixes - BluetoothManager: per-characteristic isolated channels (per-UUID read queue/timeout/dispatch, no cross-char write blocking), 10007 delayed retry for read/write, 300ms link settle after discovery, full tx/rx hex logging - McmCli/McmInfo/McmOta: command/response, TLV parse and OTA frame logs - home.vue: Auto refresh switch (polling off by default, no GATT interference with OTA), MTU-then-load sequencing - ota.vue: SAF file picker via plus.android (ACTION_GET_CONTENT) with Scanner whole-stream read + magic self-check + per-byte fallback; default path moved to _doc (Android 10+ scoped storage) - docs: change logs for 2026-09-04 protocol adaptation and 2026-09-05 field debugging; AGENTS.md updated --- AGENTS.md | 10 +- ...04_App适配固件V1.00.23协议_CLI参数与OTA恢复.md | 83 +++ ...½•_2026-09-05_真机联调_BLE通道隔离与SAF选包.md | 78 +++ .../bluetoothOperation/bluetoothOperation.vue | 5 +- SmartAssiter/pages/tabbar/home.vue | 159 +++++- SmartAssiter/pages/tabbar/ota.vue | 474 +++++++++++++++++- SmartAssiter/src/BluetoothManager.ts | 245 ++++++--- SmartAssiter/src/mcm/McmCli.ts | 24 +- SmartAssiter/src/mcm/McmCommands.ts | 64 ++- SmartAssiter/src/mcm/McmInfo.ts | 19 +- SmartAssiter/src/mcm/McmOta.ts | 364 ++++++++++++++ 11 files changed, 1400 insertions(+), 125 deletions(-) create mode 100644 SmartAssiter/docs/修改记录_2026-09-04_App适配固件V1.00.23协议_CLI参数与OTA恢复.md create mode 100644 SmartAssiter/docs/修改记录_2026-09-05_真机联调_BLE通道隔离与SAF选包.md create mode 100644 SmartAssiter/src/mcm/McmOta.ts diff --git a/AGENTS.md b/AGENTS.md index ca8e8a4..0bc128b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ 1. **设备固件** — 基于国民技术(Nations)**N32WB031 BLE SoC**(Cortex-M0,64MHz HSI,**板载硅片实测 256KB Flash**(0x01000000~0x0103FFFF,开发日志 §37),RAM 48KB+16KB): - **`mcm-ddc-ble/`** — 唯一活跃的应用固件工程(APP,链接在 APP1 bank `0x01008000`/112KB)。以 SDK rdtss 例程为蓝本,集成 FreeRTOS、UART CLI、BLE 自定义 GATT 服务(CLI 透传 / 设备信息查询 / BLE OTA 双 bank 直写升级)、bootsetting 与 APP_DATA 参数区结构化读写命令。当前版本 **V1.00.23**(`mcm-ddc-ble/inc/app_version.h`)。历史上曾有 `mcm-ddc-04/` 主工程,**已删除**,其功能已全部并入本工程。 - **`Boot/`** — 自写精简 bootloader(链接在 `0x01000000`/16KB):校验 bootsetting 记录自身完整性(magic + 结构体 CRC32)后按 active bank 的 `start_address` 直接跳转;**不校验镜像 CRC(CRC 校验在 OTA 升级过程中完成)**;记录无效或地址越界回退 APP1。 -2. **手机 App `SmartAssiter/`** — uni-app **Vue3 + TypeScript** 工程(HBuilderX 项目管理,无 package.json),通过 BLE 连接设备:查看运行参数(温度/电压/转速等)、CLI 终端、OTA 升级页(当前停用,见下)。另有登录/注册/用户信息等云端业务页面。 +2. **手机 App `SmartAssiter/`** — uni-app **Vue3 + TypeScript** 工程(HBuilderX 项目管理,无 package.json),通过 BLE 连接设备:查看运行参数(温度/电压/转速/bank/堆等)、CLI 终端、参数读写(appget/appset)、OTA 升级页(e0005 锁步,已恢复)。另有登录/注册/用户信息等云端业务页面。 3. **PC 工具 `tools/`** — 整片烧录包制作/烧录脚本 + bleak 蓝牙测试客户端。 关键文档: @@ -127,7 +127,7 @@ mcm-ddc-ble/ | `...c72ee004` | Read | **只读参数特征**:读出全部信息项 TLV(同 INFO_RSP(all)),V1.00.04 新增 | | `...c72ee005` | Read + Write(带响应) | **OTA 专用特征**(V1.00.19 新增):写携带一帧 OTA 传输帧 → 读同一特征取回 OTA_RSP(轮询至非空),见 ble_protocol.md §6.6 | -- **notify 上行已修复(V1.00.19)**:根因是固件帧 CRC16 漏算最后一个 payload 字节(3+payLen vs 协议规定的 4+payLen),所有帧被静默丢弃,与 notify 硬件链路无关(开发日志 §42)。帧协议通道(e0001/e0002)已实测恢复。App(SmartAssiter)当前仍走 `...e0003`/`...e0004` 读写特征;OTA 页可基于 `...e0005` 重做(PC 参考实现 `tools/ble_ota_update.py`)。 +- **notify 上行已修复(V1.00.19)**:根因是固件帧 CRC16 漏算最后一个 payload 字节(3+payLen vs 协议规定的 4+payLen),所有帧被静默丢弃,与 notify 硬件链路无关(开发日志 §42)。帧协议通道(e0001/e0002)已实测恢复。App(SmartAssiter)走 `...e0003`/`...e0004` 读写特征 + `...e0005` OTA 特征(App 侧参考 `src/mcm/McmOta.ts`,PC 参考 `tools/ble_ota_update.py`)。 - 帧格式:`0xCA | TYPE | SEQ | LEN(LE16) | PAYLOAD | CRC16-CCITT(0x1021/0xFFFF, LE)`,**CRC 覆盖 TYPE..PAYLOAD 共 4+payLen 字节**;OTA 为双 bank 直写、**逐帧锁步 ack**、END 整镜像 zlib CRC32 + 向量表 bank 校验后更新 bootsetting 并复位。OTA 传输层通道无关:BLE e0005 / UART 二进制模式(CLI `ota`)/ 旧帧协议通道,详见 ble_protocol.md §6.6。 - 连接后设备主动发起 MTU=247 交换;失败退化为默认 20B/包,协议按字节流重组,两种情形都正确。 - 信息项 id:0x01 固件版本 u32 / 0x02 芯片温度 i16(0.1°C,ADC CH7) / 0x03 风扇转速 u16(0xFFFF=无硬件,`app_fan_get_rpm()` 弱符号,产品板重写即可) / 0x04 电压 u16(mV,ADC CH6) / 0x05 运行时长 u32(s) / 0x06 剩余堆 u32(B) / 0x07 当前运行 bank u8(1=APP1,2=APP2,OTA 选包依据)。 @@ -139,9 +139,9 @@ mcm-ddc-ble/ - uni-app **Vue3 + TypeScript**(`manifest.json` vueVersion 3,appid `__UNI__884AC41`),**HBuilderX 工程**(有 `.hbuilderx/`,无 package.json/vite 配置)——开发/运行/打包均在 HBuilderX IDE 内进行(运行→运行到手机或模拟器;发行→原生 App 云打包)。目标平台为 Android/iOS App(BLE 功能仅 App 端有效;manifest 里保留 mp-weixin 配置但蓝牙不依赖它)。 - 由 uni-app x(uvue/uts)+ 第三方蓝牙插件迁移而来,现蓝牙全部用 **uni 内置 BLE API**(vue3 App 端内置),标准基座即可运行(见 `SmartAssiter/docs/修改记录_2026-09-03_迁移uni-app-vue3与蓝牙去插件.md`)。 - 结构: - - `src/BluetoothManager.ts` — 蓝牙管理核心(扫描/连接/服务发现/读写/MTU/RSSI;读请求经 `onBLECharacteristicValueChange` 全局回调 + 内部串行队列 + 3s 超时;连接状态经 `uni.$emit("bleState", state)` 广播,0=已连接可用) - - `src/mcm/` — 设备业务层:`McmCli.ts`(CLI 特征写命令/分包读应答——读到 0 长度包止,Promise 链串行)、`McmInfo.ts`(参数特征 TLV 解析)、`McmCommands.ts`(业务命令封装) - - `pages/tabbar/` — home(设备连接+参数展示)、ota(升级页,**当前停用**并显示原因说明)、my;`pages/bleTest/bluetoothOperation/`(CLI 终端页);login/register/displayUserInfo 为云端账号业务 + - `src/BluetoothManager.ts` — 蓝牙管理核心(扫描/连接/服务发现/读写/MTU/RSSI;**读写按特征值独立通道**:读请求经 `onBLECharacteristicValueChange` 按 characteristicId 分发到各特征自己的队列/超时(3s),写无跨特征阻塞;链路未稳误报的 10007 读/写各延时重试一次;连接状态经 `uni.$emit("bleState", state)` 广播,0=已连接可用) + - `src/mcm/` — 设备业务层:`McmCli.ts`(CLI 特征写命令/分包读应答——读到 0 长度包止,Promise 链串行)、`McmInfo.ts`(参数特征 TLV 解析,含 0x07 CUR_BANK)、`McmCommands.ts`(业务命令封装:sysinfo/devinfo/appget/appset 等)、`McmOta.ts`(combo 包解析 + e0005 锁步 OTA,重发/再同步/中止) + - `pages/tabbar/` — home(设备连接+参数展示+APP_DATA 参数读写)、ota(升级页:选择 mothercup_ble_ota.bin→e0005 锁步 OTA→复位回连复核)、my;`pages/bleTest/bluetoothOperation/`(CLI 终端页);login/register/displayUserInfo 为云端账号业务 - `api/` — 云端 HTTP 封装(`httpInstance.ts` 含服务器列表 SERVER_LIST,正式/测试/DEBUG 三地址,token 鉴权);`common/auth.ts`(token 本地存储)、`common/bleInfo.ts`(已连设备名/MAC 记忆) - `uni_modules/` 仅 uni-icons、uni-scss(官方组件,无第三方插件) - App 侧错误码约定(BluetoothManager):0=成功,10000=连接失败,10001=已断开,10002=蓝牙未开,10003=无权限,10004/10005=服务/特征缺失,10008=读写/MTU 失败或超时。 diff --git a/SmartAssiter/docs/修改记录_2026-09-04_App适配固件V1.00.23协议_CLI参数与OTA恢复.md b/SmartAssiter/docs/修改记录_2026-09-04_App适配固件V1.00.23协议_CLI参数与OTA恢复.md new file mode 100644 index 0000000..f9ca968 --- /dev/null +++ b/SmartAssiter/docs/修改记录_2026-09-04_App适配固件V1.00.23协议_CLI参数与OTA恢复.md @@ -0,0 +1,83 @@ +# 修改记录 2026-09-04:App 适配固件 V1.00.23 协议(CLI/devinfo/参数/OTA 恢复) + +## 背景 + +固件 V1.00.19~V1.00.23 期间 BLE 协议定型(docs/ble_protocol.md V1.1,§6.5/§6.6): + +- CLI 读写特征 `...e0003`:写命令行 → 分包读回(0 长度包=结束)——App 已有 +- 只读参数特征 `...e0004`:TLV 全项,**V1.00.16 起新增 0x07 CUR_BANK**(当前运行 bank) +- OTA 特征 `...e0005`(V1.00.19 起):写 0xCA 传输帧 → 读回 OTA_RSP(轮询至非空), + 逐帧锁步 + 丢帧重发 + BAD_STATE 再同步;END 后设备延迟复位保证应答送达 +- 升级包:`mothercup_ble_ota.bin` 单文件(52B 头 + bank1/bank2 双载荷,头带版本/CRC) + +本次 App 改造按此协议补齐 CLI/devinfo/sysinfo/参数读写/OTA。 + +## 改动清单 + +### src/BluetoothManager.ts + +- 新增 `MCM_OTA_UUID`(...e0005);`scanServices` 特征校验加入 OTA 特征 + (缺任一特征报 10005) +- 写接口重构为通用 `writeChar(charId, data, cb)`,`writeCliChar`/`writeOtaChar` + 均为其封装(write-with-response) +- 新增 MTU 跟踪:`setMtu` 成功记录实际值,`getMtu()` 供 OTA 计算帧长 + (`min(mtu-3-11, 222)`);断连/新连接重置为 23。**注意:部分机型 + setBLEMTU 成功回调不带 mtu 字段**(实测日志 "mtu undefined"),此时按 + 请求值记录;McmOta 侧另有 NaN 防线(异常时按 222 分块) +- 头注释更新为当前 GATT 接口实况 + +### src/mcm/McmInfo.ts + +- 新增信息项 `INFO_CUR_BANK = 0x07`(u8,1=APP1 2=APP2),`DeviceInfo` 加 + `curBank` 字段 + +### src/mcm/McmOta.ts(新建) + +- `crc32`(IEEE,同 zlib)/ `crc16`(CCITT 0x1021/0xFFFF,同固件) +- `parseCombo()`:解析校验 mothercup_ble_ota.bin(magic/hdr_len/count/ + hdr_crc/total_size/payload_crc/逐 bank crc) +- `checkBlobBank()`:传输前预检载荷向量表 Reset PC 落在目标 bank(防选错包) +- `runOta()`:BEGIN→DATA*→END 锁步;每帧写 e0005 后 20ms 轮询读 RSP(2s 超时); + 超时重发 ≤3 次;BAD_STATE 再同步(重复 BEGIN=会话已建、重复 DATA offset 等于 + 本帧结束位置=ack 丢失视为成功);`abortOta()` 中止(发 OTA_ABORT); + END 的传输级失败(超时/断连)不当失败——设备可能已复位,由回连复核定论 +- `formatVersion()`:0x00010017 → "V1.00.23" + +### src/mcm/McmCommands.ts + +- 删除 `getBankInfo()`(固件并无 `bankinfo` 命令,历史遗留 bug,一直返回 + "unknown command");bank 改由 `getCurBank()`(TLV 0x07)提供 +- 新增 `getDevInfo()`(TLV 全项)、`getDevInfoText()`(devinfo 原文) +- 新增 APP_DATA 参数读写:`getAppParams()`(`appget json` 解析)、 + `setAppParam(key, val)`(`appset `,按 "saved" 判成功) + +### pages/tabbar/ota.vue(重做,恢复升级功能) + +- 默认路径 `/sdcard/Download/mothercup_ble_ota.bin`,plus.io 读本地文件 + (沿用旧版方式) +- Load Package:解析+校验包,按 CUR_BANK 自动选对侧载荷,向量表预检, + 显示包版本/双 bank 大小/目标 bank +- Start Upgrade:进度条 + 字节数 + 每帧 rtt + 重传计数;Abort 按钮; + 全程日志卡 +- END 后:等设备复位断连(≤10s,未断则主动断开)→ 回连(失败重试 5 次)→ + 复核 curBank==目标 && fwVersion==包版本 → PASS/FAIL 日志+toast + +### pages/tabbar/home.vue + +- 参数轮询扩展为 devinfo 全项:温度/电压/风扇 + bank/uptime/堆剩余 +- bank 改从 TLV CUR_BANK 读取(不再调不存在的 bankinfo 命令) +- 新增 Params 卡:LED 闪烁半周期(ms) 与 PWM 占空比(%) 的读取与设置 + (appget json / appset),显示记录 valid 状态 +- Scan 按钮补齐静默失败反馈:蓝牙未开/无权限时 toast 提示(原代码 + openBluetooth 失败回调为空,点了毫无反应) + +## 验证 + +- TS 模块(BluetoothManager/McmCli/McmInfo/McmCommands/McmOta)经 tsc + --noEmit 语法校验通过 +- 实机验证需在 HBuilderX 运行到手机:连接设备 → home 页参数/devinfo/参数 + 读写 → ota 页选包升级 → 复核 PASS + +## 依赖 + +固件 ≥ V1.00.19(e0005 OTA 特征);当前基线 V1.00.23。 diff --git a/SmartAssiter/docs/修改记录_2026-09-05_真机联调_BLE通道隔离与SAF选包.md b/SmartAssiter/docs/修改记录_2026-09-05_真机联调_BLE通道隔离与SAF选包.md new file mode 100644 index 0000000..8296b5d --- /dev/null +++ b/SmartAssiter/docs/修改记录_2026-09-05_真机联调_BLE通道隔离与SAF选包.md @@ -0,0 +1,78 @@ +# 修改记录 2026-09-05:真机联调——BLE 通道按特征值隔离 + SAF 选包 + 全链路日志 + +## 背景 + +V1.00.23 协议适配(见 2026-09-04 修改记录)后真机联调,依次踩到并解决: +连接后读超时/写 10007、OTA 页读包失败(Android 10+ 分区存储)、 +plus 桥 byte[] 不回写导致读包内容错误等。本轮全部修复并加了可定位问题的日志。 + +## 改动清单 + +### src/BluetoothManager.ts(核心:读写按特征值隔离成独立通道) + +- **读改为按特征 UUID 分键的独立队列**(`readQueues`/`readActiveMap`/`readTimers`): + 同一特征上的读串行(e0003 分包读不与 e0004 轮询读交错);不同特征互不阻塞, + 一路超时/失败不影响其他通道;`onBLECharacteristicValueChange` 按 + characteristicId 直接分发到对应通道 +- **写无任何跨特征等待**(`writeChar` 直接发);断连时逐通道排空各自队列 +- **10007 "property not support" 延时重试**:读和写各一次(1600ms)——实测安卓 + 链路未稳定时会误报 10007(特征属性明明支持写),参考 DCloud 社区已知问题 +- **链路稳定期**:`scanServices` 成功后 300ms 内不发出 GATT 操作(连接级一次性) +- 全链路日志:连接/断开事件、`tx/rx <特征短名> <长度> `(截断标注总长)、 + 读发起/超时、10007 重试提示 + +实测根因记录:home 页曾在 `bleState=0` 时**并发**调 `setMtu` 与读 e0004, +读被安卓无声丢弃(3s 超时),后续写全部被拖进 10007;通道隔离 + 时序修正后消失。 + +### src/mcm/McmCli.ts + +- 日志:`[CLI] >> 命令` / `[CLI] << (NB, status=0) `,失败带底层 + errMsg 原文;`CliResult` 新增 `errMsg` 字段 + +### src/mcm/McmInfo.ts + +- 日志:`[INFO] TLV NB parsed: {...}`;读失败打 `[INFO] read failed` + +### src/mcm/McmOta.ts + +- 帧日志:BEGIN/END/ABORT 全帧 hex;每条 OTA_RSP 打 `echo/status/offset + hex`; + 坏帧内容一并打出(DATA 帧体不逐条打,避免刷屏) + +### pages/tabbar/home.vue + +- **Auto refresh (2s) 开关,缺省关闭**:不再自动周期读 e0004,避免轮询占用 + GATT 干扰 OTA/CLI;开关打开才轮询(立即查一次 + 2s 周期); + 连接就绪回调与 onShow 两个自动启动点都已按开关门控 +- 连接初始化改串行:先 `setMtu(247)`,回调里再 `loadDeviceInfo()`(消除 + GATT 并发撞车) + +### pages/tabbar/ota.vue(选包/读包重做) + +Android 10+ 分区存储(targetSdk≥29)禁止直读 `/sdcard/Download`(plus 报 +code 15),`uni.chooseFile` 在 App 端 vue3 又不存在,最终方案: + +- **Choose File 按钮**:plus.android 起 `Intent.ACTION_GET_CONTENT` 系统 SAF + 选择器(免权限,可直接选 Download 里的包) +- **读包**:`ContentResolver.openInputStream(uri)` → `java.util.Scanner` 按 + ISO-8859-1 整流读成 String 过桥(字节 1:1 映射,规避 **byte[] 参数按值拷贝、 + `read(buf)` 写不回 JS 侧**的桥限制——曾读出整块填充字节 0x41)→ 读回自验 + magic(`11 BA 10 CA`)→ **不符自动重开流落逐字节 `read()` 直读**(int 返回值 + 过桥,二进制绝对精确,每 16KB 打进度) +- 全程日志:picker 拉起/onActivityResult 返回码/uri 原文/读取模式/字节数/ + 文件头 hex/magic 校验结论;失败分支带异常原文 +- 手动路径方式保留:默认 `_doc/mothercup_ble_ota.bin`(应用私有目录,可 adb + push 进去);解析失败时日志带实际读到的长度+头部 hex,便于区分"读错"与"格式错" +- `window.plus` 改 `globalThis.plus`(App 端 vue3 无 window 对象) + +## 验证(真机) + +- e0004 参数特征读通:TLV 33B 解析正确(V1.00.23 / APP1 / 21.2℃ / 3335mV / + uptime / 堆剩余),10007 与读超时不再出现 +- SAF 选包读通:111168B 与包文件一致 +- parseCombo 逻辑在 PC 侧 node 用真实 `mothercup_ble_ota.bin` 逐字节验证: + magic/hdr_len/total_size/payload_crc/逐 bank crc 全对(协议符合性确认无误) + +## 待办 + +- BLE OTA 全流程真机(Start Upgrade → 复位 → 回连复核 PASS) +- 修改记录 2026-09-04 的实机验证项随本轮一并闭环 diff --git a/SmartAssiter/pages/bleTest/bluetoothOperation/bluetoothOperation.vue b/SmartAssiter/pages/bleTest/bluetoothOperation/bluetoothOperation.vue index 32c8e77..4ba5b88 100644 --- a/SmartAssiter/pages/bleTest/bluetoothOperation/bluetoothOperation.vue +++ b/SmartAssiter/pages/bleTest/bluetoothOperation/bluetoothOperation.vue @@ -155,7 +155,10 @@ async function onSendCmd() { } else if (r.status == 1) { pushMsg("message", "Unknown command or line too long") } else { - pushMsg("message", "Command failed, status=" + r.status + (r.text.length > 0 ? (", received: " + r.text) : "")) + // 失败:打印状态码 + 底层 BLE 错误原文 + 已收到的部分内容 + pushMsg("message", "Command failed, status=" + r.status + + (r.errMsg.length > 0 ? (", " + r.errMsg) : "") + + (r.text.length > 0 ? (", received: " + r.text) : "")) } } diff --git a/SmartAssiter/pages/tabbar/home.vue b/SmartAssiter/pages/tabbar/home.vue index 44e41f1..fad1613 100644 --- a/SmartAssiter/pages/tabbar/home.vue +++ b/SmartAssiter/pages/tabbar/home.vue @@ -19,17 +19,25 @@ + + Auto refresh (2s) + + Temperature {{ temperature }} ℃ + + Voltage + {{ voltage }} mV + Fan Speed {{ fanSpeed }} RPM - + Firmware @@ -39,6 +47,29 @@ Bank {{ bankInfo }} + + Uptime + {{ uptime }} + + + Free Heap + {{ freeHeap }} + + + + + + Params (APP_DATA{{ paramValid ? '' : ', defaults not saved' }}) + + LED blink (ms) + + + + + PWM duty (%) + + + @@ -91,9 +122,18 @@ const deviceName = ref("") const deviceMac = ref("") const temperature = ref("--") const fanSpeed = ref("--") +const voltage = ref("--") const fwVersion = ref("--") const bankInfo = ref("--") +const uptime = ref("--") +const freeHeap = ref("--") const sysInfo = ref("--") +const paramLed = ref("") +const paramPwm = ref("") +const paramValid = ref(false) +const paramBusy = ref(false) +// 定时查询开关:缺省关闭,避免轮询占用 GATT 干扰 OTA/CLI 等操作 +const autoRefresh = ref(false) let pollTimer = 0 let pageVisible = false @@ -102,12 +142,17 @@ let pageVisible = false async function pollParams() { if (!connected.value || !pageVisible) return - // 读只读参数特征(MCM_INFO_UUID,TLV 同 INFO_RSP 全部项),取温度+风扇 + // 读只读参数特征(MCM_INFO_UUID,TLV 同 INFO_RSP 全部项,含 CUR_BANK) const info = await McmInfo.readDeviceInfo() const t = info.chipTemp temperature.value = t != null ? t.toFixed(1) : "--" const f = info.fanRpm fanSpeed.value = f != null ? f.toString() : "N/A" + const v = info.vddMv + voltage.value = v != null ? v.toString() : "--" + if (info.curBank != null) bankInfo.value = "APP" + info.curBank + if (info.uptimeS != null) uptime.value = info.uptimeS + " s" + if (info.freeHeap != null) freeHeap.value = info.freeHeap + " B" } function stopPolling() { @@ -122,39 +167,85 @@ function startPolling() { // 注意:uts 的 setTimeout 不接受 async 回调,用 then 链式调度 pollTimer = setTimeout(() => { pollParams().then(() => { - if (connected.value && pageVisible) { + if (connected.value && pageVisible && autoRefresh.value) { startPolling() } }) }, POLL_INTERVAL) } +// 定时查询开关(switch 事件):开=立即查一次并开始轮询,关=停掉轮询 +function onAutoRefreshChange(e: any) { + autoRefresh.value = e.detail.value as boolean + console.log("[HOME] auto refresh " + (autoRefresh.value ? "ON" : "OFF")) + if (autoRefresh.value && connected.value) { + pollParams() + startPolling() + } else { + stopPolling() + } +} + // ================= 设备信息 ================= async function loadDeviceInfo() { - const v = await McmCmd.getVersion() - if (v != null) fwVersion.value = v - const b = await McmCmd.getBankInfo() - if (b != null) { - // bankinfo 原文较长,取首个 "APP1/APP2" 关键词展示 - bankInfo.value = b.indexOf("APP2") >= 0 ? "APP2" : (b.indexOf("APP1") >= 0 ? "APP1" : b) - } + // 版本/bank 走参数特征 TLV(devinfo 全项) + const info = await McmCmd.getDevInfo() + if (info.fwVersion != null) fwVersion.value = McmCmd.formatVersion(info.fwVersion) + if (info.curBank != null) bankInfo.value = "APP" + info.curBank + if (info.uptimeS != null) uptime.value = info.uptimeS + " s" + if (info.freeHeap != null) freeHeap.value = info.freeHeap + " B" + // sysinfo(FreeRTOS 运行状态)走 CLI 透传 const s = await McmCmd.getSysInfo() sysInfo.value = s != null ? s : "--" + // APP_DATA 参数(appget json) + loadAppParams() +} + +async function loadAppParams() { + const p = await McmCmd.getAppParams() + if (p != null) { + paramLed.value = p.led.toString() + paramPwm.value = p.pwm.toString() + paramValid.value = p.valid == 1 + } +} + +async function onSetParam(key: string) { + if (paramBusy.value) return + const raw = key == "led" ? paramLed.value : paramPwm.value + const val = parseInt(raw) + if (isNaN(val)) { + uni.showToast({ title: 'Invalid number', icon: 'none' }) + return + } + paramBusy.value = true + const okSet = await McmCmd.setAppParam(key, val) + paramBusy.value = false + if (okSet) { + uni.showToast({ title: key + ' saved', icon: 'success' }) + loadAppParams() + } else { + uni.showToast({ title: 'Set failed (range?)', icon: 'error' }) + } } // ================= 蓝牙事件 ================= -// 连接就绪(bleState 0:connect + 服务/特征校验已通过):协商 MTU 后加载设备信息 +// 连接就绪(bleState 0:connect + 服务/特征校验已通过):先协商 MTU, +// 完成后再加载设备信息——安卓 GATT 单操作约束,并发会互挤(读丢失/写 10007) function onConnectedReady() { - Ble.setMtu(247, (resMtu: MyApiResult) => { - console.log("[MCM] setMtu 247: type=" + resMtu.type + " msg=" + resMtu.message) - }) connected.value = true connecting.value = false deviceMac.value = Ble.getConnectMac() - loadDeviceInfo() - startPolling() + Ble.setMtu(247, (resMtu: MyApiResult) => { + console.log("[MCM] setMtu 247: type=" + resMtu.type + " msg=" + resMtu.message) + loadDeviceInfo() + // 缺省不启动定时查询,用户在页面上手动打开 Auto refresh 才轮询 + if (autoRefresh.value) { + startPolling() + } + }) } function onDisConnectUi() { @@ -163,9 +254,13 @@ function onDisConnectUi() { stopPolling() temperature.value = "--" fanSpeed.value = "--" + voltage.value = "--" fwVersion.value = "--" bankInfo.value = "--" + uptime.value = "--" + freeHeap.value = "--" sysInfo.value = "--" + paramValid.value = false } function onBleStatueListener(state: number) { @@ -187,7 +282,13 @@ function onBleStarScan() { if (scanning.value) return if (!Ble.isEnabled()) { Ble.openBluetooth((res: MyApiResult) => { - if (res.type == 0) onBleStarScan() + if (res.type == 0) { + onBleStarScan() + } else if (res.type == 10003) { + uni.showToast({ title: 'No Bluetooth permission, grant it in system settings', icon: 'none' }) + } else { + uni.showToast({ title: 'Please turn on Bluetooth first', icon: 'none' }) + } }) return } @@ -262,7 +363,9 @@ onShow(() => { deviceName.value = BleINfo.getName() ?? "" loadDeviceInfo() } - startPolling() + if (autoRefresh.value) { + startPolling() + } } else { onBleStarScan() } @@ -443,6 +546,26 @@ onPullDownRefresh(() => { margin-bottom: 12rpx; } +.paramSetRow { + display: flex; + flex-direction: row; + align-items: center; + padding: 8rpx 0; +} + +.paramSetRow .infoLabel { + width: 220rpx; +} + +.paramInput { + flex: 1; + border: 1px solid #dddddd; + border-radius: 12rpx; + padding: 8rpx 16rpx; + font-size: 24rpx; + margin-right: 16rpx; +} + .consoleText { font-size: 22rpx; color: #333333; diff --git a/SmartAssiter/pages/tabbar/ota.vue b/SmartAssiter/pages/tabbar/ota.vue index c9c5d12..316327a 100644 --- a/SmartAssiter/pages/tabbar/ota.vue +++ b/SmartAssiter/pages/tabbar/ota.vue @@ -20,21 +20,49 @@ {{ bankInfo }} - - - - OTA Upgrade - - OTA is temporarily unavailable: the current firmware's notify uplink is broken, - so upgrade acknowledgements cannot be returned to the phone. - This feature will be restored after a firmware update (SWD reflash). - - - OTA 暂不可用:当前固件 notify 上行链路异常,升级应答无法回传, - 待固件更新(SWD 烧录)后恢复。 - - + + + OTA package (mothercup_ble_ota.bin) + + + + + + Android 10+ scoped storage: /sdcard/Download is not readable directly - use Choose File, or copy the package into the app private dir (_doc = Android/data/<package>/files) + + Package Version + {{ pkgVersion }} + + + Payloads + bank1 {{ pkgBank1Size }}B / bank2 {{ pkgBank2Size }}B + + + Target Bank + APP{{ targetBank }} (opposite of current) + + + + + + + + + {{ progressText }} + + + + + Do not disconnect or exit during upgrade + + + + + Upgrade Log + {{ log }} + + @@ -47,20 +75,345 @@ import { ref } from 'vue' import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app' import * as Ble from "../../src/BluetoothManager" import * as McmCmd from "../../src/mcm/McmCommands" +import * as McmOta from "../../src/mcm/McmOta" const connected = ref(false) const curVersion = ref("--") const bankInfo = ref("--") +const filePath = ref("_doc/mothercup_ble_ota.bin") +const pkgLoaded = ref(false) +const pkgVersion = ref("") +const pkgBank1Size = ref(0) +const pkgBank2Size = ref(0) +const targetBank = ref(0) +const upgrading = ref(false) +const progress = ref(0) +const progressText = ref("0%") +const logs = ref([]) + +let pkg: McmOta.ComboPackage | null = null +let otaMac = "" // 升级开始时记录设备地址,供复位后回连复核 +let needVerifyOnConnect = false // 复位后回连成功时做升级结果复核 +let verifyRetries = 0 + +function addLog(msg: string) { + logs.value.push(msg) + console.log("[OTA-PAGE] " + msg) +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { setTimeout(() => { resolve() }, ms) }) +} // ================= 设备信息 ================= async function loadDeviceInfo() { - const v = await McmCmd.getVersion() - curVersion.value = v != null ? v : "--" - const b = await McmCmd.getBankInfo() - if (b != null) { - bankInfo.value = b.indexOf("APP2") >= 0 ? "APP2" : (b.indexOf("APP1") >= 0 ? "APP1" : "--") + const info = await McmCmd.getDevInfo() + curVersion.value = info.fwVersion != null ? McmCmd.formatVersion(info.fwVersion) : "--" + bankInfo.value = info.curBank != null ? ("APP" + info.curBank) : "--" +} + +// ================= 升级包读取(App 本地文件,plus.io) ================= + +function readFileBytes(path: string, cb: (bytes: number[] | null) => void) { + // #ifdef APP-PLUS + // App 端 vue3 无 window 对象,plus 挂在 globalThis 上 + const plusIo = (globalThis as any).plus ? (globalThis as any).plus.io : null + if (plusIo == null) { + console.log("readFileBytes: plus.io not available") + cb(null) + return } + plusIo.resolveLocalFileSystemURL(path, (entry: any) => { + entry.file((file: any) => { + const reader = new plusIo.FileReader() + reader.onloadend = (e: any) => { + try { + const dataUrl = e.target.result as string + const base64 = dataUrl.substring(dataUrl.indexOf(",") + 1) + const u8 = new Uint8Array(uni.base64ToArrayBuffer(base64)) + const out = [] as number[] + for (let i = 0; i < u8.length; i++) { + out.push(u8[i]) + } + cb(out) + } catch (err) { + console.log("readFileBytes parse error", err) + cb(null) + } + } + reader.onerror = (e: any) => { + console.log("readFileBytes read error", e) + cb(null) + } + reader.readAsDataURL(file) + }, (err: any) => { + console.log("readFileBytes file error", err) + cb(null) + }) + }, (err: any) => { + console.log("readFileBytes resolve error", err) + cb(null) + }) + // #endif + // #ifndef APP-PLUS + cb(null) + // #endif +} + +// 系统 SAF 文件选择器选包(标准基座无 uni.chooseFile,用 plus.android 起 +// ACTION_GET_CONTENT;选中后 ContentResolver 流式读取,绕开分区存储限制) +function onChooseFile() { + // #ifdef APP-PLUS + const plusAny = (globalThis as any).plus + if (plusAny == null || plusAny.android == null) { + uni.showToast({ title: 'Only supported on App', icon: 'none' }) + return + } + try { + const main = plusAny.android.runtimeMainActivity() + const Intent = plusAny.android.importClass("android.content.Intent") + const intent = new Intent(Intent.ACTION_GET_CONTENT) + intent.setType("*/*") + intent.addCategory(Intent.CATEGORY_OPENABLE) + main.onActivityResult = (requestCode: number, resultCode: number, data: any) => { + console.log("[OTA-PAGE] onActivityResult req=" + requestCode + " result=" + resultCode) + if (requestCode != 9001) return + if (resultCode != -1 || data == null) { // RESULT_OK = -1;取消则静默 + return + } + const r = readContentUri(plusAny.android.invoke(data, "getData")) + if (r.bytes == null) { + addLog("SAF read failed: " + r.err) + uni.showToast({ title: 'Failed to read the chosen file', icon: 'error' }) + return + } + addLog("File chosen via SAF, " + r.bytes.length + "B") + processPackageBytes(r.bytes) + } + main.startActivityForResult(intent, 9001) + console.log("[OTA-PAGE] SAF picker started") + } catch (e: any) { + const msg = (e && e.message) ? e.message : "" + e + console.log("[OTA-PAGE] start SAF picker error: " + msg) + addLog("FAIL: cannot open file picker: " + msg) + uni.showToast({ title: 'Cannot open file picker', icon: 'error' }) + } + // #endif + // #ifndef APP-PLUS + uni.showToast({ title: 'Only supported on App', icon: 'none' }) + // #endif +} + +// 升级包 magic(LE:11 BA 10 CA),读回内容先自验,不对自动换读取方式 +const PKG_HEAD_MAGIC = [0x11, 0xBA, 0x10, 0xCA] + +function headMatches(out: number[]): boolean { + if (out.length < PKG_HEAD_MAGIC.length) return false + for (let i = 0; i < PKG_HEAD_MAGIC.length; i++) { + if (out[i] != PKG_HEAD_MAGIC[i]) return false + } + return true +} + +// 读 content:// Uri 内容为字节数组。首选 Scanner 整流读成 String(快), +// 自验 magic 失败自动重开流逐字节直读(int 返回值过桥,二进制精确);失败时 err 带原因 +function readContentUri(uri: any): { bytes: number[] | null, err: string } { + // #ifdef APP-PLUS + try { + const plusAny = (globalThis as any).plus + const main = plusAny.android.runtimeMainActivity() + const resolver = plusAny.android.invoke(main, "getContentResolver") + console.log("[OTA-PAGE] SAF uri: " + plusAny.android.invoke(uri, "toString")) + + // 方式一:Scanner 按 ISO-8859-1 把整流读成 Java String(字节 1:1 映射字符), + // 字符串作为返回值过桥——byte[] 参数是按值拷贝的,read(buf) 写不回 JS 侧 + let out = [] as number[] + let scannerOk = false + try { + const is1 = plusAny.android.invoke(resolver, "openInputStream", uri) + if (is1 != null) { + const Scanner = plusAny.android.importClass("java.util.Scanner") + const sc = new Scanner(is1, "ISO-8859-1") + plusAny.android.invoke(sc, "useDelimiter", "\\A") // \A = 输入起始,即整流一个 token + const r = plusAny.android.invoke(sc, "next") + plusAny.android.invoke(sc, "close") // 会连带关闭底层流 + if (typeof r == "string" && (r as string).length > 0) { + const str = r as string + for (let i = 0; i < str.length; i++) { + out.push(str.charCodeAt(i) & 0xFF) + } + scannerOk = headMatches(out) + } + } + } catch (e2: any) { + console.log("[OTA-PAGE] scanner mode failed: " + ((e2 && e2.message) ? e2.message : "" + e2)) + } + if (scannerOk) { + console.log("[OTA-PAGE] SAF read mode: scanner whole-stream, magic ok") + } else { + // 方式二(兜底/自校验失败):重开流逐字节 read(),二进制绝对精确 + if (out.length > 0) { + console.log("[OTA-PAGE] scanner result failed magic check, fallback to per-byte") + } + console.log("[OTA-PAGE] SAF read mode: per-byte (slow, binary-exact)") + out = [] as number[] + const is2 = plusAny.android.invoke(resolver, "openInputStream", uri) + if (is2 == null) return { bytes: null, err: "openInputStream returned null (per-byte)" } + for (let guard = 0; guard < 4194304; guard++) { // 4MB 上限 + const b = plusAny.android.invoke(is2, "read") + if (typeof b != "number" || b < 0) break + out.push(b & 0xFF) + if (out.length % 16384 == 0) { + console.log("[OTA-PAGE] per-byte reading ... " + out.length + "B") + } + } + plusAny.android.invoke(is2, "close") + } + if (out.length == 0) return { bytes: null, err: "stream read 0 bytes" } + // 自检日志:长度 + 文件头 hex(合法包应以 11 BA 10 CA 开头) + let head = "" + for (let i = 0; i < Math.min(out.length, 16); i++) { + head += ("0" + out[i].toString(16)).slice(-2).toUpperCase() + " " + } + console.log("[OTA-PAGE] SAF read " + out.length + "B, head: " + head + + (headMatches(out) ? " [magic ok]" : " [MAGIC MISMATCH]")) + return { bytes: out, err: "" } + } catch (e: any) { + const msg = (e && e.message) ? e.message : "" + e + console.log("[OTA-PAGE] readContentUri error: " + msg) + return { bytes: null, err: msg } + } + // #endif + // #ifndef APP-PLUS + return { bytes: null, err: "not on App" } + // #endif +} + +async function onLoadFile() { + readFileBytes(filePath.value.trim(), (bytes) => { + processPackageBytes(bytes) + }) +} + +async function processPackageBytes(bytes: number[] | null) { + if (bytes == null) { + pkgLoaded.value = false + addLog("FAIL: could not read package file (path=" + filePath.value.trim() + ")") + uni.showToast({ title: 'Failed to read file, check the path', icon: 'error' }) + return + } + try { + pkg = McmOta.parseCombo(bytes) + } catch (e: any) { + pkgLoaded.value = false + // 诊断:打出实际读到的长度和文件头,区分"读文件错"与"格式错" + let head = "" + for (let i = 0; i < Math.min(bytes.length, 16); i++) { + head += ("0" + bytes[i].toString(16)).slice(-2).toUpperCase() + " " + } + addLog("Package rejected: " + ((e && e.message) ? e.message : "parse fail") + + " (got " + bytes.length + "B, head: " + head + ")") + uni.showToast({ title: 'Invalid OTA package', icon: 'error' }) + return + } + // 目标 = 当前运行 bank 的对侧(CUR_BANK 来自参数特征 TLV 0x07) + const cur = await McmCmd.getCurBank() + if (cur == null) { + pkgLoaded.value = false + addLog("Could not read current bank from device") + uni.showToast({ title: 'Read device bank failed', icon: 'error' }) + return + } + const target = 3 - cur + const blob = target == 1 ? pkg.bank1 : pkg.bank2 + // 传输前预检:载荷向量表 Reset PC 必须落在目标 bank 内 + const err = McmOta.checkBlobBank(blob, target) + if (err != null) { + pkgLoaded.value = false + addLog("Package rejected: " + err) + uni.showToast({ title: 'Wrong bank payload', icon: 'error' }) + return + } + targetBank.value = target + pkgVersion.value = McmOta.formatVersion(pkg.version) + pkgBank1Size.value = pkg.bank1.length + pkgBank2Size.value = pkg.bank2.length + pkgLoaded.value = true + addLog("Package loaded: version " + pkgVersion.value + + ", target APP" + target + " (" + blob.length + " bytes)") +} + +// ================= OTA ================= + +function onProgress(p: McmOta.OtaProgress) { + progress.value = Math.floor(100 * p.sent / p.total) + progressText.value = progress.value + "% (" + p.sent + "/" + p.total + " B, rtt " + + p.rttMs + " ms" + (p.resent > 0 ? ", " + p.resent + " resent" : "") + ")" +} + +async function onStartOta() { + if (pkg == null || upgrading.value) return + const blob = targetBank.value == 1 ? pkg.bank1 : pkg.bank2 + upgrading.value = true + progress.value = 0 + progressText.value = "0%" + otaMac = Ble.getConnectMac() + addLog("--- OTA start: target APP" + targetBank.value + " ---") + try { + await McmOta.runOta(blob, pkg.version, targetBank.value, { + onProgress: onProgress, + onLog: addLog + }) + } catch (e: any) { + upgrading.value = false + addLog("FAIL: " + ((e && e.message) ? e.message : "unknown error")) + uni.showToast({ title: 'Upgrade failed', icon: 'error' }) + return + } + // END 成功:设备应答后自动复位进新 bank,等待断连→回连→复核 + addLog("Waiting for the device to reboot ...") + waitRebootAndVerify() +} + +function onAbortOta() { + McmOta.abortOta() + addLog("Abort requested ...") +} + +// 等设备复位导致链路断开,然后回连(bleState 0 时复核,见 onBleStatueListener) +async function waitRebootAndVerify() { + for (let i = 0; i < 20 && Ble.isConnected(); i++) { + await delay(500) + } + if (Ble.isConnected()) { + // 断连事件一直没来:主动断开再走回连 + Ble.disConnect() + await delay(1000) + } + addLog("Reconnecting to verify ...") + verifyRetries = 0 + needVerifyOnConnect = true + ;(getApp().globalData as any).ConnectBle(otaMac, false) +} + +async function verifyAfterReboot() { + if (pkg == null) return + const info = await McmCmd.getDevInfo() + const okBank = info.curBank == targetBank.value + const okVer = info.fwVersion == pkg.version + if (okBank && okVer) { + addLog("PASS: now running APP" + info.curBank + ", firmware " + + McmCmd.formatVersion(info.fwVersion as number)) + uni.showToast({ title: 'Upgrade OK', icon: 'success' }) + } else { + addLog("FAIL: after reboot running APP" + (info.curBank ?? 0) + ", firmware " + + (info.fwVersion != null ? McmCmd.formatVersion(info.fwVersion) : "?") + + " (expected APP" + targetBank.value + ", " + McmOta.formatVersion(pkg.version) + ")") + uni.showToast({ title: 'Verify failed', icon: 'error' }) + } + upgrading.value = false + loadDeviceInfo() } // ================= 蓝牙状态监听 ================= @@ -68,8 +421,31 @@ async function loadDeviceInfo() { function onBleStatueListener(state: number) { if (state == 0) { connected.value = true - loadDeviceInfo() - } else if (state == 10000 || state == 10001) { + if (needVerifyOnConnect) { + needVerifyOnConnect = false + // 回连就绪:协商 MTU 后复核升级结果 + Ble.setMtu(247, () => { + verifyAfterReboot() + }) + } else { + loadDeviceInfo() + } + } else if (state == 10000) { + connected.value = false + if (needVerifyOnConnect && verifyRetries < 5) { + // 复位后回连失败:重试 + verifyRetries++ + setTimeout(() => { + if (needVerifyOnConnect) { + (getApp().globalData as any).ConnectBle(otaMac, false) + } + }, 2000) + } else if (needVerifyOnConnect) { + needVerifyOnConnect = false + upgrading.value = false + addLog("FAIL: reconnect to the device failed after reboot") + } + } else if (state == 10001) { connected.value = false } } @@ -135,6 +511,13 @@ onUnload(() => { padding: 20rpx 0; } +.hintText { + font-size: 22rpx; + color: #999999; + line-height: 1.5; + margin-top: 8rpx; +} + .sectionTitle { font-size: 26rpx; color: #888888; @@ -162,10 +545,59 @@ onUnload(() => { margin-left: 20rpx; } +.input { + border: 1px solid #dddddd; + border-radius: 12rpx; + padding: 12rpx 16rpx; + font-size: 24rpx; + margin-bottom: 16rpx; +} + +.btnRow { + display: flex; + flex-direction: row; + justify-content: flex-end; + margin-top: 8rpx; +} + +.btn { + background-color: #3A6FA1; + color: #ffffff; +} +.btnWarn { + background-color: #dd524d; +} + +.progressBox { + height: 16rpx; + background-color: #eeeeee; + border-radius: 8rpx; + overflow: hidden; +} + +.progressBar { + height: 16rpx; + background-color: #3A6FA1; +} + +.progressText { + font-size: 24rpx; + color: #888888; + text-align: center; + margin-top: 8rpx; +} + .warnText { font-size: 24rpx; color: #dd524d; margin-top: 12rpx; line-height: 1.5; } + +.logText { + font-size: 22rpx; + color: #333333; + font-family: monospace; + line-height: 1.5; +} diff --git a/SmartAssiter/src/BluetoothManager.ts b/SmartAssiter/src/BluetoothManager.ts index 9115ffb..c5a2d33 100644 --- a/SmartAssiter/src/BluetoothManager.ts +++ b/SmartAssiter/src/BluetoothManager.ts @@ -1,12 +1,13 @@ /** * 蓝牙管理模块(基于 uni 内置 BLE API) * - * 当前固件(mcm-ddc-ble V1.00.08+)的可用 GATT 接口: + * 当前固件(mcm-ddc-ble V1.00.23)的可用 GATT 接口: * - CLI 读写特征 ...c72e0003:写原始命令行(write-with-response,≤63B), - * 读同一特征取回输出文本(≤512B) - * - 只读参数特征 ...c72e0004:读出全部信息项 TLV(同 INFO_RSP(all)) - * 旧帧协议下行 ...c72e0001 与 notify 上行 ...c72e0002 因固件 notify 链路不通 - * 已停用,本模块不再提供 notify 订阅与帧协议写入接口。 + * 读同一特征分包取回输出文本(每片 ≤ att_mtu-2,0 长度包=结束) + * - 只读参数特征 ...c72e0004:读出全部信息项 TLV(同 INFO_RSP(all), + * 含 0x07 CUR_BANK 当前运行 bank) + * - OTA 特征 ...c72e0005:OTA 传输帧下行(write-with-response)→ + * 读同一特征取回 OTA_RSP(读需轮询至非空),见 docs/ble_protocol.md §6.6 * * 对外约定: * - MyApiResult.type == 0 表示成功 @@ -19,6 +20,7 @@ export const MCM_SERVICE_UUID = "00002760-08c2-11e1-9073-0e8ac72e1001" export const MCM_CLI_UUID = "00002760-08c2-11e1-9073-0e8ac72e0003" // 读写:写命令行 / 读输出文本 export const MCM_INFO_UUID = "00002760-08c2-11e1-9073-0e8ac72e0004" // 只读:设备参数 TLV(同 INFO_RSP 全部项) +export const MCM_OTA_UUID = "00002760-08c2-11e1-9073-0e8ac72e0005" // OTA:写传输帧 / 读 OTA_RSP(轮询至非空) export type MyApiResult = { type: number, @@ -43,64 +45,85 @@ let manualDisconnect = false let btEnabled = false // 缓存的蓝牙开关状态(由 adapter 状态监听/操作结果维护) let scanning = false let scanTimeoutTimer = 0 +let negotiatedMtu = 23 // 当前生效的 ATT MTU(默认 23,setMtu 成功后更新) let stateListenerRegistered = false let connListenerRegistered = false let scanListenerRegistered = false let valueListenerRegistered = false -// 读请求队列:uni 的读结果经 onBLECharacteristicValueChange 全局回调返回, -// 同一时间只能跟踪一个待决读请求,多余请求排队串行执行 +// 读请求按特征值各自独立排队/跟踪(CLI/参数/OTA 各通道互不影响、可并行): +// uni 的读结果经 onBLECharacteristicValueChange 全局回调返回,按 characteristicId 分发; +// 同一特征上的读串行,不同特征各管各的超时与状态 type ReadReq = { charId: string, - cb: (res: MyApiResult) => void + cb: (res: MyApiResult) => void, + retried: boolean // 10007 已重试过 } -const readQueue: ReadReq[] = [] -let readActive: ReadReq | null = null -let readTimer = 0 +const readQueues: { [key: string]: ReadReq[] } = {} +const readActiveMap: { [key: string]: ReadReq | null } = {} +const readTimers: { [key: string]: number } = {} const READ_TIMEOUT_MS = 3000 -// 注册特征值变化监听(read 结果经此回调分发,幂等) +function charKey(charId: string): string { + return charId.toUpperCase() +} + +// 注册特征值变化监听(read 结果按特征值分发到各自的待决请求,幂等) function ensureValueChangeListener() { if (valueListenerRegistered) return valueListenerRegistered = true uni.onBLECharacteristicValueChange((res: any) => { - if (readActive == null) return const charId = res.characteristicId as string - if (!uuidEq(charId, readActive.charId)) return + const key = charKey(charId) + if (readActiveMap[key] == null) return const bytes = new Uint8Array(res.value as ArrayBuffer) const arr = [] as number[] for (let i = 0; i < bytes.length; i++) { arr.push(bytes[i]) } - finishRead({ type: 0, message: "read ok", data: arr } as MyApiResult) + console.log("[MCM] rx " + charTag(charId) + " " + arr.length + "B: " + bytesHex(arr, 64)) + finishRead(key, { type: 0, message: "read ok", data: arr } as MyApiResult) }) } -function finishRead(res: MyApiResult) { - const req = readActive - readActive = null - clearTimeout(readTimer) +function finishRead(key: string, res: MyApiResult) { + const req = readActiveMap[key] + readActiveMap[key] = null + clearTimeout(readTimers[key]) if (req != null) { + if (res.type != 0) { + console.log("[MCM] rx " + charTag(req.charId) + " FAIL: [" + res.type + "] " + res.message) + } req.cb(res) } - pumpReadQueue() + pumpReadQueue(key) } -function pumpReadQueue() { - if (readActive != null) return +function pumpReadQueue(key: string) { + if (readActiveMap[key] != null) return const id = connectedDeviceId - const req = readQueue.shift() + const queue = readQueues[key] + if (queue == null) return + const req = queue.shift() if (req == null) return if (id == null) { req.cb({ type: 10001, message: "not connected", data: null } as MyApiResult) - pumpReadQueue() + pumpReadQueue(key) return } - readActive = req - readTimer = setTimeout(() => { - finishRead({ type: 10008, message: "read timeout", data: null } as MyApiResult) + // 链路稳定期内推迟发出(连接级一次性等待,不做跨特征阻塞) + const waitMs = linkReadyAt - Date.now() + if (waitMs > 0) { + queue.unshift(req) + setTimeout(() => { pumpReadQueue(key) }, waitMs) + return + } + readActiveMap[key] = req + console.log("[MCM] read " + charTag(req.charId) + " ...") + readTimers[key] = setTimeout(() => { + finishRead(key, { type: 10008, message: "read timeout", data: null } as MyApiResult) }, READ_TIMEOUT_MS) uni.readBLECharacteristicValue({ deviceId: id, @@ -108,7 +131,22 @@ function pumpReadQueue() { characteristicId: req.charId, success: () => {}, fail: (err: any) => { - finishRead(failRes(10008, err)) + const msg: string = (err && err.errMsg) ? err.errMsg : "fail" + // 链路未稳定时读也会被误报 10007 property not support:补等后重试一次 + if (!req.retried && msg.indexOf("property not support") >= 0) { + console.log("[MCM] read " + charTag(req.charId) + " 10007, wait " + + WRITE_10007_RETRY_MS + "ms and retry") + readActiveMap[key] = null + clearTimeout(readTimers[key]) + req.retried = true + setTimeout(() => { + if (readQueues[key] == null) readQueues[key] = [] as ReadReq[] + readQueues[key].unshift(req) + pumpReadQueue(key) + }, WRITE_10007_RETRY_MS) + return + } + finishRead(key, failRes(10008, err)) } }) } @@ -132,6 +170,22 @@ function uuidEq(a: string, b: string): boolean { return a.toUpperCase() == b.toUpperCase() } +// 字节数组转 hex 串(日志用,超过 maxLen 截断并标注总长度) +function bytesHex(arr: number[], maxLen: number): string { + let s = "" + const n = Math.min(arr.length, maxLen) + for (let i = 0; i < n; i++) { + s += ("0" + arr[i].toString(16)).slice(-2).toUpperCase() + " " + } + if (arr.length > maxLen) s += "..(" + arr.length + "B)" + return s +} + +// 特征 UUID 短名(日志用,如 "...E0003") +function charTag(charId: string): string { + return "..." + charId.slice(-4).toUpperCase() +} + // 注册系统蓝牙开关状态监听并同步一次当前状态(幂等) function ensureStateListener() { if (stateListenerRegistered) return @@ -154,18 +208,27 @@ function ensureConnListener() { uni.onBLEConnectionStateChange((res: any) => { if (res.connected) { connectedDeviceId = res.deviceId + console.log("[MCM] connected: " + res.deviceId) } else { + console.log("[MCM] disconnected: " + res.deviceId + (manualDisconnect ? " (manual)" : "")) const wasConnected = connectedDeviceId != null && connectedDeviceId == res.deviceId connectedDeviceId = null - // 断连后所有待决/排队读请求立即失败 - if (readActive != null) { - finishRead({ type: 10001, message: "disconnected", data: null } as MyApiResult) + negotiatedMtu = 23 + linkReadyAt = 0 + // 断连后所有特征的待决/排队读请求立即失败(逐通道清理) + const keys = [] as string[] + for (const k in readActiveMap) keys.push(k) + for (const k in readQueues) { + if (keys.indexOf(k) < 0) keys.push(k) } - while (readQueue.length > 0) { - const req = readQueue.shift() - if (req != null) { - req.cb({ type: 10001, message: "disconnected", data: null } as MyApiResult) + for (let i = 0; i < keys.length; i++) { + const k = keys[i] + if (readActiveMap[k] != null) { + finishRead(k, { type: 10001, message: "disconnected", data: null } as MyApiResult) } + // finishRead→pumpReadQueue 会以 not connected 依次排空该通道队列; + // 对在稳定期推迟的队列(无待决请求)也主动触发一次排空 + pumpReadQueue(k) } if (wasConnected && !manualDisconnect) { uni.$emit("bleState", 10001) @@ -335,15 +398,26 @@ export function scanServices(cb: (res: MyApiResult) => void) { const chars = resCh.characteristics as any[] let hasCli = false let hasInfo = false + let hasOta = false for (let i = 0; i < chars.length; i++) { const cu = chars[i].uuid as string + // 打印手机侧实际发现的特征及属性(排查 GATT 缓存/权限位用) + const props = chars[i].properties as any + console.log("[MCM] char " + cu + + " read=" + (props && props.read) + + " write=" + (props && props.write) + + " writeNoRsp=" + (props && props.writeNoResponse) + + " notify=" + (props && props.notify)) if (uuidEq(cu, MCM_CLI_UUID)) hasCli = true if (uuidEq(cu, MCM_INFO_UUID)) hasInfo = true + if (uuidEq(cu, MCM_OTA_UUID)) hasOta = true } - if (!hasCli || !hasInfo) { - cb({ type: 10005, message: "MCM cli/info characteristic not found", data: null } as MyApiResult) + if (!hasCli || !hasInfo || !hasOta) { + cb({ type: 10005, message: "MCM cli/info/ota characteristic not found", data: null } as MyApiResult) return } + // 链路稳定期:首个 GATT 读写操作会等到此刻之后发出(安卓时序兼容) + linkReadyAt = Date.now() + GATT_SETTLE_MS cb(ok("services ready")) }, fail: (err: any) => { @@ -359,38 +433,82 @@ export function scanServices(cb: (res: MyApiResult) => void) { // ================= 读写 ================= -// 写 CLI 特征(write-with-response):data 为原始命令行字节(≤63B,且不超 mtu-3) -export function writeCliChar(data: number[], cb: (res: MyApiResult) => void) { +// 各特征值的读写相互独立、可并行:不做跨特征的全局串行化。 +// 仅保留两个连接级保护:服务发现后的短暂稳定期(安卓链路未稳时 +// 读会无声丢失、写会误报 10007),以及写 10007 的延时重试。 +const GATT_SETTLE_MS = 300 // scanServices 成功后首个 GATT 操作前的稳定等待 +const WRITE_10007_RETRY_MS = 1600 // 10007 重试前的补等时长 + +let linkReadyAt = 0 // 链路就绪时刻(scanServices 成功时设置,断连清零) + +// 写特征(通用,write-with-response):data 为原始字节 +export function writeChar(charId: string, data: number[], cb: (res: MyApiResult) => void) { const id = connectedDeviceId if (id == null) { cb({ type: 10001, message: "not connected", data: null } as MyApiResult) return } - const buf = new Uint8Array(data.length) - for (let i = 0; i < data.length; i++) { - buf[i] = data[i] & 0xFF - } - uni.writeBLECharacteristicValue({ - deviceId: id, - serviceId: MCM_SERVICE_UUID, - characteristicId: MCM_CLI_UUID, - value: buf.buffer, - writeType: "write", - success: () => { - cb(ok("write ok")) - }, - fail: (err: any) => { - cb(failRes(10008, err)) + const attempt = (isRetry: boolean) => { + if (connectedDeviceId == null) { + cb({ type: 10001, message: "disconnected", data: null } as MyApiResult) + return } - }) + const waitMs = linkReadyAt - Date.now() + if (waitMs > 0) { + setTimeout(() => { attempt(isRetry) }, waitMs) + return + } + const buf = new Uint8Array(data.length) + for (let i = 0; i < data.length; i++) { + buf[i] = data[i] & 0xFF + } + uni.writeBLECharacteristicValue({ + deviceId: id, + serviceId: MCM_SERVICE_UUID, + characteristicId: charId, + value: buf.buffer, + writeType: "write", + success: () => { + console.log("[MCM] tx " + charTag(charId) + " " + data.length + "B ok: " + bytesHex(data, 48)) + cb(ok("write ok")) + }, + fail: (err: any) => { + const msg: string = (err && err.errMsg) ? err.errMsg : "fail" + // 部分安卓机型链路未稳定时误报 10007 property not support:补等后重试一次 + if (!isRetry && msg.indexOf("property not support") >= 0) { + console.log("[MCM] tx " + charTag(charId) + " 10007, wait " + + WRITE_10007_RETRY_MS + "ms and retry") + setTimeout(() => { attempt(true) }, WRITE_10007_RETRY_MS) + return + } + console.log("[MCM] tx " + charTag(charId) + " FAIL: " + msg) + cb(failRes(10008, err)) + } + }) + } + attempt(false) } -// 读特征(通用):结果经 onBLECharacteristicValueChange 回传,内部串行排队,3s 超时; +// 写 CLI 特征(write-with-response):data 为原始命令行字节(≤63B,且不超 mtu-3) +export function writeCliChar(data: number[], cb: (res: MyApiResult) => void) { + writeChar(MCM_CLI_UUID, data, cb) +} + +// 写 OTA 特征(write-with-response):data 为一帧完整 0xCA OTA 传输帧; +// 应答经 readChar(MCM_OTA_UUID) 轮询取回(读可能先返回空,需读到非空为止) +export function writeOtaChar(data: number[], cb: (res: MyApiResult) => void) { + writeChar(MCM_OTA_UUID, data, cb) +} + +// 读特征(通用):结果经 onBLECharacteristicValueChange 按 characteristicId 分发, +// 同一特征上的读串行排队,不同特征各自独立、可并行;每请求 3s 超时; // type==0 时 data 为 number[] export function readChar(charId: string, cb: (res: MyApiResult) => void) { ensureValueChangeListener() - readQueue.push({ charId: charId, cb: cb } as ReadReq) - pumpReadQueue() + const key = charKey(charId) + if (readQueues[key] == null) readQueues[key] = [] as ReadReq[] + readQueues[key].push({ charId: charId, cb: cb, retried: false } as ReadReq) + pumpReadQueue(key) } // 读取设备参数特征(只读 MCM_INFO_UUID,内容为全部信息项 TLV) @@ -412,7 +530,11 @@ export function setMtu(len: number, cb: (res: MyApiResult) => void) { deviceId: id, mtu: len, success: (res: any) => { - cb({ type: 0, message: "mtu " + res.mtu, data: null } as MyApiResult) + // 部分平台(实测有的 Android 机型/uni 版本)成功回调不带 mtu 字段: + // 此时按请求值记录(设备侧每连接也会主动发起 MTU=247 交换) + const m = res ? (res.mtu as number) : (undefined as any) + negotiatedMtu = (typeof m == "number" && m >= 23) ? m : len + cb({ type: 0, message: "mtu " + negotiatedMtu, data: null } as MyApiResult) }, fail: (err: any) => { cb(failRes(10008, err)) @@ -424,6 +546,11 @@ export function setMtu(len: number, cb: (res: MyApiResult) => void) { // #endif } +// 当前协商的 ATT MTU(未调用过 setMtu 或协商失败时为默认 23) +export function getMtu(): number { + return negotiatedMtu +} + // 读取 rssi 信号 需要蓝牙已经连接 export function readRssi(cb: (res: MyApiResult) => void) { const id = connectedDeviceId diff --git a/SmartAssiter/src/mcm/McmCli.ts b/SmartAssiter/src/mcm/McmCli.ts index bb8de60..7a0da3e 100644 --- a/SmartAssiter/src/mcm/McmCli.ts +++ b/SmartAssiter/src/mcm/McmCli.ts @@ -19,6 +19,7 @@ const CLI_RSP_DELAY_MS = 30 export type CliResult = { text: string, // 命令输出原文 status: number // 0=ok 1=未知命令/行超长 -1=超时 -2=行超长(本地拦截) -3=写入失败 -4=未连接 + errMsg: string // 底层 BLE 错误原文(uni errMsg),无错误为 "" } let cliChain: Promise = Promise.resolve() @@ -56,19 +57,22 @@ export function execCli(cmd: string, timeoutMs: number = 5000): Promise { if (!Ble.isConnected()) { - return { text: "", status: -4 } as CliResult + return { text: "", status: -4, errMsg: "not connected" } as CliResult } const bytes = toBytes(cmd) if (bytes.length == 0 || bytes.length > CLI_CMD_MAX) { - return { text: "", status: -2 } as CliResult + return { text: "", status: -2, errMsg: "line too long (>63B)" } as CliResult } + console.log("[CLI] >> " + cmd) const op = (async (): Promise => { const wr = await new Promise((resolve) => { Ble.writeCliChar(bytes, (res: MyApiResult) => { resolve(res) }) }) if (wr.type != 0) { - return { text: "", status: wr.type == 10001 ? -4 : -3 } as CliResult + const st = wr.type == 10001 ? -4 : -3 + console.log("[CLI] << FAIL write: [" + wr.type + "] " + wr.message) + return { text: "", status: st, errMsg: "write: [" + wr.type + "] " + wr.message } as CliResult } await delay(CLI_RSP_DELAY_MS) // 分包读:每次读返回下一片,0 长度包 = 应答结束 @@ -79,7 +83,8 @@ async function doExecCli(cmd: string, timeoutMs: number): Promise { }) if (rd.type != 0) { const st = rd.type == 10001 ? -4 : -1 - return { text: "", status: st } as CliResult + console.log("[CLI] << FAIL read: [" + rd.type + "] " + rd.message) + return { text: "", status: st, errMsg: "read: [" + rd.type + "] " + rd.message } as CliResult } const arr = rd.data as number[] if (arr.length == 0) { @@ -90,12 +95,17 @@ async function doExecCli(cmd: string, timeoutMs: number): Promise { const text = toText(all) // 固件对无输出的成功命令回 "ok\r\n",对未知命令/超长行回 "unknown command\r\n" const status = text == "unknown command\r\n" ? 1 : 0 - return { text: text, status: status } as CliResult + console.log("[CLI] << (" + all.length + "B, status=" + status + ") " + JSON.stringify(text)) + return { text: text, status: status, errMsg: "" } as CliResult })() // 总超时兜底:超时后底层读请求自行收尾(读队列内部有独立超时) - return await Promise.race([ + const r = await Promise.race([ op, - delay(timeoutMs).then(() => ({ text: "", status: -1 } as CliResult)) + delay(timeoutMs).then(() => ({ text: "", status: -1, errMsg: "overall timeout" } as CliResult)) ]) + if (r.status == -1 && r.errMsg == "overall timeout") { + console.log("[CLI] << FAIL overall timeout (" + timeoutMs + "ms)") + } + return r } diff --git a/SmartAssiter/src/mcm/McmCommands.ts b/SmartAssiter/src/mcm/McmCommands.ts index 414f8d4..ca90d94 100644 --- a/SmartAssiter/src/mcm/McmCommands.ts +++ b/SmartAssiter/src/mcm/McmCommands.ts @@ -1,21 +1,36 @@ /** * CAIIC-MCM 业务命令封装(读写特征方式,不依赖 notify) - * 固件版本走只读参数特征(McmInfo.readDeviceInfo); - * sysinfo/bankinfo 等系统状态走 CLI 特征透传,命令名集中在顶部常量。 + * 固件版本/当前 bank 走只读参数特征(McmInfo.readDeviceInfo); + * sysinfo/devinfo/appget/appset 等走 CLI 特征透传,命令名集中在顶部常量。 + * + * 固件 CLI 命令表(V1.00.23):help/version/sysinfo/devinfo/blelog/led/ + * appget/appset/bsdump/bsset/appsw/ota/reset/factory/uartrst/uartinfo */ import { execCli, CliResult } from "./McmCli" -import { readDeviceInfo, formatVersion } from "./McmInfo" +import { readDeviceInfo, formatVersion, DeviceInfo } from "./McmInfo" const CMD_SYSINFO = "sysinfo" -const CMD_BANKINFO = "bankinfo" +const CMD_DEVINFO = "devinfo" +const CMD_APPGET_JSON = "appget json" -/** 固件版本号,如 "V1.00.08"(参数特征 TLV 的 FW_VERSION 项);失败返回 null */ +/** 固件版本号,如 "V1.00.23"(参数特征 TLV 的 FW_VERSION 项);失败返回 null */ export async function getVersion(): Promise { const info = await readDeviceInfo() const v = info.fwVersion return v != null ? formatVersion(v) : null } +/** 当前运行 bank:1=APP1 2=APP2(参数特征 TLV 的 CUR_BANK 项);失败返回 null */ +export async function getCurBank(): Promise { + const info = await readDeviceInfo() + return info.curBank +} + +/** 设备信息全项(温度/电压/风扇/运行时长/堆剩余/版本/bank),失败字段为 null */ +export async function getDevInfo(): Promise { + return await readDeviceInfo() +} + /** 系统运行状态原文(运行时间/任务数/heap/任务表),失败返回 null */ export async function getSysInfo(): Promise { const r: CliResult = await execCli(CMD_SYSINFO, 5000) @@ -23,12 +38,41 @@ export async function getSysInfo(): Promise { return r.text } -/** bank 信息原文(当前运行 bank 与 bootsetting),失败返回 null */ -export async function getBankInfo(): Promise { - const r: CliResult = await execCli(CMD_BANKINFO) +/** 设备信息原文(devinfo CLI 输出),失败返回 null */ +export async function getDevInfoText(): Promise { + const r: CliResult = await execCli(CMD_DEVINFO, 5000) if (r.status != 0) return null return r.text } -export { execCli } -export type { CliResult } +// ================= APP_DATA 参数(led/pwm) ================= + +export type AppParams = { + led: number, // LED1 闪烁半周期 ms(50..10000) + pwm: number, // PWM 占空比 %(0..100) + valid: number // 1=记录有效 0=缺省值(未落盘) +} + +/** 读 APP_DATA 参数(appget json → {"led":500,"pwm":0,"valid":1}),失败返回 null */ +export async function getAppParams(): Promise { + const r: CliResult = await execCli(CMD_APPGET_JSON, 5000) + if (r.status != 0) return null + try { + const o = JSON.parse(r.text.trim()) + if (typeof o.led != "number" || typeof o.pwm != "number") return null + return { led: o.led, pwm: o.pwm, valid: typeof o.valid == "number" ? o.valid : 0 } as AppParams + } catch (e) { + console.log("[CMD] appget json parse fail: " + JSON.stringify(r.text)) + return null + } +} + +/** 写 APP_DATA 参数并落盘(appset ,成功回 " = , saved") */ +export async function setAppParam(key: string, val: number): Promise { + const r: CliResult = await execCli("appset " + key + " " + val, 5000) + if (r.status != 0) return false + return r.text.indexOf("saved") >= 0 +} + +export { execCli, readDeviceInfo, formatVersion } +export type { CliResult, DeviceInfo } diff --git a/SmartAssiter/src/mcm/McmInfo.ts b/SmartAssiter/src/mcm/McmInfo.ts index d871db6..f0d25f5 100644 --- a/SmartAssiter/src/mcm/McmInfo.ts +++ b/SmartAssiter/src/mcm/McmInfo.ts @@ -14,6 +14,7 @@ export const INFO_FAN_RPM = 0x03 // u16 风扇转速 rpm(0xFFFF = 无此硬 export const INFO_VDD_MV = 0x04 // u16 电源电压 mV(0 = 读取失败) export const INFO_UPTIME_S = 0x05 // u32 系统运行时间 s export const INFO_FREE_HEAP = 0x06 // u32 FreeRTOS 堆剩余 B +export const INFO_CUR_BANK = 0x07 // u8 当前运行 bank(1=APP1 2=APP2),V1.00.16 起 export type DeviceInfo = { fwVersion: number | null, // 原始 u32 版本号 @@ -21,7 +22,8 @@ export type DeviceInfo = { fanRpm: number | null, // 风扇转速 rpm;null = 无硬件/未应答 vddMv: number | null, // 电源电压 mV uptimeS: number | null, // 运行时间 s - freeHeap: number | null // 堆剩余 B + freeHeap: number | null, // 堆剩余 B + curBank: number | null // 当前运行 bank:1=APP1 2=APP2 } function emptyInfo(): DeviceInfo { @@ -31,7 +33,8 @@ function emptyInfo(): DeviceInfo { fanRpm: null, vddMv: null, uptimeS: null, - freeHeap: null + freeHeap: null, + curBank: null } as DeviceInfo } @@ -72,6 +75,9 @@ export function parseTlv(payload: number[]): DeviceInfo { info.uptimeS = readU32LE(payload, off) } else if (id == INFO_FREE_HEAP && len == 4) { info.freeHeap = readU32LE(payload, off) + } else if (id == INFO_CUR_BANK && len == 1) { + const raw = payload[off] + info.curBank = (raw == 1 || raw == 2) ? raw : null } i += 2 + len } @@ -88,8 +94,13 @@ export async function readDeviceInfo(): Promise { resolve(res.type == 0 ? res.data as number[] : null) }) }) - if (payload == null) return emptyInfo() - return parseTlv(payload) + if (payload == null) { + console.log("[INFO] read failed") + return emptyInfo() + } + const info = parseTlv(payload) + console.log("[INFO] TLV " + payload.length + "B parsed: " + JSON.stringify(info)) + return info } /** 固件版本号 u32 格式化,如 0x00010001 → "V1.00.01" */ diff --git a/SmartAssiter/src/mcm/McmOta.ts b/SmartAssiter/src/mcm/McmOta.ts new file mode 100644 index 0000000..f601ca3 --- /dev/null +++ b/SmartAssiter/src/mcm/McmOta.ts @@ -0,0 +1,364 @@ +/** + * CAIIC-MCM BLE OTA(OTA 特征 ...c72e0005,锁步传输) + * + * 协议(docs/ble_protocol.md §6.6,固件 V1.00.19+): + * - 0xCA 帧:SOF | TYPE | SEQ | LEN(LE16) | PAYLOAD | CRC16-CCITT(0x1021/0xFFFF, LE) + * CRC16 覆盖 TYPE..PAYLOAD(4+payLen 字节) + * - 每写一帧(write-with-response)后**读同一特征**取回 OTA_RSP; + * 读可能先于设备处理完成返回空,需轮询至非空 + * - 锁步:必须等到上一帧的 OTA_RSP 再发下一帧;ack 中 offset 为设备期望的 + * 下一字节。丢帧/丢 ack 时重发同一帧:重复 BEGIN 设备回 BAD_STATE(视为 + * 会话已建立),重复 DATA 回 BAD_STATE+期望 offset(若恰好等于本帧结束 + * 位置说明设备早已收到,视为 ack 继续) + * - OTA_END 校验通过后设备先应答、等主机取走应答后复位进入新 bank + * + * 升级包:mothercup_ble_ota.bin 单文件(52B 头 + bank1 载荷 + bank2 载荷, + * 头带 version/total_size/payload_crc/各 bank off+size+crc + hdr_crc,全小端) + */ +import * as Ble from "../BluetoothManager" +import { MyApiResult } from "../BluetoothManager" + +// 帧类型 +const TYPE_OTA_BEGIN = 0x10 +const TYPE_OTA_DATA = 0x11 +const TYPE_OTA_END = 0x12 +const TYPE_OTA_ABORT = 0x13 +const TYPE_OTA_RSP = 0x1F + +// OTA_RSP 状态码 +const ST_OK = 0 +const ST_BAD_STATE = 2 + +// combo 包头 +const COMBO_MAGIC = 0xCA10BA11 +const COMBO_HDR_LEN = 52 + +const RSP_TIMEOUT_MS = 2000 // 单帧 ack 超时(扇区擦除约 +50ms,足够) +const XFER_TRIES = 3 // 丢帧重发次数 +const RSP_POLL_MS = 20 // 读应答轮询间隔 + +export type ComboPackage = { + version: number, // 包版本号 0x00MMmmpp + bank1: number[], // APP1 链接载荷 + bank2: number[] // APP2 链接载荷 +} + +export type OtaProgress = { + sent: number, + total: number, + rttMs: number, + resent: number, + resynced: number +} + +export type OtaCallbacks = { + onProgress: (p: OtaProgress) => void, + onLog: (msg: string) => void +} + +// ================= CRC ================= + +let crc32Table: number[] | null = null + +/** CRC32-IEEE(同 zlib.crc32 / 固件 caiic_crc32) */ +export function crc32(data: number[], start: number = 0, len: number = -1): number { + if (crc32Table == null) { + crc32Table = [] + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) { + c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1) + } + crc32Table.push(c >>> 0) + } + } + if (len < 0) len = data.length - start + let crc = 0xFFFFFFFF + for (let i = start; i < start + len; i++) { + crc = (crc32Table[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8)) >>> 0 + } + return (crc ^ 0xFFFFFFFF) >>> 0 +} + +/** CRC16-CCITT(poly 0x1021,init 0xFFFF),同固件 proto_crc16 */ +function crc16(data: number[], start: number, len: number): number { + let crc = 0xFFFF + for (let i = start; i < start + len; i++) { + crc ^= (data[i] << 8) & 0xFFFF + for (let b = 0; b < 8; b++) { + crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xFFFF : (crc << 1) & 0xFFFF + } + } + return crc +} + +// ================= 小工具 ================= + +function u32le(b: number[], off: number): number { + return (b[off] | (b[off + 1] << 8) | (b[off + 2] << 16) | (b[off + 3] << 24)) >>> 0 +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { setTimeout(() => { resolve() }, ms) }) +} + +function hex(v: number): string { + return "0x" + ("00000000" + v.toString(16)).slice(-8).toUpperCase() +} + +/** 固件版本号 u32 格式化,0x00010017 → "V1.00.23" */ +export function formatVersion(v: number): string { + const major = Math.floor(v / 65536) % 256 + const minor = Math.floor(v / 256) % 256 + const patch = v % 256 + return "V" + major + "." + (minor < 10 ? "0" + minor : minor) + "." + (patch < 10 ? "0" + patch : patch) +} + +// ================= 升级包解析 ================= + +/** + * 解析 mothercup_ble_ota.bin 单文件升级包;格式非法抛出 Error。 + * 头 52B(13×u32 LE):magic | hdr_len | version | total_size | payload_crc | + * count(=2) | b1_off | b1_size | b1_crc | b2_off | b2_size | b2_crc | hdr_crc + */ +export function parseCombo(data: number[]): ComboPackage { + if (data.length < COMBO_HDR_LEN) throw new Error("package too small") + const magic = u32le(data, 0) + const hdrLen = u32le(data, 4) + const version = u32le(data, 8) + const totalSize = u32le(data, 12) + const payloadCrc = u32le(data, 16) + const count = u32le(data, 20) + if (magic != COMBO_MAGIC || hdrLen != COMBO_HDR_LEN || count != 2) { + throw new Error("bad package: magic/hdr_len/count mismatch (not a mothercup_ble_ota.bin)") + } + if (crc32(data, 0, 48) != u32le(data, 48)) { + throw new Error("bad package: header crc mismatch") + } + if (data.length != totalSize) { + throw new Error("bad package: file size " + data.length + " != header total " + totalSize) + } + if (crc32(data, hdrLen, data.length - hdrLen) != payloadCrc) { + throw new Error("bad package: payload crc mismatch") + } + const out = { version: version, bank1: [] as number[], bank2: [] as number[] } + for (let bi = 0; bi < 2; bi++) { + const off = u32le(data, 24 + bi * 12) + const size = u32le(data, 28 + bi * 12) + const crc = u32le(data, 32 + bi * 12) + if (off + size > data.length) throw new Error("bad package: bank" + (bi + 1) + " out of range") + const blob = data.slice(off, off + size) + if (crc32(blob) != crc) throw new Error("bad package: bank" + (bi + 1) + " payload crc mismatch") + if (bi == 0) out.bank1 = blob; else out.bank2 = blob + } + return out +} + +const BANK_BASE = [0, 0x01008000, 0x01024000] + +/** + * 传输前预检:载荷向量表 Reset PC 必须落在目标 bank 地址范围内 + * (设备在 OTA_END 还会再查一次 bank_mismatch;此处提前拦截选错包) + */ +export function checkBlobBank(blob: number[], target: number): string | null { + const pc = u32le(blob, 4) + const base = BANK_BASE[target] + if (pc < base + 8 || pc >= base + blob.length) { + return "payload reset PC " + hex(pc) + " is not inside APP" + target + + " (" + hex(base) + "..) - wrong bank payload" + } + return null +} + +// ================= 帧编解码 ================= + +let frameSeq = 0 + +function encodeFrame(ftype: number, payload: number[]): number[] { + const out = [0xCA, ftype & 0xFF, frameSeq & 0xFF, + payload.length & 0xFF, (payload.length >> 8) & 0xFF] + for (let i = 0; i < payload.length; i++) out.push(payload[i]) + const c = crc16(out, 1, 4 + payload.length) + out.push(c & 0xFF, (c >> 8) & 0xFF) + frameSeq = (frameSeq + 1) & 0xFF + return out +} + +/** 解析一帧 OTA_RSP:{echo, status, offset};帧非法返回 null */ +function decodeRsp(frame: number[]): { echo: number, status: number, offset: number } | null { + if (frame.length < 5 + 6 + 2) return null + if (frame[0] != 0xCA || frame[1] != TYPE_OTA_RSP) return null + const plen = frame[3] | (frame[4] << 8) + if (5 + plen + 2 != frame.length) return null + const c = crc16(frame, 1, 4 + plen) + if ((frame[5 + plen] | (frame[5 + plen + 1] << 8)) != c) return null + return { echo: frame[5], status: frame[6], offset: u32le(frame, 7) } +} + +// ================= 锁步会话 ================= + +let abortRequested = false + +/** 请求中止当前升级(当前帧往返结束后发 OTA_ABORT 并抛出 aborted) */ +export function abortOta() { + abortRequested = true +} + +function writeOta(frame: number[]): Promise { + return new Promise((resolve) => { Ble.writeOtaChar(frame, (res: MyApiResult) => { resolve(res) }) }) +} + +function readOta(): Promise { + return new Promise((resolve) => { Ble.readChar(Ble.MCM_OTA_UUID, (res: MyApiResult) => { resolve(res) }) }) +} + +/** 写一帧并轮询读回应答;超时抛 Error("timeout") */ +async function xfer(frame: number[]): Promise<{ echo: number, status: number, offset: number }> { + const tname = frameTypeName(frame[1]) + if (frame[1] != TYPE_OTA_DATA) { + console.log("[OTA] tx " + tname + " seq=" + frame[2] + " " + frame.length + "B: " + frameHex(frame)) + } + const wr = await writeOta(frame) + if (wr.type != 0) throw new Error(wr.type == 10001 ? "disconnected" : "write fail: " + wr.message) + const deadline = Date.now() + RSP_TIMEOUT_MS + for (;;) { + const rd = await readOta() + if (rd.type == 0) { + const arr = rd.data as number[] + if (arr.length > 0) { + const rsp = decodeRsp(arr) + if (rsp != null) { + console.log("[OTA] rx RSP echo=" + frameTypeName(rsp.echo) + + " status=" + rsp.status + " offset=" + rsp.offset + ": " + frameHex(arr)) + return rsp + } + throw new Error("bad RSP frame: " + frameHex(arr)) + } + } else if (rd.type == 10001) { + throw new Error("disconnected") + } + if (Date.now() > deadline) throw new Error("timeout") + await delay(RSP_POLL_MS) + } +} + +function frameTypeName(t: number): string { + switch (t) { + case TYPE_OTA_BEGIN: return "BEGIN" + case TYPE_OTA_DATA: return "DATA" + case TYPE_OTA_END: return "END" + case TYPE_OTA_ABORT: return "ABORT" + case TYPE_OTA_RSP: return "RSP" + } + return "0x" + ("0" + t.toString(16)).slice(-2).toUpperCase() +} + +function frameHex(frame: number[]): string { + let s = "" + for (let i = 0; i < frame.length; i++) { + s += ("0" + frame[i].toString(16)).slice(-2).toUpperCase() + " " + } + return s +} + +/** + * 跑一次完整 OTA:BEGIN -> DATA* -> END(设备随后自动复位进新 bank)。 + * 成功 resolve;失败 reject(Error)。中断用 abortOta()。 + */ +export async function runOta(blob: number[], version: number, target: number, + cb: OtaCallbacks): Promise { + abortRequested = false + const prog = { sent: 0, total: blob.length, rttMs: 0, resent: 0, resynced: 0 } as OtaProgress + + // 单帧锁步往返(带重发与 BAD_STATE 再同步) + async function call(ftype: number, payload: number[], ackOff: number): Promise<{ status: number, offset: number }> { + const stage = ftype == TYPE_OTA_BEGIN ? "BEGIN" : ftype == TYPE_OTA_DATA ? "DATA" : + ftype == TYPE_OTA_END ? "END" : "ABORT" + for (let attempt = 1; attempt <= XFER_TRIES; attempt++) { + try { + const rsp = await xfer(encodeFrame(ftype, payload)) + if (rsp.echo != ftype) throw new Error(stage + ": RSP echo mismatch") + // 重复 BEGIN:会话已在设备上建立(首次 ack 丢了) + if (ftype == TYPE_OTA_BEGIN && rsp.status == ST_BAD_STATE) { + prog.resynced++ + cb.onLog("BEGIN: session already open on device, resynced") + return { status: ST_OK, offset: rsp.offset } + } + // 重复 DATA:设备早已收下本块(它的 ack 丢了) + if (ftype == TYPE_OTA_DATA && rsp.status == ST_BAD_STATE && rsp.offset == ackOff) { + prog.resynced++ + cb.onLog("DATA: chunk already stored (ack lost), resynced @" + rsp.offset) + return { status: ST_OK, offset: rsp.offset } + } + return { status: rsp.status, offset: rsp.offset } + } catch (e: any) { + const msg = (e && e.message) ? e.message : "fail" + if (msg == "timeout" && attempt < XFER_TRIES) { + prog.resent++ + cb.onLog(stage + ": ack timeout, resending (attempt " + (attempt + 1) + "/" + XFER_TRIES + ")") + continue + } + throw new Error(stage + ": " + (msg == "timeout" ? "no OTA_RSP after " + XFER_TRIES + " tries" : msg)) + } + } + throw new Error(stage + ": unexpected") + } + + // 帧长:min(ATT 写上限(mtu-3) - 帧开销(11), 222);mtu 异常时取保守值 + const mtu = Ble.getMtu() + const chunk = (typeof mtu == "number" && !isNaN(mtu) && mtu >= 23) ? + Math.min(mtu - 3 - 11, 222) : 222 + + // BEGIN + cb.onLog("OTA_BEGIN: open session (" + blob.length + " bytes, crc32=" + hex(crc32(blob)) + ")") + let t0 = Date.now() + let r = await call(TYPE_OTA_BEGIN, [ + blob.length & 0xFF, (blob.length >> 8) & 0xFF, (blob.length >> 16) & 0xFF, (blob.length >>> 24) & 0xFF, + crc32(blob) & 0xFF, (crc32(blob) >> 8) & 0xFF, (crc32(blob) >> 16) & 0xFF, (crc32(blob) >>> 24) & 0xFF, + version & 0xFF, (version >> 8) & 0xFF, (version >> 16) & 0xFF, (version >>> 24) & 0xFF + ], 0) + if (r.status != ST_OK) throw new Error("OTA_BEGIN rejected, status=" + r.status) + cb.onLog("OTA_BEGIN ok (" + (Date.now() - t0) + " ms), streaming data ...") + + // DATA + t0 = Date.now() + while (prog.sent < blob.length) { + if (abortRequested) { + try { await xfer(encodeFrame(TYPE_OTA_ABORT, [])) } catch (e) {} + throw new Error("aborted by user") + } + const n = Math.min(chunk, blob.length - prog.sent) + const payload = [prog.sent & 0xFF, (prog.sent >> 8) & 0xFF, + (prog.sent >> 16) & 0xFF, (prog.sent >>> 24) & 0xFF].concat(blob.slice(prog.sent, prog.sent + n)) + const tf = Date.now() + r = await call(TYPE_OTA_DATA, payload, prog.sent + n) + prog.rttMs = Date.now() - tf + if (r.status != ST_OK) { + throw new Error("OTA_DATA rejected at " + prog.sent + ", status=" + r.status + + " (device expects offset " + r.offset + ")") + } + prog.sent += n + cb.onProgress(prog) + } + + // END(设备校验 + 更新 bootsetting 后自动复位;应答可能在复位前送达) + cb.onLog("OTA_END: verify image + switch bank ...") + try { + r = await call(TYPE_OTA_END, [ + crc32(blob) & 0xFF, (crc32(blob) >> 8) & 0xFF, + (crc32(blob) >> 16) & 0xFF, (crc32(blob) >>> 24) & 0xFF + ], 0) + } catch (e: any) { + // END 的传输级失败(超时/断连)可能是设备已复位——由调用方的 + // 回连复核给出最终结论(与 PC 工具一致),此处不当失败处理 + cb.onLog("OTA_END ack lost (" + ((e && e.message) ? e.message : "?") + + ") - waiting for reboot anyway ...") + r = { status: ST_OK, offset: blob.length } + } + if (r.status != ST_OK) { + // 设备明确拒绝(CRC 错/选错 bank)才是真失败 + throw new Error("OTA_END rejected, status=" + r.status + " (4=crc_fail, 6=bank_mismatch)") + } + cb.onLog("OTA_END done (" + ((Date.now() - t0) / 1000).toFixed(1) + "s, " + + prog.resent + " resent, " + prog.resynced + " resynced), device reboots into APP" + target + " ...") +}