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 -}