evan.liu 3e87d333cb 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
2026-09-05 06:45:04 +08:00

604 lines
18 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- #ifdef APP -->
<scroll-view style="flex:1">
<!-- #endif -->
<view class="page">
<view class="card" v-if="!connected">
<text class="tipText">Not connected. Please connect a CAIIC-MCM device on the Device tab first</text>
</view>
<template v-if="connected">
<!-- 当前设备信息 -->
<view class="card">
<view class="infoRow">
<text class="infoLabel">Current Firmware</text>
<text class="infoValue">{{ curVersion }}</text>
</view>
<view class="infoRow">
<text class="infoLabel">Bank</text>
<text class="infoValue">{{ bankInfo }}</text>
</view>
</view>
<!-- 升级包(mothercup_ble_ota.bin 单文件,含双 bank 载荷) -->
<view class="card">
<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/&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>
<!-- #ifdef APP -->
</scroll-view>
<!-- #endif -->
</template>
<script setup lang="ts">
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 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()
}
// ================= 蓝牙状态监听 =================
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) {
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
}
}
let subscribed = false
function subscribe() {
if (subscribed) return
uni.$on("bleState", onBleStatueListener)
subscribed = true
}
function unsubscribe() {
if (!subscribed) return
uni.$off("bleState", onBleStatueListener)
subscribed = false
}
// ================= 生命周期 =================
onLoad((options: any) => {
subscribe()
})
onShow(() => {
subscribe()
connected.value = Ble.isConnected()
if (connected.value) {
loadDeviceInfo()
}
})
onHide(() => {
unsubscribe()
})
onUnload(() => {
unsubscribe()
})
</script>
<style scoped>
.page{
display:flex;
flex-direction:column;
padding: 0 20rpx;
margin-bottom: 16px;
}
.card {
border-radius: 36rpx;
background-color: #ffffff;
padding: 24rpx;
margin-top: 20rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
}
.tipText {
font-size: 26rpx;
color: #999999;
text-align: center;
padding: 20rpx 0;
}
.hintText {
font-size: 22rpx;
color: #999999;
line-height: 1.5;
margin-top: 8rpx;
}
.sectionTitle {
font-size: 26rpx;
color: #888888;
margin-bottom: 12rpx;
}
.infoRow {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 8rpx 0;
}
.infoLabel {
font-size: 26rpx;
color: #888888;
flex-shrink: 0;
}
.infoValue {
font-size: 24rpx;
color: #333333;
flex: 1;
text-align: right;
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>