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
This commit is contained in:
evan.liu 2026-09-05 06:45:04 +08:00
parent ead16ef671
commit 3e87d333cb
11 changed files with 1400 additions and 125 deletions

View File

@ -7,7 +7,7 @@
1. **设备固件** — 基于国民技术(Nations)**N32WB031 BLE SoC**(Cortex-M0,64MHz HSI,**板载硅片实测 256KB Flash**(0x01000000~0x0103FFFF,开发日志 §37),RAM 48KB+16KB): 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/` 主工程,**已删除**,其功能已全部并入本工程。 - **`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。 - **`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 蓝牙测试客户端。 3. **PC 工具 `tools/`** — 整片烧录包制作/烧录脚本 + bleak 蓝牙测试客户端。
关键文档: 关键文档:
@ -127,7 +127,7 @@ mcm-ddc-ble/
| `...c72ee004` | Read | **只读参数特征**:读出全部信息项 TLV(同 INFO_RSP(all)),V1.00.04 新增 | | `...c72ee004` | Read | **只读参数特征**:读出全部信息项 TLV(同 INFO_RSP(all)),V1.00.04 新增 |
| `...c72ee005` | Read + Write(带响应) | **OTA 专用特征**(V1.00.19 新增):写携带一帧 OTA 传输帧 → 读同一特征取回 OTA_RSP(轮询至非空),见 ble_protocol.md §6.6 | | `...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。 - 帧格式:`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/包,协议按字节流重组,两种情形都正确。 - 连接后设备主动发起 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 选包依据)。 - 信息项 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 **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`)。 - 由 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/BluetoothManager.ts` — 蓝牙管理核心(扫描/连接/服务发现/读写/MTU/RSSI;**读写按特征值独立通道**:读请求经 `onBLECharacteristicValueChange` 按 characteristicId 分发到各特征自己的队列/超时(3s),写无跨特征阻塞;链路未稳误报的 10007 读/写各延时重试一次;连接状态经 `uni.$emit("bleState", state)` 广播,0=已连接可用)
- `src/mcm/` — 设备业务层:`McmCli.ts`(CLI 特征写命令/分包读应答——读到 0 长度包止,Promise 链串行)、`McmInfo.ts`(参数特征 TLV 解析)、`McmCommands.ts`(业务命令封装) - `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(设备连接+参数展示)、ota(升级页,**当前停用**并显示原因说明)、my;`pages/bleTest/bluetoothOperation/`(CLI 终端页);login/register/displayUserInfo 为云端账号业务 - `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 记忆) - `api/` — 云端 HTTP 封装(`httpInstance.ts` 含服务器列表 SERVER_LIST,正式/测试/DEBUG 三地址,token 鉴权);`common/auth.ts`(token 本地存储)、`common/bleInfo.ts`(已连设备名/MAC 记忆)
- `uni_modules/` 仅 uni-icons、uni-scss(官方组件,无第三方插件) - `uni_modules/` 仅 uni-icons、uni-scss(官方组件,无第三方插件)
- App 侧错误码约定(BluetoothManager):0=成功,10000=连接失败,10001=已断开,10002=蓝牙未开,10003=无权限,10004/10005=服务/特征缺失,10008=读写/MTU 失败或超时。 - App 侧错误码约定(BluetoothManager):0=成功,10000=连接失败,10001=已断开,10002=蓝牙未开,10003=无权限,10004/10005=服务/特征缺失,10008=读写/MTU 失败或超时。

View File

@ -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 <led|pwm> <val>`,按 "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。

View File

