From c65809a6f1e10e41253c3f97fe98ef50d50986e6 Mon Sep 17 00:00:00 2001
From: "evan.liu" <583229071@qq.com>
Date: Fri, 4 Sep 2026 09:21:08 +0800
Subject: [PATCH] =?UTF-8?q?SmartAssiter=E5=8E=BBnotify=E9=87=8D=E8=AE=BE?=
=?UTF-8?q?=E8=AE=A1:BLE=E9=80=9A=E4=BF=A1=E5=88=87=E6=8D=A2=E5=88=B0?=
=?UTF-8?q?=E5=9B=BA=E4=BB=B6=E8=AF=BB=E5=86=99=E7=89=B9=E5=BE=81=E6=8E=A5?=
=?UTF-8?q?=E5=8F=A3(CLI=E7=89=B9=E5=BE=81...c72e0003=E5=86=99=E5=91=BD?=
=?UTF-8?q?=E4=BB=A4=E8=AF=BB=E5=BA=94=E7=AD=94,=E5=8F=AA=E8=AF=BB?=
=?UTF-8?q?=E5=8F=82=E6=95=B0=E7=89=B9=E5=BE=81...c72e0004=20TLV);?=
=?UTF-8?q?=E5=88=A0=E9=99=A4=E5=B8=A7=E5=8D=8F=E8=AE=AE/notify=E4=BB=A3?=
=?UTF-8?q?=E7=A0=81(FrameCodec/McmChannel/OtaManager/Crc);BluetoothManage?=
=?UTF-8?q?r=E9=87=8D=E5=86=99(=E9=80=9A=E7=94=A8readChar=E8=AF=BB?=
=?UTF-8?q?=E9=98=9F=E5=88=97+writeCliChar,scanServices=E6=A0=A1=E9=AA=8C?=
=?UTF-8?q?=E7=89=B9=E5=BE=81);=E8=BF=9E=E6=8E=A5=E5=B0=B1=E7=BB=AA?=
=?UTF-8?q?=E4=BF=A1=E5=8F=B7=E6=94=B9=E4=B8=BAbleState=200;OTA=E9=A1=B5?=
=?UTF-8?q?=E6=9A=82=E5=81=9C=E7=94=A8(notify=E4=B8=8A=E8=A1=8C=E4=B8=8D?=
=?UTF-8?q?=E9=80=9A=E5=BE=85=E5=9B=BA=E4=BB=B6=E4=BF=AE=E5=A4=8D)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
SmartAssiter/App.vue | 22 +-
.../修改记录_2026-09-04_App去notify重设计.md | 62 ++++
.../bluetoothOperation/bluetoothOperation.vue | 23 +-
SmartAssiter/pages/tabbar/home.vue | 31 +-
SmartAssiter/pages/tabbar/ota.vue | 253 ++-------------
SmartAssiter/src/BluetoothManager.ts | 298 ++++++++----------
SmartAssiter/src/mcm/Crc.ts | 47 ---
SmartAssiter/src/mcm/FrameCodec.ts | 126 --------
SmartAssiter/src/mcm/McmChannel.ts | 274 ----------------
SmartAssiter/src/mcm/McmCli.ts | 91 ++++++
SmartAssiter/src/mcm/McmCommands.ts | 39 +--
SmartAssiter/src/mcm/McmInfo.ts | 38 +--
SmartAssiter/src/mcm/OtaManager.ts | 214 -------------
13 files changed, 348 insertions(+), 1170 deletions(-)
create mode 100644 SmartAssiter/docs/修改记录_2026-09-04_App去notify重设计.md
delete mode 100644 SmartAssiter/src/mcm/Crc.ts
delete mode 100644 SmartAssiter/src/mcm/FrameCodec.ts
delete mode 100644 SmartAssiter/src/mcm/McmChannel.ts
create mode 100644 SmartAssiter/src/mcm/McmCli.ts
delete mode 100644 SmartAssiter/src/mcm/OtaManager.ts
diff --git a/SmartAssiter/App.vue b/SmartAssiter/App.vue
index 03e555a..06f059b 100644
--- a/SmartAssiter/App.vue
+++ b/SmartAssiter/App.vue
@@ -1,7 +1,6 @@
@@ -309,13 +141,6 @@ onUnload(() => {
margin-bottom: 12rpx;
}
-.input {
- border: 1px solid #dddddd;
- border-radius: 12rpx;
- padding: 16rpx;
- font-size: 26rpx;
-}
-
.infoRow {
display: flex;
flex-direction: row;
@@ -337,52 +162,10 @@ onUnload(() => {
margin-left: 20rpx;
}
-.btnRow {
- display: flex;
- flex-direction: row;
- justify-content: flex-end;
- margin-top: 16rpx;
-}
-
-.btn {
- background-color: #3A6FA1;
- color: #ffffff;
-}
-.btnWarn {
- background-color: #dd524d;
-}
-
-.progressBox {
- height: 24rpx;
- border-radius: 12rpx;
- background-color: #eeeeee;
- overflow: hidden;
- margin-top: 8rpx;
-}
-
-.progressBar {
- height: 100%;
- background-color: #3A6FA1;
-}
-
-.progressText {
- font-size: 26rpx;
- color: #3A6FA1;
- text-align: center;
- margin-top: 8rpx;
-}
-
.warnText {
font-size: 24rpx;
color: #dd524d;
- text-align: center;
margin-top: 12rpx;
-}
-
-.logText {
- font-size: 22rpx;
- color: #333333;
- font-family: monospace;
line-height: 1.5;
}
diff --git a/SmartAssiter/src/BluetoothManager.ts b/SmartAssiter/src/BluetoothManager.ts
index b7d29a8..9115ffb 100644
--- a/SmartAssiter/src/BluetoothManager.ts
+++ b/SmartAssiter/src/BluetoothManager.ts
@@ -1,19 +1,23 @@
/**
- * 蓝牙管理模块(基于 uni 内置 BLE API,替代原 android-ble 第三方插件)
+ * 蓝牙管理模块(基于 uni 内置 BLE API)
*
- * 对外约定与原插件保持一致,业务页面无需改动逻辑:
+ * 当前固件(mcm-ddc-ble V1.00.08+)的可用 GATT 接口:
+ * - CLI 读写特征 ...c72e0003:写原始命令行(write-with-response,≤63B),
+ * 读同一特征取回输出文本(≤512B)
+ * - 只读参数特征 ...c72e0004:读出全部信息项 TLV(同 INFO_RSP(all))
+ * 旧帧协议下行 ...c72e0001 与 notify 上行 ...c72e0002 因固件 notify 链路不通
+ * 已停用,本模块不再提供 notify 订阅与帧协议写入接口。
+ *
+ * 对外约定:
* - MyApiResult.type == 0 表示成功
- * - 1000=notify 订阅成功 1001=订阅失败
* - 10000=连接失败 10001=已断开 10002=蓝牙未开/不可用 10003=无蓝牙权限
- * - notify 数据通过回调返回 {type:0, data: NotityData},由 App.vue 转发 uni.$emit("bleData")
- * - 连接状态变化通过 uni.$emit("bleState", state) 通知(0=已连接 10000=连接失败 10001=已断开)
+ * - 10004/10005=服务/特征发现问题 10008=读写/MTU/RSSI 失败或超时
+ * - 连接状态变化通过 uni.$emit("bleState", state) 通知(0=已连接可用 10000=连接失败 10001=已断开)
*/
// CAIIC-MCM 设备 GATT(固件 mcm-ddc-ble app_rdtss.h 实际值,小写与 Android UUID.toString 格式一致)
-// 注意:ble_protocol.md V1.1 表格写作 ...C72EE001/...C72EE002,与固件实际 ...C72E0001/...C72E0002 不符,以固件为准
export const MCM_SERVICE_UUID = "00002760-08c2-11e1-9073-0e8ac72e1001"
-export const MCM_WRITE_UUID = "00002760-08c2-11e1-9073-0e8ac72e0001" // Write Without Response
-export const MCM_NOTIFY_UUID = "00002760-08c2-11e1-9073-0e8ac72e0002" // Notify
+export const MCM_CLI_UUID = "00002760-08c2-11e1-9073-0e8ac72e0003" // 读写:写命令行 / 读输出文本
export const MCM_INFO_UUID = "00002760-08c2-11e1-9073-0e8ac72e0004" // 只读:设备参数 TLV(同 INFO_RSP 全部项)
export type MyApiResult = {
@@ -32,17 +36,10 @@ export type BleScanResult = {
rssi: number
}
-// notify 回调数据结构(对齐原插件 NotityData 的 {data, mac} 字段)
-export type NotityData = {
- data: number[],
- mac: string
-}
-
// ================= 模块状态 =================
let connectedDeviceId: string | null = null
let manualDisconnect = false
-let connTimeoutMs = 0 // createBLEConnection 超时,0 表示不设置
let btEnabled = false // 缓存的蓝牙开关状态(由 adapter 状态监听/操作结果维护)
let scanning = false
let scanTimeoutTimer = 0
@@ -50,39 +47,68 @@ let scanTimeoutTimer = 0
let stateListenerRegistered = false
let connListenerRegistered = false
let scanListenerRegistered = false
-let notifyListenerRegistered = false
-let notifyDataCb: ((res: MyApiResult) => void) | null = null
-let readPendingChar: string | null = null // 等待读结果的特征 UUID
-let readCb: ((res: MyApiResult) => void) | null = null
+let valueListenerRegistered = false
+
+// 读请求队列:uni 的读结果经 onBLECharacteristicValueChange 全局回调返回,
+// 同一时间只能跟踪一个待决读请求,多余请求排队串行执行
+type ReadReq = {
+ charId: string,
+ cb: (res: MyApiResult) => void
+}
+const readQueue: ReadReq[] = []
+let readActive: ReadReq | null = null
let readTimer = 0
-// 注册特征值变化监听(notify 数据与 read 结果都经此回调分发,幂等)
+const READ_TIMEOUT_MS = 3000
+
+// 注册特征值变化监听(read 结果经此回调分发,幂等)
function ensureValueChangeListener() {
- if (notifyListenerRegistered) return
- notifyListenerRegistered = true
+ 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 bytes = new Uint8Array(res.value as ArrayBuffer)
const arr = [] as number[]
for (let i = 0; i < bytes.length; i++) {
arr.push(bytes[i])
}
- if (uuidEq(charId, MCM_NOTIFY_UUID)) {
- const nd: NotityData = { data: arr, mac: res.deviceId } as NotityData
- const c = notifyDataCb
- if (c != null) {
- c({ type: 0, message: "notify", data: nd } as MyApiResult)
- }
- return
- }
- if (readPendingChar != null && uuidEq(charId, readPendingChar)) {
- const c = readCb
- readCb = null
- readPendingChar = null
- clearTimeout(readTimer)
- if (c != null) {
- c({ type: 0, message: "read ok", data: arr } as MyApiResult)
- }
+ finishRead({ type: 0, message: "read ok", data: arr } as MyApiResult)
+ })
+}
+
+function finishRead(res: MyApiResult) {
+ const req = readActive
+ readActive = null
+ clearTimeout(readTimer)
+ if (req != null) {
+ req.cb(res)
+ }
+ pumpReadQueue()
+}
+
+function pumpReadQueue() {
+ if (readActive != null) return
+ const id = connectedDeviceId
+ const req = readQueue.shift()
+ if (req == null) return
+ if (id == null) {
+ req.cb({ type: 10001, message: "not connected", data: null } as MyApiResult)
+ pumpReadQueue()
+ return
+ }
+ readActive = req
+ readTimer = setTimeout(() => {
+ finishRead({ type: 10008, message: "read timeout", data: null } as MyApiResult)
+ }, READ_TIMEOUT_MS)
+ uni.readBLECharacteristicValue({
+ deviceId: id,
+ serviceId: MCM_SERVICE_UUID,
+ characteristicId: req.charId,
+ success: () => {},
+ fail: (err: any) => {
+ finishRead(failRes(10008, err))
}
})
}
@@ -131,6 +157,16 @@ function ensureConnListener() {
} else {
const wasConnected = connectedDeviceId != null && connectedDeviceId == res.deviceId
connectedDeviceId = null
+ // 断连后所有待决/排队读请求立即失败
+ if (readActive != null) {
+ finishRead({ type: 10001, message: "disconnected", data: null } as MyApiResult)
+ }
+ while (readQueue.length > 0) {
+ const req = readQueue.shift()
+ if (req != null) {
+ req.cb({ type: 10001, message: "disconnected", data: null } as MyApiResult)
+ }
+ }
if (wasConnected && !manualDisconnect) {
uni.$emit("bleState", 10001)
}
@@ -147,14 +183,6 @@ export function isEnabled(): boolean {
return btEnabled
}
-// 监听系统蓝牙开关状态 res.data 的值为 true-打开 false-关闭
-export function OpenStateListener(cb: (res: MyApiResult) => void) {
- ensureStateListener()
- uni.onBluetoothAdapterStateChange((res: any) => {
- cb({ type: 0, message: "adapter state changed", data: res.available } as MyApiResult)
- })
-}
-
// 打开蓝牙(初始化蓝牙适配器;蓝牙关闭/无权限时走 fail)
export function openBluetooth(cb: (res: MyApiResult) => void) {
ensureStateListener()
@@ -245,48 +273,21 @@ export function getConnectMac(): string {
return connectedDeviceId ?? ""
}
-// 连接蓝牙(autoReconnect 保留兼容签名,当前业务恒传 false,未实现自动重连)
+// 连接蓝牙
export function connect(mac: string, autoReconnect: boolean, cb: (res: MyApiResult) => void) {
ensureStateListener()
ensureConnListener()
manualDisconnect = false
- if (connTimeoutMs > 0) {
- uni.createBLEConnection({
- deviceId: mac,
- timeout: connTimeoutMs,
- success: () => {
- connectedDeviceId = mac
- cb(ok("connected"))
- },
- fail: (err: any) => {
- cb(failRes(10000, err))
- }
- })
- } else {
- uni.createBLEConnection({
- deviceId: mac,
- success: () => {
- connectedDeviceId = mac
- cb(ok("connected"))
- },
- fail: (err: any) => {
- cb(failRes(10000, err))
- }
- })
- }
-}
-
-// 设置蓝牙连接超时时间(ms)
-export function connectTimeout(timeout: number): void {
- connTimeoutMs = timeout
-}
-
-// 设置自动连接间隔(保留兼容,未实现)
-export function setBtAutoConnectTime(timeout: number): void {
-}
-
-// 取消自动连接蓝牙(保留兼容,未实现)
-export function cancelAutoConnectBt(): void {
+ uni.createBLEConnection({
+ deviceId: mac,
+ success: () => {
+ connectedDeviceId = mac
+ cb(ok("connected"))
+ },
+ fail: (err: any) => {
+ cb(failRes(10000, err))
+ }
+ })
}
// 断开蓝牙
@@ -303,9 +304,9 @@ export function disConnect() {
}
}
-// ================= 服务与订阅 =================
+// ================= 服务发现 =================
-// 连接后扫描服务:确认 MCM 服务及写/notify 特征存在(替代插件的自动识别)
+// 连接后扫描服务:确认 MCM 服务及 CLI 读写/参数只读特征存在
export function scanServices(cb: (res: MyApiResult) => void) {
const id = connectedDeviceId
if (id == null) {
@@ -330,7 +331,19 @@ export function scanServices(cb: (res: MyApiResult) => void) {
uni.getBLEDeviceCharacteristics({
deviceId: id as string,
serviceId: MCM_SERVICE_UUID,
- success: () => {
+ success: (resCh: any) => {
+ const chars = resCh.characteristics as any[]
+ let hasCli = false
+ let hasInfo = false
+ for (let i = 0; i < chars.length; i++) {
+ const cu = chars[i].uuid as string
+ if (uuidEq(cu, MCM_CLI_UUID)) hasCli = true
+ if (uuidEq(cu, MCM_INFO_UUID)) hasInfo = true
+ }
+ if (!hasCli || !hasInfo) {
+ cb({ type: 10005, message: "MCM cli/info characteristic not found", data: null } as MyApiResult)
+ return
+ }
cb(ok("services ready"))
},
fail: (err: any) => {
@@ -344,36 +357,10 @@ export function scanServices(cb: (res: MyApiResult) => void) {
})
}
-// 开启服务消息通知(显式使用 CAIIC-MCM 的服务/notify UUID)
-// 订阅成功回调 type=1000,失败 type=1001;之后 notify 数据以 type=0 持续回调
-export function onNotityReadBleData(readNotification: boolean, cb: (res: MyApiResult) => void) {
- const id = connectedDeviceId
- if (id == null) {
- cb({ type: 10001, message: "not connected", data: null } as MyApiResult)
- return
- }
- notifyDataCb = cb
- ensureValueChangeListener()
- uni.notifyBLECharacteristicValueChange({
- deviceId: id,
- serviceId: MCM_SERVICE_UUID,
- characteristicId: MCM_NOTIFY_UUID,
- state: readNotification,
- success: () => {
- cb({ type: 1000, message: "notify subscribed", data: null } as MyApiResult)
- },
- fail: (err: any) => {
- cb(failRes(1001, err))
- }
- })
-}
+// ================= 读写 =================
-// ================= 写入 =================
-
-// 发送数据(显式写类型):writeType 0=WRITE(有响应) 1=WRITE_NO_RESPONSE(无响应)
-// CAIIC-MCM 设备的下行特征仅支持 Write Without Response,必须用 writeType=1。
-// 服务/特征 UUID 用固件实际值显式指定(下行特征只有 WRITE_NO_RESPONSE 属性)
-export function sendDataBytes(data: number[], writeType: number, cb: (res: MyApiResult) => void) {
+// 写 CLI 特征(write-with-response):data 为原始命令行字节(≤63B,且不超 mtu-3)
+export function writeCliChar(data: number[], cb: (res: MyApiResult) => void) {
const id = connectedDeviceId
if (id == null) {
cb({ type: 10001, message: "not connected", data: null } as MyApiResult)
@@ -383,74 +370,37 @@ export function sendDataBytes(data: number[], writeType: number, cb: (res: MyApi
for (let i = 0; i < data.length; i++) {
buf[i] = data[i] & 0xFF
}
- if (writeType == 1) {
- uni.writeBLECharacteristicValue({
- deviceId: id,
- serviceId: MCM_SERVICE_UUID,
- characteristicId: MCM_WRITE_UUID,
- value: buf.buffer,
- writeType: "writeNoResponse",
- success: () => {
- cb(ok("write ok"))
- },
- fail: (err: any) => {
- cb(failRes(10008, err))
- }
- })
- } else {
- uni.writeBLECharacteristicValue({
- deviceId: id,
- serviceId: MCM_SERVICE_UUID,
- characteristicId: MCM_WRITE_UUID,
- value: buf.buffer,
- writeType: "write",
- success: () => {
- cb(ok("write ok"))
- },
- fail: (err: any) => {
- cb(failRes(10008, err))
- }
- })
- }
-}
-
-// ================= 读取 =================
-
-// 读取设备参数特征(只读 MCM_INFO_UUID,内容为全部信息项 TLV,同 INFO_RSP(all))。
-// uni 的读结果经 onBLECharacteristicValueChange 回传;type==0 时 data 为 number[]。
-export function readInfoChar(cb: (res: MyApiResult) => void) {
- const id = connectedDeviceId
- if (id == null) {
- cb({ type: 10001, message: "not connected", data: null } as MyApiResult)
- return
- }
- ensureValueChangeListener()
- readPendingChar = MCM_INFO_UUID
- readCb = cb
- readTimer = setTimeout(() => {
- if (readCb != null) {
- readCb = null
- readPendingChar = null
- cb({ type: 10008, message: "read timeout", data: null } as MyApiResult)
- }
- }, 3000)
- uni.readBLECharacteristicValue({
+ uni.writeBLECharacteristicValue({
deviceId: id,
serviceId: MCM_SERVICE_UUID,
- characteristicId: MCM_INFO_UUID,
- success: () => {},
+ characteristicId: MCM_CLI_UUID,
+ value: buf.buffer,
+ writeType: "write",
+ success: () => {
+ cb(ok("write ok"))
+ },
fail: (err: any) => {
- readPendingChar = null
- readCb = null
- clearTimeout(readTimer)
cb(failRes(10008, err))
}
})
}
+// 读特征(通用):结果经 onBLECharacteristicValueChange 回传,内部串行排队,3s 超时;
+// type==0 时 data 为 number[]
+export function readChar(charId: string, cb: (res: MyApiResult) => void) {
+ ensureValueChangeListener()
+ readQueue.push({ charId: charId, cb: cb } as ReadReq)
+ pumpReadQueue()
+}
+
+// 读取设备参数特征(只读 MCM_INFO_UUID,内容为全部信息项 TLV)
+export function readInfoChar(cb: (res: MyApiResult) => void) {
+ readChar(MCM_INFO_UUID, cb)
+}
+
// ================= 其他 =================
-// 设置 mtu(仅 App 端有效;其他平台回调失败,调用方保持默认 20 字节切片)
+// 设置 mtu(仅 App 端有效;其他平台回调失败,调用方保持默认 20 字节上限)
export function setMtu(len: number, cb: (res: MyApiResult) => void) {
const id = connectedDeviceId
if (id == null) {
diff --git a/SmartAssiter/src/mcm/Crc.ts b/SmartAssiter/src/mcm/Crc.ts
deleted file mode 100644
index 6795e13..0000000
--- a/SmartAssiter/src/mcm/Crc.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * CRC 校验算法(CAIIC BLE 协议 V1.0)
- * - crc16Ccitt: 帧校验,CRC16-CCITT poly 0x1021 初值 0xFFFF
- * - crc32Ieee : 固件镜像校验,zlib CRC32 poly 0xEDB88320 初值/异或出 0xFFFFFFFF
- *
- * 注意:uts number 位运算按 32 位有符号处理,CRC32 中间值全部保持在 Int 范围内,
- * 无符号右移用 (x >> 1) & 0x7FFFFFFF 实现;最终返回值转为无符号(0..2^32-1)。
- */
-
-// 0xEDB88320 的 32 位补码形式(避免字面量超出 Int 范围)
-const CRC32_POLY = -306674912
-
-export function crc16Ccitt(data: number[], offset: number, len: number): number {
- let crc = 0xFFFF
- for (let i = offset; i < offset + len; i++) {
- crc = crc ^ ((data[i] & 0xFF) << 8)
- for (let k = 0; k < 8; k++) {
- if ((crc & 0x8000) != 0) {
- crc = ((crc << 1) ^ 0x1021) & 0xFFFF
- } else {
- crc = (crc << 1) & 0xFFFF
- }
- }
- }
- return crc
-}
-
-export function crc32Ieee(data: number[]): number {
- let crc = -1 // 0xFFFFFFFF
- for (let i = 0; i < data.length; i++) {
- crc = crc ^ (data[i] & 0xFF)
- for (let k = 0; k < 8; k++) {
- const shifted = (crc >> 1) & 0x7FFFFFFF
- if ((crc & 1) != 0) {
- crc = shifted ^ CRC32_POLY
- } else {
- crc = shifted
- }
- }
- }
- crc = crc ^ -1
- // 转为无符号返回值(避免后续 >> 算术右移出问题)
- if (crc < 0) {
- return crc + 4294967296
- }
- return crc
-}
diff --git a/SmartAssiter/src/mcm/FrameCodec.ts b/SmartAssiter/src/mcm/FrameCodec.ts
deleted file mode 100644
index 271fe80..0000000
--- a/SmartAssiter/src/mcm/FrameCodec.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * CAIIC BLE 协议 V1.1 帧编解码
- * 帧格式(小端):[0]=0xCA SOF, [1]=TYPE, [2]=SEQ, [3:4]=LEN(u16 LE),
- * [5..]=PAYLOAD(LEN 字节), 末尾 2B CRC16-CCITT(覆盖 TYPE..PAYLOAD, LE)
- * 帧开销 7 字节(头 5 + CRC 2),payload 上限 480。
- * 一个协议帧可跨多次 BLE 写入传输,接收侧按字节流重组。
- */
-import { crc16Ccitt } from "./Crc"
-
-export const FRAME_SOF = 0xCA
-export const FRAME_OVERHEAD = 7
-export const MAX_PAYLOAD = 480
-
-// 帧类型
-export const TYPE_CLI_REQ = 0x01 // 手机→设备 命令文本(≤63B)
-export const TYPE_CLI_RSP = 0x02 // 设备→手机 应答文本分块
-export const TYPE_CLI_RSP_END = 0x03 // 设备→手机 {status u8} 0=ok 1=未知命令/行超长
-export const TYPE_OTA_BEGIN = 0x10 // {total_size u32, image_crc32 u32, version u32}
-export const TYPE_OTA_DATA = 0x11 // {offset u32, data…}
-export const TYPE_OTA_END = 0x12 // {crc32 u32}
-export const TYPE_OTA_RSP = 0x1F // {cmd_echo u8, status u8, offset u32}
-export const TYPE_INFO_QUERY = 0x20 // {item_id u8}×n;空 payload 或单个 0xFF = 查询全部
-export const TYPE_INFO_RSP = 0x21 // TLV 序列 {id u8, len u8, value…}×n
-
-export type BleFrame = {
- type: number,
- seq: number,
- payload: number[]
-}
-
-/** 组帧:SOF + TYPE + SEQ + LEN + PAYLOAD + CRC16 */
-export function encodeFrame(type: number, seq: number, payload: number[]): number[] {
- const len = payload.length
- const out = [] as number[]
- out.push(FRAME_SOF)
- out.push(type & 0xFF)
- out.push(seq & 0xFF)
- out.push(len & 0xFF)
- out.push((len >> 8) & 0xFF)
- for (let i = 0; i < len; i++) {
- out.push(payload[i] & 0xFF)
- }
- // CRC 覆盖 TYPE..PAYLOAD,即 out 中索引 [1, 5+len)
- const crc = crc16Ccitt(out, 1, 4 + len)
- out.push(crc & 0xFF)
- out.push((crc >> 8) & 0xFF)
- return out
-}
-
-/** 字节流组包器:跨 notify 分片重组,CRC 错/超长静默丢弃并重新找 SOF */
-export class FrameDecoder {
- buf = [] as number[]
-
- reset() {
- this.buf = [] as number[]
- }
-
- feed(data: number[]): BleFrame[] {
- const frames = [] as BleFrame[]
- for (let i = 0; i < data.length; i++) {
- this.buf.push(data[i] & 0xFF)
- }
- while (true) {
- // 重新找 SOF
- while (this.buf.length > 0 && this.buf[0] != FRAME_SOF) {
- this.buf.shift()
- }
- if (this.buf.length < 5) break
- const len = this.buf[3] | (this.buf[4] << 8)
- if (len > MAX_PAYLOAD) {
- this.buf.shift() // 非法长度,丢弃该 SOF 继续找
- continue
- }
- const total = 5 + len + 2
- if (this.buf.length < total) break // 帧未收完,等下一包
- const crc = crc16Ccitt(this.buf, 1, 4 + len)
- const rxCrc = this.buf[5 + len] | (this.buf[5 + len + 1] << 8)
- if (crc != rxCrc) {
- this.buf.shift() // CRC 错,丢弃该 SOF 重新找
- continue
- }
- const payload = [] as number[]
- for (let i = 0; i < len; i++) {
- payload.push(this.buf[5 + i])
- }
- frames.push({
- type: this.buf[1],
- seq: this.buf[2],
- payload: payload
- } as BleFrame)
- this.buf.splice(0, total)
- }
- return frames
- }
-}
-
-/** 小端 u32 打包(纯算术实现,避免大数值位运算的符号问题) */
-export function pushU32LE(out: number[], v: number) {
- let x = v % 4294967296
- out.push(x % 256)
- x = Math.floor(x / 256)
- out.push(x % 256)
- x = Math.floor(x / 256)
- out.push(x % 256)
- x = Math.floor(x / 256)
- out.push(x % 256)
-}
-
-/** 小端 u32 解包(纯算术实现) */
-export function readU32LE(data: number[], offset: number): number {
- return (data[offset] & 0xFF) +
- (data[offset + 1] & 0xFF) * 256 +
- (data[offset + 2] & 0xFF) * 65536 +
- (data[offset + 3] & 0xFF) * 16777216
-}
-
-/** 小端 u16 解包 */
-export function readU16LE(data: number[], offset: number): number {
- return (data[offset] & 0xFF) + (data[offset + 1] & 0xFF) * 256
-}
-
-/** 小端 i16 解包(处理符号位) */
-export function readI16LE(data: number[], offset: number): number {
- const v = readU16LE(data, offset)
- return v >= 0x8000 ? v - 0x10000 : v
-}
diff --git a/SmartAssiter/src/mcm/McmChannel.ts b/SmartAssiter/src/mcm/McmChannel.ts
deleted file mode 100644
index 5097bee..0000000
--- a/SmartAssiter/src/mcm/McmChannel.ts
+++ /dev/null
@@ -1,274 +0,0 @@
-/**
- * CAIIC-MCM 连接级通道(单例)
- * - 经 uni.$on("bleData") 接收原始字节,FrameDecoder 字节流重组后按帧类型分发
- * - sendFrame:整帧按 (att_mtu-3) 切片串行写入(写特征为 Write Without Response)
- * - sendCli :CLI 命令透传,串行执行(协议要求一条命令应答完再发下一条)
- * - OTA_RSP 帧转发给 OtaManager 注册的 handler
- *
- * 约定:所有接口的 Promise 只 resolve 不 reject,失败通过返回值表达(uts 兼容性考虑)。
- */
-import * as Ble from "../BluetoothManager"
-import { MyApiResult, NotityData } from "../BluetoothManager"
-import {
- FrameDecoder, BleFrame, encodeFrame,
- TYPE_CLI_REQ, TYPE_CLI_RSP, TYPE_CLI_RSP_END, TYPE_OTA_RSP, TYPE_INFO_QUERY, TYPE_INFO_RSP
-} from "./FrameCodec"
-import { asciiToBytes, bytesToAscii, bytesToHex } from "../commonFunctions"
-
-export type CliResult = {
- text: string, // 聚合的应答文本
- status: number // 0=ok 1=未知命令/行超长 -1=超时 -2=命令超长 -3=发送失败 -4=连接断开
-}
-
-type CliJob = {
- textChunks: string[],
- resolve: (r: CliResult) => void,
- timer: number
-}
-
-type InfoJob = {
- resolve: (payload: number[] | null) => void,
- timer: number
-}
-
-const CLI_MAX_CMD_BYTES = 63
-
-let txSeq = 0
-let maxWriteBytes = 20 // 单次 BLE 写入上限 = att_mtu - 3,未协商 MTU 时按 20 兼容
-let decoder = new FrameDecoder()
-let attached = false
-
-let cliJob: CliJob | null = null
-let cliChain: Promise = Promise.resolve(true)
-let writeChain: Promise = Promise.resolve(true)
-
-let infoJob: InfoJob | null = null
-let infoChain: Promise = Promise.resolve(true)
-
-let otaRspHandler: ((payload: number[]) => void) | null = null
-let disconnectHandler: (() => void) | null = null
-
-// ================= 接收 =================
-
-function dispatchFrame(frame: BleFrame) {
- console.log("[MCM] RX frame type=0x" + frame.type.toString(16) + " seq=" + frame.seq + " payload=" + bytesToHex(frame.payload))
- if (frame.type == TYPE_CLI_RSP) {
- const job = cliJob
- if (job != null) {
- job.textChunks.push(bytesToAscii(frame.payload))
- }
- } else if (frame.type == TYPE_CLI_RSP_END) {
- const job = cliJob
- if (job != null) {
- cliJob = null
- clearTimeout(job.timer)
- let text = ""
- for (let i = 0; i < job.textChunks.length; i++) {
- text += job.textChunks[i]
- }
- const status = frame.payload.length > 0 ? frame.payload[0] : 1
- job.resolve({ text: text, status: status } as CliResult)
- }
- } else if (frame.type == TYPE_OTA_RSP) {
- const h = otaRspHandler
- if (h != null) {
- h(frame.payload)
- }
- } else if (frame.type == TYPE_INFO_RSP) {
- const job = infoJob
- if (job != null) {
- infoJob = null
- clearTimeout(job.timer)
- job.resolve(frame.payload)
- }
- }
-}
-
-function onBleData(res: MyApiResult) {
- if (res.type != 0) return
- // 插件 notify 回调的 data 是 NotityData 结构({data: number[], mac, serviceId, ...}),
- // 不是裸字节数组,需取 .data 字段
- const nd = res.data as NotityData
- console.log("[MCM] RX notify " + nd.data.length + "B: " + bytesToHex(nd.data))
- const frames = decoder.feed(nd.data)
- for (let i = 0; i < frames.length; i++) {
- dispatchFrame(frames[i])
- }
-}
-
-function onBleState(state: number) {
- // 10000=连接失败 10001=已断开(见 home 页既有约定)
- if (state == 10000 || state == 10001) {
- resetChannel()
- const h = disconnectHandler
- if (h != null) h()
- }
-}
-
-/** 断连/异常时复位通道:挂起的 CLI 命令以 -4 结束,挂起的信息查询以 null 结束 */
-export function resetChannel() {
- decoder.reset()
- txSeq = 0
- const job = cliJob
- if (job != null) {
- cliJob = null
- clearTimeout(job.timer)
- let text = ""
- for (let i = 0; i < job.textChunks.length; i++) {
- text += job.textChunks[i]
- }
- job.resolve({ text: text, status: -4 } as CliResult)
- }
- const iJob = infoJob
- if (iJob != null) {
- infoJob = null
- clearTimeout(iJob.timer)
- iJob.resolve(null)
- }
-}
-
-// ================= 生命周期 =================
-
-/** 注册全局蓝牙事件监听(幂等,App onLaunch 时调用一次) */
-export function attach() {
- if (attached) return
- attached = true
- uni.$on("bleData", (res: any) => onBleData(res as MyApiResult))
- uni.$on("bleState", (state: any) => onBleState(state as number))
-}
-
-/** 记录协商后的 att_mtu,写切片大小 = mtu - 3(协议上限 244) */
-export function setMtuSize(mtu: number) {
- let w = mtu - 3
- if (w > 244) w = 244
- if (w < 20) w = 20
- maxWriteBytes = w
-}
-
-export function getMaxWriteBytes(): number {
- return maxWriteBytes
-}
-
-export function setOtaRspHandler(h: ((payload: number[]) => void) | null) {
- otaRspHandler = h
-}
-
-export function setDisconnectHandler(h: (() => void) | null) {
- disconnectHandler = h
-}
-
-// ================= 发送 =================
-
-function sendOnce(chunk: number[]): Promise {
- return new Promise((resolve) => {
- // 设备下行特征仅支持 Write Without Response → writeType=1
- Ble.sendDataBytes(chunk, 1, (res: MyApiResult) => resolve(res))
- })
-}
-
-async function sendFrameImpl(type: number, payload: number[]): Promise {
- const frame = encodeFrame(type, txSeq & 0xFF, payload)
- txSeq = (txSeq + 1) & 0xFF
- console.log("[MCM] TX frame type=0x" + type.toString(16) + " len=" + frame.length + ": " + bytesToHex(frame))
- let offset = 0
- while (offset < frame.length) {
- const chunkLen = Math.min(frame.length - offset, maxWriteBytes)
- const chunk = [] as number[]
- for (let i = 0; i < chunkLen; i++) {
- chunk.push(frame[offset + i])
- }
- const res = await sendOnce(chunk)
- if (res.type != 0) {
- console.log("[MCM] TX write failed: type=" + res.type + " msg=" + res.message)
- return false
- }
- offset += chunkLen
- }
- return true
-}
-
-/** 发送一个协议帧(写队列串行化,帧间不会交错) */
-export function sendFrame(type: number, payload: number[]): Promise {
- const p = writeChain.then((): Promise => sendFrameImpl(type, payload))
- writeChain = p
- return p
-}
-
-function sendCliImpl(cmd: string, timeoutMs: number): Promise {
- return new Promise((resolve) => {
- // 固件 CLI_REQ 为整行 payload 直接执行(协议 V1.1:无结束符)
- const payload = asciiToBytes(cmd)
- if (payload.length > CLI_MAX_CMD_BYTES) {
- resolve({ text: "", status: -2 } as CliResult)
- return
- }
- const job: CliJob = {
- textChunks: [] as string[],
- resolve: resolve,
- timer: 0
- } as CliJob
- job.timer = setTimeout(() => {
- if (cliJob == job) {
- cliJob = null
- let text = ""
- for (let i = 0; i < job.textChunks.length; i++) {
- text += job.textChunks[i]
- }
- resolve({ text: text, status: -1 } as CliResult)
- }
- }, timeoutMs)
- cliJob = job
- sendFrame(TYPE_CLI_REQ, payload).then((ok) => {
- if (!ok && cliJob == job) {
- cliJob = null
- clearTimeout(job.timer)
- resolve({ text: "", status: -3 } as CliResult)
- }
- })
- })
-}
-
-/** CLI 命令透传:串行执行,聚合 CLI_RSP 文本直到 CLI_RSP_END;只 resolve 不 reject */
-export function sendCli(cmd: string, timeoutMs: number = 3000): Promise {
- attach()
- const p = cliChain.then((): Promise => sendCliImpl(cmd, timeoutMs))
- cliChain = p.then((): boolean => true)
- return p
-}
-
-// ================= 信息查询(INFO_QUERY / INFO_RSP) =================
-
-function queryInfoImpl(items: number[], timeoutMs: number): Promise {
- return new Promise((resolve) => {
- const job: InfoJob = {
- resolve: resolve,
- timer: 0
- } as InfoJob
- job.timer = setTimeout(() => {
- if (infoJob == job) {
- infoJob = null
- resolve(null)
- }
- }, timeoutMs)
- infoJob = job
- // payload = {item_id u8}×n;空数组 = 查询全部
- sendFrame(TYPE_INFO_QUERY, items).then((ok) => {
- if (!ok && infoJob == job) {
- infoJob = null
- clearTimeout(job.timer)
- resolve(null)
- }
- })
- })
-}
-
-/**
- * 设备信息查询:串行执行,返回 INFO_RSP 的 TLV payload;
- * 超时/发送失败/断连返回 null;只 resolve 不 reject
- */
-export function queryInfo(items: number[], timeoutMs: number = 2000): Promise {
- attach()
- const p = infoChain.then((): Promise => queryInfoImpl(items, timeoutMs))
- infoChain = p.then((): boolean => true)
- return p
-}
diff --git a/SmartAssiter/src/mcm/McmCli.ts b/SmartAssiter/src/mcm/McmCli.ts
new file mode 100644
index 0000000..8c7b850
--- /dev/null
+++ b/SmartAssiter/src/mcm/McmCli.ts
@@ -0,0 +1,91 @@
+/**
+ * CAIIC-MCM CLI 通道(读写特征方式,不依赖 notify)
+ *
+ * 链路:手机把原始命令行写入 CLI 特征 ...c72e0003(write-with-response),
+ * 固件在写回调里同步执行命令并把输出捕获到静态缓冲;手机随后读同一特征
+ * 取回输出文本(≤512B,单读实际上限为 mtu-1,建议连接后先协商 MTU 247)。
+ *
+ * 所有命令经 Promise 链串行执行,避免并发写/读交错。
+ */
+import * as Ble from "../BluetoothManager"
+import { MyApiResult } from "../BluetoothManager"
+
+// 固件约束:命令行 ≤63B(app_ble_proto.c CLI_RW_CMD_MAX)
+const CLI_CMD_MAX = 63
+// 写成功到读应答之间的等待(固件同步执行命令,理论可立即读,留一点余量)
+const CLI_RSP_DELAY_MS = 30
+
+export type CliResult = {
+ text: string, // 命令输出原文
+ status: number // 0=ok 1=未知命令/行超长 -1=超时 -2=行超长(本地拦截) -3=写入失败 -4=未连接
+}
+
+let cliChain: Promise = Promise.resolve()
+
+function toBytes(s: string): number[] {
+ const out = [] as number[]
+ for (let i = 0; i < s.length; i++) {
+ out.push(s.charCodeAt(i) & 0xFF)
+ }
+ return out
+}
+
+function toText(arr: number[]): string {
+ let s = ""
+ for (let i = 0; i < arr.length; i++) {
+ s += String.fromCharCode(arr[i])
+ }
+ return s
+}
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => {
+ setTimeout(() => { resolve() }, ms)
+ })
+}
+
+/**
+ * 执行一行 CLI 命令并取回输出文本。所有调用串行排队;只 resolve 不 reject。
+ */
+export function execCli(cmd: string, timeoutMs: number = 5000): Promise {
+ const p = cliChain.then(() => doExecCli(cmd, timeoutMs))
+ cliChain = p.catch(() => {})
+ return p
+}
+
+async function doExecCli(cmd: string, timeoutMs: number): Promise {
+ if (!Ble.isConnected()) {
+ return { text: "", status: -4 } as CliResult
+ }
+ const bytes = toBytes(cmd)
+ if (bytes.length == 0 || bytes.length > CLI_CMD_MAX) {
+ return { text: "", status: -2 } as CliResult
+ }
+
+ const op = (async (): Promise => {
+ const wr = await new Promise((resolve) => {
+ Ble.writeCliChar(bytes, (res: MyApiResult) => { resolve(res) })
+ })
+ if (wr.type != 0) {
+ return { text: "", status: wr.type == 10001 ? -4 : -3 } as CliResult
+ }
+ await delay(CLI_RSP_DELAY_MS)
+ const rd = await new Promise((resolve) => {
+ Ble.readChar(Ble.MCM_CLI_UUID, (res: MyApiResult) => { resolve(res) })
+ })
+ if (rd.type != 0) {
+ const st = rd.type == 10001 ? -4 : -1
+ return { text: "", status: st } as CliResult
+ }
+ const text = toText(rd.data as number[])
+ // 固件对无输出的成功命令回 "ok\r\n",对未知命令/超长行回 "unknown command\r\n"
+ const status = text == "unknown command\r\n" ? 1 : 0
+ return { text: text, status: status } as CliResult
+ })()
+
+ // 总超时兜底:超时后底层读请求自行收尾(读队列内部有独立超时)
+ return await Promise.race([
+ op,
+ delay(timeoutMs).then(() => ({ text: "", status: -1 } as CliResult))
+ ])
+}
diff --git a/SmartAssiter/src/mcm/McmCommands.ts b/SmartAssiter/src/mcm/McmCommands.ts
index 24d84a8..414f8d4 100644
--- a/SmartAssiter/src/mcm/McmCommands.ts
+++ b/SmartAssiter/src/mcm/McmCommands.ts
@@ -1,51 +1,34 @@
/**
- * CAIIC-MCM 业务命令封装
- * 温度/风扇/固件版本走 INFO_QUERY(协议 V1.1 第 5 节);
- * sysinfo/bankinfo 等系统状态仍走 CLI 透传,命令名集中在顶部常量。
+ * CAIIC-MCM 业务命令封装(读写特征方式,不依赖 notify)
+ * 固件版本走只读参数特征(McmInfo.readDeviceInfo);
+ * sysinfo/bankinfo 等系统状态走 CLI 特征透传,命令名集中在顶部常量。
*/
-import { sendCli, CliResult } from "./McmChannel"
-import {
- queryDeviceInfo, formatVersion,
- INFO_FW_VERSION, INFO_CHIP_TEMP, INFO_FAN_RPM
-} from "./McmInfo"
+import { execCli, CliResult } from "./McmCli"
+import { readDeviceInfo, formatVersion } from "./McmInfo"
const CMD_SYSINFO = "sysinfo"
const CMD_BANKINFO = "bankinfo"
-/** 固件版本号,如 "V1.00.01"(来自 INFO_QUERY FW_VERSION);失败返回 null */
+/** 固件版本号,如 "V1.00.08"(参数特征 TLV 的 FW_VERSION 项);失败返回 null */
export async function getVersion(): Promise {
- const info = await queryDeviceInfo([INFO_FW_VERSION])
+ const info = await readDeviceInfo()
const v = info.fwVersion
return v != null ? formatVersion(v) : null
}
/** 系统运行状态原文(运行时间/任务数/heap/任务表),失败返回 null */
export async function getSysInfo(): Promise {
- const r: CliResult = await sendCli(CMD_SYSINFO, 5000)
+ const r: CliResult = await execCli(CMD_SYSINFO, 5000)
if (r.status != 0) return null
return r.text
}
/** bank 信息原文(当前运行 bank 与 bootsetting),失败返回 null */
export async function getBankInfo(): Promise {
- const r: CliResult = await sendCli(CMD_BANKINFO)
+ const r: CliResult = await execCli(CMD_BANKINFO)
if (r.status != 0) return null
return r.text
}
-/** 温度(℃),读取失败返回 null */
-export async function getTemperature(): Promise {
- const info = await queryDeviceInfo([INFO_CHIP_TEMP])
- return info.chipTemp
-}
-
-/** 风扇转速(RPM),无测速硬件/读取失败返回 null */
-export async function getFanSpeed(): Promise {
- const info = await queryDeviceInfo([INFO_FAN_RPM])
- return info.fanRpm
-}
-
-/** 通用 CLI 命令(调试终端用) */
-export async function execCli(cmd: string, timeoutMs: number = 5000): Promise {
- return await sendCli(cmd, timeoutMs)
-}
+export { execCli }
+export type { CliResult }
diff --git a/SmartAssiter/src/mcm/McmInfo.ts b/SmartAssiter/src/mcm/McmInfo.ts
index 352f9d4..d871db6 100644
--- a/SmartAssiter/src/mcm/McmInfo.ts
+++ b/SmartAssiter/src/mcm/McmInfo.ts
@@ -1,10 +1,9 @@
/**
- * CAIIC-MCM 设备信息查询(协议 V1.1 第 5 节:INFO_QUERY / INFO_RSP)
- * INFO_RSP payload 为 TLV 序列 {id u8, len u8, value…}×n,数值均小端;
- * 设备不认识的 id 不出现在应答中;解析时未知 id 按 len 跳过。
+ * CAIIC-MCM 设备信息(只读参数特征 ...c72e0004)
+ * 读出内容为全部信息项的 TLV 序列 {id u8, len u8, value…}×n,数值均小端,
+ * 与帧协议 INFO_RSP(all) 的 payload 一致;解析时未知 id 按 len 跳过。
+ * 不依赖 notify 通道。
*/
-import { queryInfo } from "./McmChannel"
-import { readU16LE, readI16LE, readU32LE } from "./FrameCodec"
import * as Ble from "../BluetoothManager"
import { MyApiResult } from "../BluetoothManager"
@@ -36,7 +35,20 @@ function emptyInfo(): DeviceInfo {
} as DeviceInfo
}
-/** 解析 INFO_RSP 的 TLV payload;未知 id 按 len 跳过,数值长度不符的项忽略 */
+function readU16LE(b: number[], off: number): number {
+ return (b[off] | (b[off + 1] << 8)) >>> 0
+}
+
+function readI16LE(b: number[], off: number): number {
+ const v = readU16LE(b, off)
+ return v >= 0x8000 ? v - 0x10000 : v
+}
+
+function readU32LE(b: number[], off: number): number {
+ return (b[off] | (b[off + 1] << 8) | (b[off + 2] << 16) | (b[off + 3] << 24)) >>> 0
+}
+
+/** 解析 TLV 序列;未知 id 按 len 跳过,数值长度不符的项忽略 */
export function parseTlv(payload: number[]): DeviceInfo {
const info = emptyInfo()
let i = 0
@@ -67,18 +79,8 @@ export function parseTlv(payload: number[]): DeviceInfo {
}
/**
- * 查询设备信息;items 为空数组 = 查询全部。
- * 超时/断连/发送失败返回各字段全 null 的 DeviceInfo。
- */
-export async function queryDeviceInfo(items: number[]): Promise {
- const payload = await queryInfo(items, 2000)
- if (payload == null) return emptyInfo()
- return parseTlv(payload)
-}
-
-/**
- * 读只读参数特征(MCM_INFO_UUID,内容为全部信息项 TLV,同 INFO_RSP(all))。
- * 不依赖 notify 通道;失败/超时返回各字段全 null 的 DeviceInfo。
+ * 读只读参数特征(MCM_INFO_UUID,内容为全部信息项 TLV)。
+ * 失败/超时返回各字段全 null 的 DeviceInfo。
*/
export async function readDeviceInfo(): Promise {
const payload = await new Promise((resolve) => {
diff --git a/SmartAssiter/src/mcm/OtaManager.ts b/SmartAssiter/src/mcm/OtaManager.ts
deleted file mode 100644
index 1450104..0000000
--- a/SmartAssiter/src/mcm/OtaManager.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-/**
- * CAIIC-MCM BLE OTA 升级状态机(协议第 5 节:双 bank 直写)
- * 流程:OTA_BEGIN{size,crc32,version} → 等 OTA_RSP(0x10,ok)
- * → OTA_DATA{offset,data} 按 4KB 扇区发送,满扇区等 OTA_RSP(0x11,ok) 作流控
- * (status=2 时按应答 offset 重发重同步)
- * → OTA_END{crc32} → 等 OTA_RSP(0x12,ok) → 设备 200ms 后自动复位切 bank
- * 约定:Promise 只 resolve 不 reject;断连或 abort() 协作式中止。
- */
-import {
- sendFrame, setOtaRspHandler, setDisconnectHandler
-} from "./McmChannel"
-import {
- TYPE_OTA_BEGIN, TYPE_OTA_DATA, TYPE_OTA_END,
- MAX_PAYLOAD, pushU32LE, readU32LE
-} from "./FrameCodec"
-import { crc32Ieee } from "./Crc"
-
-export type OtaCallbacks = {
- onProgress: (percent: number) => void, // 0~100
- onState: (msg: string) => void
-}
-
-type OtaAck = {
- received: boolean, // false=超时
- status: number, // 0 ok / 1 bad_frame / 2 bad_state(offset 乱序) / 3 size_too_big / 4 crc_fail / 5 flash_fail
- offset: number
-}
-
-type OtaWaiter = {
- echo: number,
- timer: number,
- resolve: (ack: OtaAck) => void
-}
-
-const BANK_MAX = 0x38000 // 224KB bank 上限
-const SECTOR_SIZE = 4096 // 设备 4KB 扇区缓冲,攒满回 ack
-const DATA_PER_FRAME = MAX_PAYLOAD - 4 // OTA_DATA 帧数据段上限(4 字节 offset + 476 数据 = 480)
-
-let waiter: OtaWaiter | null = null
-let aborted = false
-let running = false
-
-function statusText(status: number): string {
- switch (status) {
- case 1: return "bad_frame"
- case 2: return "bad_state"
- case 3: return "size_too_big"
- case 4: return "crc_fail"
- case 5: return "flash_fail"
- }
- return "unknown(" + status + ")"
-}
-
-function onOtaRsp(payload: number[]) {
- // payload: {cmd_echo u8, status u8, offset u32 LE}
- if (payload.length < 6) return
- const w = waiter
- if (w == null) return
- if (payload[0] != w.echo) return // 非当前等待的应答(如末扇区 ack 迟到),忽略
- waiter = null
- clearTimeout(w.timer)
- w.resolve({ received: true, status: payload[1], offset: readU32LE(payload, 2) } as OtaAck)
-}
-
-function waitRsp(echo: number, timeoutMs: number): Promise {
- return new Promise((resolve) => {
- const w: OtaWaiter = { echo: echo, timer: 0, resolve: resolve } as OtaWaiter
- w.timer = setTimeout(() => {
- if (waiter == w) {
- waiter = null
- resolve({ received: false, status: -1, offset: 0 } as OtaAck)
- }
- }, timeoutMs)
- waiter = w
- })
-}
-
-export function isRunning(): boolean {
- return running
-}
-
-/** 协作式中止:发送循环在下一扇区边界停止 */
-export function abort() {
- aborted = true
- const w = waiter
- if (w != null) {
- waiter = null
- clearTimeout(w.timer)
- w.resolve({ received: false, status: -2, offset: 0 } as OtaAck)
- }
-}
-
-async function run(image: number[], version: number, cb: OtaCallbacks): Promise {
- const total = image.length
- const crc = crc32Ieee(image)
-
- // 1. OTA_BEGIN
- const pb = [] as number[]
- pushU32LE(pb, total)
- pushU32LE(pb, crc)
- pushU32LE(pb, version)
- cb.onState("Sending OTA_BEGIN (" + total + " bytes)...")
- if (!await sendFrame(TYPE_OTA_BEGIN, pb)) {
- cb.onState("Failed to send OTA_BEGIN")
- return false
- }
- let ack = await waitRsp(TYPE_OTA_BEGIN, 5000)
- if (!ack.received) {
- cb.onState(ack.status == -2 ? "Upgrade aborted" : "Timeout waiting for OTA_BEGIN ack")
- return false
- }
- if (ack.status != 0) {
- cb.onState("Device rejected upgrade: " + statusText(ack.status))
- return false
- }
-
- // 2. OTA_DATA:按 4KB 扇区发送,满扇区等 ack(流控)
- let offset = 0
- while (offset < total) {
- if (aborted) {
- cb.onState("Upgrade aborted")
- return false
- }
- const sectorEnd = Math.min(offset + SECTOR_SIZE, total)
- let pos = offset
- while (pos < sectorEnd) {
- const n = Math.min(sectorEnd - pos, DATA_PER_FRAME)
- const payload = [] as number[]
- pushU32LE(payload, pos)
- for (let i = 0; i < n; i++) {
- payload.push(image[pos + i])
- }
- if (!await sendFrame(TYPE_OTA_DATA, payload)) {
- cb.onState("Failed to write OTA_DATA, offset=" + pos)
- return false
- }
- pos += n
- cb.onProgress(Math.floor(pos * 100 / total))
- }
- if (sectorEnd - offset >= SECTOR_SIZE) {
- // 满扇区:设备擦写完成后回 OTA_RSP(0x11)(擦写期间关中断数十 ms,超时给足)
- ack = await waitRsp(TYPE_OTA_DATA, 8000)
- if (!ack.received) {
- cb.onState(ack.status == -2 ? "Upgrade aborted" : "Timeout waiting for sector ack, offset=" + offset)
- return false
- }
- if (ack.status == 2) {
- // offset 乱序:从设备期望的 offset 重发重同步
- offset = ack.offset
- cb.onState("Resync to offset=" + offset)
- continue
- }
- if (ack.status != 0) {
- cb.onState("Device write error: " + statusText(ack.status))
- return false
- }
- }
- offset = sectorEnd
- }
-
- // 3. OTA_END:设备 flush 尾部并整镜像回读校验 CRC32
- const pe = [] as number[]
- pushU32LE(pe, crc)
- cb.onState("Sending OTA_END, verifying image on device...")
- if (!await sendFrame(TYPE_OTA_END, pe)) {
- cb.onState("Failed to send OTA_END")
- return false
- }
- ack = await waitRsp(TYPE_OTA_END, 15000)
- if (!ack.received) {
- cb.onState(ack.status == -2 ? "Upgrade aborted" : "Timeout waiting for OTA_END ack")
- return false
- }
- if (ack.status != 0) {
- cb.onState("Image verification failed: " + statusText(ack.status))
- return false
- }
- cb.onProgress(100)
- cb.onState("Upgrade succeeded. Device will reboot into the new firmware in ~200ms, please reconnect.")
- return true
-}
-
-/**
- * 开始 OTA 升级
- * @param image 固件 bin 文件全部字节
- * @param version 版本号 u32,如 0x010002 表示 V1.00.02
- */
-export async function start(image: number[], version: number, cb: OtaCallbacks): Promise {
- if (running) {
- cb.onState("Upgrade already in progress")
- return false
- }
- if (image.length < 4 || image.length > BANK_MAX) {
- cb.onState("Invalid image size (4 ~ " + BANK_MAX + " bytes)")
- return false
- }
- running = true
- aborted = false
- setOtaRspHandler((payload: number[]) => onOtaRsp(payload))
- setDisconnectHandler(() => {
- aborted = true
- abort()
- })
- const ok = await run(image, version, cb)
- running = false
- setOtaRspHandler(null)
- setDisconnectHandler(null)
- const w = waiter
- if (w != null) {
- waiter = null
- clearTimeout(w.timer)
- }
- return ok
-}