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:
parent
ead16ef671
commit
3e87d333cb
10
AGENTS.md
10
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 失败或超时。
|
||||
|
||||
@ -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。
|
||||
78
SmartAssiter/docs/修改记录_2026-09-05_真机联调_BLE通道隔离与SAF选包.md
Normal file
78
SmartAssiter/docs/修改记录_2026-09-05_真机联调_BLE通道隔离与SAF选包.md
Normal 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 的实机验证项随本轮一并闭环
|
||||
@ -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) : ""))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -19,17 +19,25 @@
|
||||
|
||||
<!-- 参数显示 -->
|
||||
<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">
|
||||
<text class="paramLabel">Temperature</text>
|
||||
<text class="paramValue">{{ temperature }}<text class="paramUnit"> ℃</text></text>
|
||||
</view>
|
||||
<view class="paramItem">
|
||||
<text class="paramLabel">Voltage</text>
|
||||
<text class="paramValue">{{ voltage }}<text class="paramUnit"> mV</text></text>
|
||||
</view>
|
||||
<view class="paramItem">
|
||||
<text class="paramLabel">Fan Speed</text>
|
||||
<text class="paramValue">{{ fanSpeed }}<text class="paramUnit"> RPM</text></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 设备信息 -->
|
||||
<!-- 设备信息(devinfo:版本/bank/运行时长/堆剩余) -->
|
||||
<view class="card" v-if="connected">
|
||||
<view class="infoRow">
|
||||
<text class="infoLabel">Firmware</text>
|
||||
@ -39,6 +47,29 @@
|
||||
<text class="infoLabel">Bank</text>
|
||||
<text class="infoValue">{{ bankInfo }}</text>
|
||||
</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>
|
||||
|
||||
<!-- sysinfo 控制台输出(原 System Status 页内容) -->
|
||||
@ -91,9 +122,18 @@ const deviceName = ref<string>("")
|
||||
const deviceMac = ref<string>("")
|
||||
const temperature = ref<string>("--")
|
||||
const fanSpeed = ref<string>("--")
|
||||
const voltage = ref<string>("--")
|
||||
const fwVersion = ref<string>("--")
|
||||
const bankInfo = ref<string>("--")
|
||||
const uptime = ref<string>("--")
|
||||
const freeHeap = 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 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,40 +167,86 @@ 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()
|
||||
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() {
|
||||
connected.value = false
|
||||
@ -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()
|
||||
}
|
||||
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;
|
||||
|
||||
@ -20,21 +20,49 @@
|
||||
<text class="infoValue">{{ bankInfo }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- OTA 停用说明 -->
|
||||
<!-- 升级包(mothercup_ble_ota.bin 单文件,含双 bank 载荷) -->
|
||||
<view class="card">
|
||||
<text class="sectionTitle">OTA Upgrade</text>
|
||||
<text class="warnText">
|
||||
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).
|
||||
</text>
|
||||
<text class="warnText">
|
||||
OTA 暂不可用:当前固件 notify 上行链路异常,升级应答无法回传,
|
||||
待固件更新(SWD 烧录)后恢复。
|
||||
</text>
|
||||
<text class="sectionTitle">OTA package (mothercup_ble_ota.bin)</text>
|
||||
<input class="input" v-model="filePath" placeholder="_doc/mothercup_ble_ota.bin" :disabled="upgrading" />
|
||||
<view class="btnRow">
|
||||
<button class="btn" size="mini" :disabled="upgrading" @tap="onChooseFile">Choose File</button>
|
||||
<button class="btn" size="mini" :disabled="upgrading" @tap="onLoadFile">Load Package</button>
|
||||
</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/<package>/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>
|
||||
<!-- #ifdef APP -->
|
||||
@ -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<boolean>(false)
|
||||
const curVersion = 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() {
|
||||
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
|
||||
if (needVerifyOnConnect) {
|
||||
needVerifyOnConnect = false
|
||||
// 回连就绪:协商 MTU 后复核升级结果
|
||||
Ble.setMtu(247, () => {
|
||||
verifyAfterReboot()
|
||||
})
|
||||
} else {
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -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,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
|
||||
if (id == null) {
|
||||
cb({ type: 10001, message: "not connected", data: null } as MyApiResult)
|
||||
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)
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
buf[i] = data[i] & 0xFF
|
||||
@ -373,24 +465,50 @@ export function writeCliChar(data: number[], cb: (res: MyApiResult) => void) {
|
||||
uni.writeBLECharacteristicValue({
|
||||
deviceId: id,
|
||||
serviceId: MCM_SERVICE_UUID,
|
||||
characteristicId: MCM_CLI_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
|
||||
|
||||
@ -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<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> {
|
||||
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<CliResult> => {
|
||||
const wr = await new Promise<MyApiResult>((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<CliResult> {
|
||||
})
|
||||
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<CliResult> {
|
||||
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
|
||||
}
|
||||
|
||||
@ -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<string | null> {
|
||||
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<number | null> {
|
||||
const info = await readDeviceInfo()
|
||||
return info.curBank
|
||||
}
|
||||
|
||||
/** 设备信息全项(温度/电压/风扇/运行时长/堆剩余/版本/bank),失败字段为 null */
|
||||
export async function getDevInfo(): Promise<DeviceInfo> {
|
||||
return await readDeviceInfo()
|
||||
}
|
||||
|
||||
/** 系统运行状态原文(运行时间/任务数/heap/任务表),失败返回 null */
|
||||
export async function getSysInfo(): Promise<string | null> {
|
||||
const r: CliResult = await execCli(CMD_SYSINFO, 5000)
|
||||
@ -23,12 +38,41 @@ export async function getSysInfo(): Promise<string | null> {
|
||||
return r.text
|
||||
}
|
||||
|
||||
/** bank 信息原文(当前运行 bank 与 bootsetting),失败返回 null */
|
||||
export async function getBankInfo(): Promise<string | null> {
|
||||
const r: CliResult = await execCli(CMD_BANKINFO)
|
||||
/** 设备信息原文(devinfo CLI 输出),失败返回 null */
|
||||
export async function getDevInfoText(): Promise<string | null> {
|
||||
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<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 }
|
||||
|
||||
@ -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<DeviceInfo> {
|
||||
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" */
|
||||
|
||||
364
SmartAssiter/src/mcm/McmOta.ts
Normal file
364
SmartAssiter/src/mcm/McmOta.ts
Normal 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 + " ...")
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user