@ -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 <特征短名> <长度> <hex>`(截断标注总长)、
读发起/超时、10007 重试提示
实测根因记录:home 页曾在 `bleState=0` 时**并发**调 `setMtu` 与读 e0004,
读被安卓无声丢弃(3s 超时),后续写全部被拖进 10007;通道隔离 + 时序修正后消失。
### src/mcm/McmCli.ts
- 日志:`[CLI] >> 命令` / `[CLI] << (NB, status=0) <JSON 化应答>`,失败带底层
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 的实机验证项随本轮一并闭环

View File

@ -155,7 +155,10 @@ async function onSendCmd() {
} else if (r.status == 1) { } else if (r.status == 1) {
pushMsg("message", "Unknown command or line too long") pushMsg("message", "Unknown command or line too long")
} else { } 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) : ""))
} }
} }

View File

@ -19,17 +19,25 @@
<!-- 参数显示 --> <!-- 参数显示 -->
<view class="card paramCard" v-if="connected"> <view class="card paramCard" v-if="connected">
<view class="paramItem">
<text class="paramLabel">Auto refresh (2s)</text>
<switch :checked="autoRefresh" @change="onAutoRefreshChange" style="transform:scale(0.7)" />
</view>
<view class="paramItem"> <view class="paramItem">
<text class="paramLabel">Temperature</text> <text class="paramLabel">Temperature</text>
<text class="paramValue">{{ temperature }}<text class="paramUnit"> ℃</text></text> <text class="paramValue">{{ temperature }}<text class="paramUnit"> ℃</text></text>
</view> </view>
<view class="paramItem">
<text class="paramLabel">Voltage</text>
<text class="paramValue">{{ voltage }}<text class="paramUnit"> mV</text></text>
</view>
<view class="paramItem"> <view class="paramItem">
<text class="paramLabel">Fan Speed</text> <text class="paramLabel">Fan Speed</text>
<text class="paramValue">{{ fanSpeed }}<text class="paramUnit"> RPM</text></text> <text class="paramValue">{{ fanSpeed }}<text class="paramUnit"> RPM</text></text>
</view> </view>
</view> </view>
<!-- 设备信息 --> <!-- 设备信息(devinfo:版本/bank/运行时长/堆剩余) -->
<view class="card" v-if="connected"> <view class="card" v-if="connected">
<view class="infoRow"> <view class="infoRow">
<text class="infoLabel">Firmware</text> <text class="infoLabel">Firmware</text>
@ -39,6 +47,29 @@
<text class="infoLabel">Bank</text> <text class="infoLabel">Bank</text>
<text class="infoValue">{{ bankInfo }}</text> <text class="infoValue">{{ bankInfo }}</text>
</view> </view>
<view class="infoRow">
<text class="infoLabel">Uptime</text>
<text class="infoValue">{{ uptime }}</text>
</view>
<view class="infoRow">
<text class="infoLabel">Free Heap</text>
<text class="infoValue">{{ freeHeap }}</text>
</view>
</view>
<!-- 设备参数(APP_DATA:appget/appset) -->
<view class="card" v-if="connected">
<text class="consoleTitle">Params (APP_DATA{{ paramValid ? '' : ', defaults not saved' }})</text>
<view class="paramSetRow">
<text class="infoLabel">LED blink (ms)</text>
<input class="paramInput" type="number" v-model="paramLed" :disabled="paramBusy" />
<button class="btn" size="mini" :disabled="paramBusy" @tap="onSetParam('led')">Set</button>
</view>
<view class="paramSetRow">
<text class="infoLabel">PWM duty (%)</text>
<input class="paramInput" type="number" v-model="paramPwm" :disabled="paramBusy" />
<button class="btn" size="mini" :disabled="paramBusy" @tap="onSetParam('pwm')">Set</button>
</view>
</view> </view>
<!-- sysinfo 控制台输出(原 System Status 页内容) --> <!-- sysinfo 控制台输出(原 System Status 页内容) -->
@ -91,9 +122,18 @@ const deviceName = ref<string>("")
const deviceMac = ref<string>("") const deviceMac = ref<string>("")
const temperature = ref<string>("--") const temperature = ref<string>("--")
const fanSpeed = ref<string>("--") const fanSpeed = ref<string>("--")
const voltage = ref<string>("--")
const fwVersion = ref<string>("--") const fwVersion = ref<string>("--")
const bankInfo = ref<string>("--") const bankInfo = ref<string>("--")
const uptime = ref<string>("--")
const freeHeap = ref<string>("--")
const sysInfo = ref<string>("--") const sysInfo = ref<string>("--")
const paramLed = ref<string>("")
const paramPwm = ref<string>("")
const paramValid = ref<boolean>(false)
const paramBusy = ref<boolean>(false)
// 定时查询开关:缺省关闭,避免轮询占用 GATT 干扰 OTA/CLI 等操作
const autoRefresh = ref<boolean>(false)
let pollTimer = 0 let pollTimer = 0
let pageVisible = false let pageVisible = false
@ -102,12 +142,17 @@ let pageVisible = false
async function pollParams() { async function pollParams() {
if (!connected.value || !pageVisible) return 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 info = await McmInfo.readDeviceInfo()
const t = info.chipTemp const t = info.chipTemp
temperature.value = t != null ? t.toFixed(1) : "--" temperature.value = t != null ? t.toFixed(1) : "--"
const f = info.fanRpm const f = info.fanRpm
fanSpeed.value = f != null ? f.toString() : "N/A" 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() { function stopPolling() {
@ -122,39 +167,85 @@ function startPolling() {
// 注意:uts 的 setTimeout 不接受 async 回调,用 then 链式调度 // 注意:uts 的 setTimeout 不接受 async 回调,用 then 链式调度
pollTimer = setTimeout(() => { pollTimer = setTimeout(() => {
pollParams().then(() => { pollParams().then(() => {
if (connected.value && pageVisible) { if (connected.value && pageVisible && autoRefresh.value) {
startPolling() startPolling()
} }
}) })
}, POLL_INTERVAL) }, 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() { async function loadDeviceInfo() {
const v = await McmCmd.getVersion() // 版本/bank 走参数特征 TLV(devinfo 全项)
if (v != null) fwVersion.value = v const info = await McmCmd.getDevInfo()
const b = await McmCmd.getBankInfo() if (info.fwVersion != null) fwVersion.value = McmCmd.formatVersion(info.fwVersion)
if (b != null) { if (info.curBank != null) bankInfo.value = "APP" + info.curBank
// bankinfo 原文较长,取首个 "APP1/APP2" 关键词展示 if (info.uptimeS != null) uptime.value = info.uptimeS + " s"
bankInfo.value = b.indexOf("APP2") >= 0 ? "APP2" : (b.indexOf("APP1") >= 0 ? "APP1" : b) if (info.freeHeap != null) freeHeap.value = info.freeHeap + " B"
} // sysinfo(FreeRTOS 运行状态)走 CLI 透传
const s = await McmCmd.getSysInfo() const s = await McmCmd.getSysInfo()
sysInfo.value = s != null ? s : "--" 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() { function onConnectedReady() {
Ble.setMtu(247, (resMtu: MyApiResult) => {
console.log("[MCM] setMtu 247: type=" + resMtu.type + " msg=" + resMtu.message)
})
connected.value = true connected.value = true
connecting.value = false connecting.value = false
deviceMac.value = Ble.getConnectMac() deviceMac.value = Ble.getConnectMac()
Ble.setMtu(247, (resMtu: MyApiResult) => {
console.log("[MCM] setMtu 247: type=" + resMtu.type + " msg=" + resMtu.message)
loadDeviceInfo() loadDeviceInfo()
// 缺省不启动定时查询,用户在页面上手动打开 Auto refresh 才轮询
if (autoRefresh.value) {
startPolling() startPolling()
}
})
} }
function onDisConnectUi() { function onDisConnectUi() {
@ -163,9 +254,13 @@ function onDisConnectUi() {
stopPolling() stopPolling()
temperature.value = "--" temperature.value = "--"
fanSpeed.value = "--" fanSpeed.value = "--"
voltage.value = "--"
fwVersion.value = "--" fwVersion.value = "--"
bankInfo.value = "--" bankInfo.value = "--"
uptime.value = "--"
freeHeap.value = "--"
sysInfo.value = "--" sysInfo.value = "--"
paramValid.value = false
} }
function onBleStatueListener(state: number) { function onBleStatueListener(state: number) {
@ -187,7 +282,13 @@ function onBleStarScan() {
if (scanning.value) return if (scanning.value) return
if (!Ble.isEnabled()) { if (!Ble.isEnabled()) {
Ble.openBluetooth((res: MyApiResult) => { 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 return
} }
@ -262,7 +363,9 @@ onShow(() => {
deviceName.value = BleINfo.getName() ?? "" deviceName.value = BleINfo.getName() ?? ""
loadDeviceInfo() loadDeviceInfo()
} }
if (autoRefresh.value) {
startPolling() startPolling()
}
} else { } else {
onBleStarScan() onBleStarScan()
} }
@ -443,6 +546,26 @@ onPullDownRefresh(() => {
margin-bottom: 12rpx; 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 { .consoleText {
font-size: 22rpx; font-size: 22rpx;
color: #333333; color: #333333;

View File

@ -20,21 +20,49 @@
<text class="infoValue">{{ bankInfo }}</text> <text class="infoValue">{{ bankInfo }}</text>
</view> </view>
</view> </view>
</template>
<!-- OTA 停用说明 --> <!-- 升级包(mothercup_ble_ota.bin 单文件,含双 bank 载荷) -->
<view class="card"> <view class="card">
<text class="sectionTitle">OTA Upgrade</text> <text class="sectionTitle">OTA package (mothercup_ble_ota.bin)</text>
<text class="warnText"> <input class="input" v-model="filePath" placeholder="_doc/mothercup_ble_ota.bin" :disabled="upgrading" />
OTA is temporarily unavailable: the current firmware's notify uplink is broken, <view class="btnRow">
so upgrade acknowledgements cannot be returned to the phone. <button class="btn" size="mini" :disabled="upgrading" @tap="onChooseFile">Choose File</button>
This feature will be restored after a firmware update (SWD reflash). <button class="btn" size="mini" :disabled="upgrading" @tap="onLoadFile">Load Package</button>
</text>
<text class="warnText">
OTA 暂不可用:当前固件 notify 上行链路异常,升级应答无法回传,
待固件更新(SWD 烧录)后恢复。
</text>
</view> </view>
<text class="hintText">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/&lt;package&gt;/files)</text>
<view class="infoRow" v-if="pkgLoaded">
<text class="infoLabel">Package Version</text>
<text class="infoValue">{{ pkgVersion }}</text>
</view>
<view class="infoRow" v-if="pkgLoaded">
<text class="infoLabel">Payloads</text>
<text class="infoValue">bank1 {{ pkgBank1Size }}B / bank2 {{ pkgBank2Size }}B</text>
</view>
<view class="infoRow" v-if="pkgLoaded">
<text class="infoLabel">Target Bank</text>
<text class="infoValue">APP{{ targetBank }} (opposite of current)</text>
</view>
</view>
<!-- 升级控制 -->
<view class="card">
<view class="progressBox">
<view class="progressBar" :style="'width:' + progress + '%'"></view>
</view>
<text class="progressText">{{ progressText }}</text>
<view class="btnRow">
<button class="btn" size="mini" v-if="!upgrading" :disabled="!pkgLoaded" @tap="onStartOta">Start Upgrade</button>
<button class="btn btnWarn" size="mini" v-if="upgrading" @tap="onAbortOta">Abort</button>
</view>
<text class="warnText" v-if="upgrading">Do not disconnect or exit during upgrade</text>
</view>
<!-- 日志 -->
<view class="card" v-if="logs.length > 0">
<text class="sectionTitle">Upgrade Log</text>
<text class="logText" v-for="(log, index) in logs" :key="index">{{ log }}</text>
</view>
</template>
</view> </view>
<!-- #ifdef APP --> <!-- #ifdef APP -->
@ -47,20 +75,345 @@ import { ref } from 'vue'
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app' import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
import * as Ble from "../../src/BluetoothManager" import * as Ble from "../../src/BluetoothManager"
import * as McmCmd from "../../src/mcm/McmCommands" import * as McmCmd from "../../src/mcm/McmCommands"
import * as McmOta from "../../src/mcm/McmOta"
const connected = ref<boolean>(false) const connected = ref<boolean>(false)
const curVersion = ref<string>("--") const curVersion = ref<string>("--")
const bankInfo = ref<string>("--") const bankInfo = ref<string>("--")
const filePath = ref<string>("_doc/mothercup_ble_ota.bin")
const pkgLoaded = ref<boolean>(false)
const pkgVersion = ref<string>("")
const pkgBank1Size = ref<number>(0)
const pkgBank2Size = ref<number>(0)
const targetBank = ref<number>(0)
const upgrading = ref<boolean>(false)
const progress = ref<number>(0)
const progressText = ref<string>("0%")
const logs = ref<string[]>([])
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<void> {
return new Promise((resolve) => { setTimeout(() => { resolve() }, ms) })
}
// ================= 设备信息 ================= // ================= 设备信息 =================
async function loadDeviceInfo() { async function loadDeviceInfo() {
const v = await McmCmd.getVersion() const info = await McmCmd.getDevInfo()
curVersion.value = v != null ? v : "--" curVersion.value = info.fwVersion != null ? McmCmd.formatVersion(info.fwVersion) : "--"
const b = await McmCmd.getBankInfo() bankInfo.value = info.curBank != null ? ("APP" + info.curBank) : "--"
if (b != null) { }
bankInfo.value = b.indexOf("APP2") >= 0 ? "APP2" : (b.indexOf("APP1") >= 0 ? "APP1" : "--")
// ================= 升级包读取(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) { function onBleStatueListener(state: number) {
if (state == 0) { if (state == 0) {
connected.value = true connected.value = true
if (needVerifyOnConnect) {
needVerifyOnConnect = false
// 回连就绪:协商 MTU 后复核升级结果
Ble.setMtu(247, () => {
verifyAfterReboot()
})
} else {
loadDeviceInfo() loadDeviceInfo()
} else if (state == 10000 || state == 10001) { }
} 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 connected.value = false
} }
} }
@ -135,6 +511,13 @@ onUnload(() => {
padding: 20rpx 0; padding: 20rpx 0;
} }
.hintText {
font-size: 22rpx;
color: #999999;
line-height: 1.5;
margin-top: 8rpx;
}
.sectionTitle { .sectionTitle {
font-size: 26rpx; font-size: 26rpx;
color: #888888; color: #888888;
@ -162,10 +545,59 @@ onUnload(() => {
margin-left: 20rpx; 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 { .warnText {
font-size: 24rpx; font-size: 24rpx;
color: #dd524d; color: #dd524d;
margin-top: 12rpx; margin-top: 12rpx;
line-height: 1.5; line-height: 1.5;
} }
.logText {
font-size: 22rpx;
color: #333333;
font-family: monospace;
line-height: 1.5;
}
</style> </style>

View File

@ -1,12 +1,13 @@
/** /**
* 蓝牙管理模块(基于 uni 内置 BLE API) * 蓝牙管理模块(基于 uni 内置 BLE API)
* *
* 当前固件(mcm-ddc-ble V1.00.08+)的可用 GATT 接口: * 当前固件(mcm-ddc-ble V1.00.23)的可用 GATT 接口:
* - CLI 读写特征 ...c72e0003:写原始命令行(write-with-response,≤63B), * - CLI 读写特征 ...c72e0003:写原始命令行(write-with-response,≤63B),
* 读同一特征取回输出文本(≤512B) * 读同一特征分包取回输出文本(每片 ≤ att_mtu-2,0 长度包=结束)
* - 只读参数特征 ...c72e0004:读出全部信息项 TLV(同 INFO_RSP(all)) * - 只读参数特征 ...c72e0004:读出全部信息项 TLV(同 INFO_RSP(all),
* 旧帧协议下行 ...c72e0001 与 notify 上行 ...c72e0002 因固件 notify 链路不通 * 含 0x07 CUR_BANK 当前运行 bank)
* 已停用,本模块不再提供 notify 订阅与帧协议写入接口。 * - OTA 特征 ...c72e0005:OTA 传输帧下行(write-with-response)→
* 读同一特征取回 OTA_RSP(读需轮询至非空),见 docs/ble_protocol.md §6.6
* *
* 对外约定: * 对外约定:
* - MyApiResult.type == 0 表示成功 * - MyApiResult.type == 0 表示成功
@ -19,6 +20,7 @@
export const MCM_SERVICE_UUID = "00002760-08c2-11e1-9073-0e8ac72e1001" export const MCM_SERVICE_UUID = "00002760-08c2-11e1-9073-0e8ac72e1001"
export const MCM_CLI_UUID = "00002760-08c2-11e1-9073-0e8ac72e0003" // 读写:写命令行 / 读输出文本 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_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 = { export type MyApiResult = {
type: number, type: number,
@ -43,64 +45,85 @@ let manualDisconnect = false
let btEnabled = false // 缓存的蓝牙开关状态(由 adapter 状态监听/操作结果维护) let btEnabled = false // 缓存的蓝牙开关状态(由 adapter 状态监听/操作结果维护)
let scanning = false let scanning = false
let scanTimeoutTimer = 0 let scanTimeoutTimer = 0
let negotiatedMtu = 23 // 当前生效的 ATT MTU(默认 23,setMtu 成功后更新)
let stateListenerRegistered = false let stateListenerRegistered = false
let connListenerRegistered = false let connListenerRegistered = false
let scanListenerRegistered = false let scanListenerRegistered = false
let valueListenerRegistered = false let valueListenerRegistered = false
// 读请求队列:uni 的读结果经 onBLECharacteristicValueChange 全局回调返回, // 读请求按特征值各自独立排队/跟踪(CLI/参数/OTA 各通道互不影响、可并行):
// 同一时间只能跟踪一个待决读请求,多余请求排队串行执行 // uni 的读结果经 onBLECharacteristicValueChange 全局回调返回,按 characteristicId 分发;
// 同一特征上的读串行,不同特征各管各的超时与状态
type ReadReq = { type ReadReq = {
charId: string, charId: string,
cb: (res: MyApiResult) => void cb: (res: MyApiResult) => void,
retried: boolean // 10007 已重试过
} }
const readQueue: ReadReq[] = [] const readQueues: { [key: string]: ReadReq[] } = {}
let readActive: ReadReq | null = null const readActiveMap: { [key: string]: ReadReq | null } = {}
let readTimer = 0 const readTimers: { [key: string]: number } = {}
const READ_TIMEOUT_MS = 3000 const READ_TIMEOUT_MS = 3000
// 注册特征值变化监听(read 结果经此回调分发,幂等) function charKey(charId: string): string {
return charId.toUpperCase()
}
// 注册特征值变化监听(read 结果按特征值分发到各自的待决请求,幂等)
function ensureValueChangeListener() { function ensureValueChangeListener() {
if (valueListenerRegistered) return if (valueListenerRegistered) return
valueListenerRegistered = true valueListenerRegistered = true
uni.onBLECharacteristicValueChange((res: any) => { uni.onBLECharacteristicValueChange((res: any) => {
if (readActive == null) return
const charId = res.characteristicId as string 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 bytes = new Uint8Array(res.value as ArrayBuffer)
const arr = [] as number[] const arr = [] as number[]
for (let i = 0; i < bytes.length; i++) { for (let i = 0; i < bytes.length; i++) {
arr.push(bytes[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) { function finishRead(key: string, res: MyApiResult) {
const req = readActive const req = readActiveMap[key]
readActive = null readActiveMap[key] = null
clearTimeout(readTimer) clearTimeout(readTimers[key])
if (req != null) { if (req != null) {
if (res.type != 0) {
console.log("[MCM] rx " + charTag(req.charId) + " FAIL: [" + res.type + "] " + res.message)
}
req.cb(res) req.cb(res)
} }
pumpReadQueue() pumpReadQueue(key)
} }
function pumpReadQueue() { function pumpReadQueue(key: string) {
if (readActive != null) return if (readActiveMap[key] != null) return
const id = connectedDeviceId const id = connectedDeviceId
const req = readQueue.shift() const queue = readQueues[key]
if (queue == null) return
const req = queue.shift()
if (req == null) return if (req == null) return
if (id == null) { if (id == null) {
req.cb({ type: 10001, message: "not connected", data: null } as MyApiResult) req.cb({ type: 10001, message: "not connected", data: null } as MyApiResult)
pumpReadQueue() pumpReadQueue(key)
return return
} }
readActive = req // 链路稳定期内推迟发出(连接级一次性等待,不做跨特征阻塞)
readTimer = setTimeout(() => { const waitMs = linkReadyAt - Date.now()
finishRead({ type: 10008, message: "read timeout", data: null } as MyApiResult) 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) }, READ_TIMEOUT_MS)
uni.readBLECharacteristicValue({ uni.readBLECharacteristicValue({
deviceId: id, deviceId: id,
@ -108,7 +131,22 @@ function pumpReadQueue() {
characteristicId: req.charId, characteristicId: req.charId,
success: () => {}, success: () => {},
fail: (err: any) => { 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() 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() { function ensureStateListener() {
if (stateListenerRegistered) return if (stateListenerRegistered) return
@ -154,18 +208,27 @@ function ensureConnListener() {
uni.onBLEConnectionStateChange((res: any) => { uni.onBLEConnectionStateChange((res: any) => {
if (res.connected) { if (res.connected) {
connectedDeviceId = res.deviceId connectedDeviceId = res.deviceId
console.log("[MCM] connected: " + res.deviceId)
} else { } else {
console.log("[MCM] disconnected: " + res.deviceId + (manualDisconnect ? " (manual)" : ""))
const wasConnected = connectedDeviceId != null && connectedDeviceId == res.deviceId const wasConnected = connectedDeviceId != null && connectedDeviceId == res.deviceId
connectedDeviceId = null connectedDeviceId = null
// 断连后所有待决/排队读请求立即失败 negotiatedMtu = 23
if (readActive != null) { linkReadyAt = 0
finishRead({ type: 10001, message: "disconnected", data: null } as MyApiResult) // 断连后所有特征的待决/排队读请求立即失败(逐通道清理)
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) { for (let i = 0; i < keys.length; i++) {
const req = readQueue.shift() const k = keys[i]
if (req != null) { if (readActiveMap[k] != null) {
req.cb({ type: 10001, message: "disconnected", data: null } as MyApiResult) finishRead(k, { type: 10001, message: "disconnected", data: null } as MyApiResult)
} }
// finishRead→pumpReadQueue 会以 not connected 依次排空该通道队列;
// 对在稳定期推迟的队列(无待决请求)也主动触发一次排空
pumpReadQueue(k)
} }
if (wasConnected && !manualDisconnect) { if (wasConnected && !manualDisconnect) {
uni.$emit("bleState", 10001) uni.$emit("bleState", 10001)
@ -335,15 +398,26 @@ export function scanServices(cb: (res: MyApiResult) => void) {
const chars = resCh.characteristics as any[] const chars = resCh.characteristics as any[]
let hasCli = false let hasCli = false
let hasInfo = false let hasInfo = false
let hasOta = false
for (let i = 0; i < chars.length; i++) { for (let i = 0; i < chars.length; i++) {
const cu = chars[i].uuid as string 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_CLI_UUID)) hasCli = true
if (uuidEq(cu, MCM_INFO_UUID)) hasInfo = true if (uuidEq(cu, MCM_INFO_UUID)) hasInfo = true
if (uuidEq(cu, MCM_OTA_UUID)) hasOta = true
} }
if (!hasCli || !hasInfo) { if (!hasCli || !hasInfo || !hasOta) {
cb({ type: 10005, message: "MCM cli/info characteristic not found", data: null } as MyApiResult) cb({ type: 10005, message: "MCM cli/info/ota characteristic not found", data: null } as MyApiResult)
return return
} }
// 链路稳定期:首个 GATT 读写操作会等到此刻之后发出(安卓时序兼容)
linkReadyAt = Date.now() + GATT_SETTLE_MS
cb(ok("services ready")) cb(ok("services ready"))
}, },
fail: (err: any) => { fail: (err: any) => {
@ -359,13 +433,31 @@ 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 const id = connectedDeviceId
if (id == null) { if (id == null) {
cb({ type: 10001, message: "not connected", data: null } as MyApiResult) cb({ type: 10001, message: "not connected", data: null } as MyApiResult)
return return
} }
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) const buf = new Uint8Array(data.length)
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
buf[i] = data[i] & 0xFF buf[i] = data[i] & 0xFF
@ -373,24 +465,50 @@ export function writeCliChar(data: number[], cb: (res: MyApiResult) => void) {
uni.writeBLECharacteristicValue({ uni.writeBLECharacteristicValue({
deviceId: id, deviceId: id,
serviceId: MCM_SERVICE_UUID, serviceId: MCM_SERVICE_UUID,
characteristicId: MCM_CLI_UUID, characteristicId: charId,
value: buf.buffer, value: buf.buffer,
writeType: "write", writeType: "write",
success: () => { success: () => {
console.log("[MCM] tx " + charTag(charId) + " " + data.length + "B ok: " + bytesHex(data, 48))
cb(ok("write ok")) cb(ok("write ok"))
}, },
fail: (err: any) => { 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)) 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[] // type==0 时 data 为 number[]
export function readChar(charId: string, cb: (res: MyApiResult) => void) { export function readChar(charId: string, cb: (res: MyApiResult) => void) {
ensureValueChangeListener() ensureValueChangeListener()
readQueue.push({ charId: charId, cb: cb } as ReadReq) const key = charKey(charId)
pumpReadQueue() 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) // 读取设备参数特征(只读 MCM_INFO_UUID,内容为全部信息项 TLV)
@ -412,7 +530,11 @@ export function setMtu(len: number, cb: (res: MyApiResult) => void) {
deviceId: id, deviceId: id,
mtu: len, mtu: len,
success: (res: any) => { 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) => { fail: (err: any) => {
cb(failRes(10008, err)) cb(failRes(10008, err))
@ -424,6 +546,11 @@ export function setMtu(len: number, cb: (res: MyApiResult) => void) {
// #endif // #endif
} }
// 当前协商的 ATT MTU(未调用过 setMtu 或协商失败时为默认 23)
export function getMtu(): number {
return negotiatedMtu
}
// 读取 rssi 信号 需要蓝牙已经连接 // 读取 rssi 信号 需要蓝牙已经连接
export function readRssi(cb: (res: MyApiResult) => void) { export function readRssi(cb: (res: MyApiResult) => void) {
const id = connectedDeviceId const id = connectedDeviceId

View File

@ -19,6 +19,7 @@ const CLI_RSP_DELAY_MS = 30
export type CliResult = { export type CliResult = {
text: string, // 命令输出原文 text: string, // 命令输出原文
status: number // 0=ok 1=未知命令/行超长 -1=超时 -2=行超长(本地拦截) -3=写入失败 -4=未连接 status: number // 0=ok 1=未知命令/行超长 -1=超时 -2=行超长(本地拦截) -3=写入失败 -4=未连接
errMsg: string // 底层 BLE 错误原文(uni errMsg),无错误为 ""
} }
let cliChain: Promise<any> = Promise.resolve() let cliChain: Promise<any> = Promise.resolve()
@ -56,19 +57,22 @@ export function execCli(cmd: string, timeoutMs: number = 5000): Promise<CliResul
async function doExecCli(cmd: string, timeoutMs: number): Promise<CliResult> { async function doExecCli(cmd: string, timeoutMs: number): Promise<CliResult> {
if (!Ble.isConnected()) { if (!Ble.isConnected()) {
return { text: "", status: -4 } as CliResult return { text: "", status: -4, errMsg: "not connected" } as CliResult
} }
const bytes = toBytes(cmd) const bytes = toBytes(cmd)
if (bytes.length == 0 || bytes.length > CLI_CMD_MAX) { 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<CliResult> => { const op = (async (): Promise<CliResult> => {
const wr = await new Promise<MyApiResult>((resolve) => { const wr = await new Promise<MyApiResult>((resolve) => {
Ble.writeCliChar(bytes, (res: MyApiResult) => { resolve(res) }) Ble.writeCliChar(bytes, (res: MyApiResult) => { resolve(res) })
}) })
if (wr.type != 0) { 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) await delay(CLI_RSP_DELAY_MS)
// 分包读:每次读返回下一片,0 长度包 = 应答结束 // 分包读:每次读返回下一片,0 长度包 = 应答结束
@ -79,7 +83,8 @@ async function doExecCli(cmd: string, timeoutMs: number): Promise<CliResult> {
}) })
if (rd.type != 0) { if (rd.type != 0) {
const st = rd.type == 10001 ? -4 : -1 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[] const arr = rd.data as number[]
if (arr.length == 0) { if (arr.length == 0) {
@ -90,12 +95,17 @@ async function doExecCli(cmd: string, timeoutMs: number): Promise<CliResult> {
const text = toText(all) const text = toText(all)
// 固件对无输出的成功命令回 "ok\r\n",对未知命令/超长行回 "unknown command\r\n" // 固件对无输出的成功命令回 "ok\r\n",对未知命令/超长行回 "unknown command\r\n"
const status = text == "unknown command\r\n" ? 1 : 0 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, 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
} }

View File

@ -1,21 +1,36 @@
/** /**
* CAIIC-MCM 业务命令封装(读写特征方式,不依赖 notify) * CAIIC-MCM 业务命令封装(读写特征方式,不依赖 notify)
* 固件版本走只读参数特征(McmInfo.readDeviceInfo); * 固件版本/当前 bank 走只读参数特征(McmInfo.readDeviceInfo);
* sysinfo/bankinfo 等系统状态走 CLI 特征透传,命令名集中在顶部常量。 * 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 { execCli, CliResult } from "./McmCli"
import { readDeviceInfo, formatVersion } from "./McmInfo" import { readDeviceInfo, formatVersion, DeviceInfo } from "./McmInfo"
const CMD_SYSINFO = "sysinfo" 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<string | null> { export async function getVersion(): Promise<string | null> {
const info = await readDeviceInfo() const info = await readDeviceInfo()
const v = info.fwVersion const v = info.fwVersion
return v != null ? formatVersion(v) : null return v != null ? formatVersion(v) : null
} }
/** 当前运行 bank:1=APP1 2=APP2(参数特征 TLV 的 CUR_BANK 项);失败返回 null */
export async function getCurBank(): Promise<number | null> {
const info = await readDeviceInfo()
return info.curBank
}
/** 设备信息全项(温度/电压/风扇/运行时长/堆剩余/版本/bank),失败字段为 null */
export async function getDevInfo(): Promise<DeviceInfo> {
return await readDeviceInfo()
}
/** 系统运行状态原文(运行时间/任务数/heap/任务表),失败返回 null */ /** 系统运行状态原文(运行时间/任务数/heap/任务表),失败返回 null */
export async function getSysInfo(): Promise<string | null> { export async function getSysInfo(): Promise<string | null> {
const r: CliResult = await execCli(CMD_SYSINFO, 5000) const r: CliResult = await execCli(CMD_SYSINFO, 5000)
@ -23,12 +38,41 @@ export async function getSysInfo(): Promise<string | null> {
return r.text return r.text
} }
/** bank 信息原文(当前运行 bank 与 bootsetting),失败返回 null */ /** 设备信息原文(devinfo CLI 输出),失败返回 null */
export async function getBankInfo(): Promise<string | null> { export async function getDevInfoText(): Promise<string | null> {
const r: CliResult = await execCli(CMD_BANKINFO) const r: CliResult = await execCli(CMD_DEVINFO, 5000)
if (r.status != 0) return null if (r.status != 0) return null
return r.text return r.text
} }
export { execCli } // ================= APP_DATA 参数(led/pwm) =================
export type { CliResult }
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<AppParams | null> {
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 <led|pwm> <val>,成功回 "<key> = <val>, saved") */
export async function setAppParam(key: string, val: number): Promise<boolean> {
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 }

View File

@ -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_VDD_MV = 0x04 // u16 电源电压 mV(0 = 读取失败)
export const INFO_UPTIME_S = 0x05 // u32 系统运行时间 s export const INFO_UPTIME_S = 0x05 // u32 系统运行时间 s
export const INFO_FREE_HEAP = 0x06 // u32 FreeRTOS 堆剩余 B 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 = { export type DeviceInfo = {
fwVersion: number | null, // 原始 u32 版本号 fwVersion: number | null, // 原始 u32 版本号
@ -21,7 +22,8 @@ export type DeviceInfo = {
fanRpm: number | null, // 风扇转速 rpm;null = 无硬件/未应答 fanRpm: number | null, // 风扇转速 rpm;null = 无硬件/未应答
vddMv: number | null, // 电源电压 mV vddMv: number | null, // 电源电压 mV
uptimeS: number | null, // 运行时间 s uptimeS: number | null, // 运行时间 s
freeHeap: number | null // 堆剩余 B freeHeap: number | null, // 堆剩余 B
curBank: number | null // 当前运行 bank:1=APP1 2=APP2
} }
function emptyInfo(): DeviceInfo { function emptyInfo(): DeviceInfo {
@ -31,7 +33,8 @@ function emptyInfo(): DeviceInfo {
fanRpm: null, fanRpm: null,
vddMv: null, vddMv: null,
uptimeS: null, uptimeS: null,
freeHeap: null freeHeap: null,
curBank: null
} as DeviceInfo } as DeviceInfo
} }
@ -72,6 +75,9 @@ export function parseTlv(payload: number[]): DeviceInfo {
info.uptimeS = readU32LE(payload, off) info.uptimeS = readU32LE(payload, off)
} else if (id == INFO_FREE_HEAP && len == 4) { } else if (id == INFO_FREE_HEAP && len == 4) {
info.freeHeap = readU32LE(payload, off) 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 i += 2 + len
} }
@ -88,8 +94,13 @@ export async function readDeviceInfo(): Promise<DeviceInfo> {
resolve(res.type == 0 ? res.data as number[] : null) resolve(res.type == 0 ? res.data as number[] : null)
}) })
}) })
if (payload == null) return emptyInfo() if (payload == null) {
return parseTlv(payload) 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" */ /** 固件版本号 u32 格式化,如 0x00010001 → "V1.00.01" */

View File

@ -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<void> {
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<MyApiResult> {
return new Promise((resolve) => { Ble.writeOtaChar(frame, (res: MyApiResult) => { resolve(res) }) })
}
function readOta(): Promise<MyApiResult> {
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<void> {
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 + " ...")
}