SmartAssiter去notify重设计:BLE通信切换到固件读写特征接口(CLI特征...c72e0003写命令读应答,只读参数特征...c72e0004 TLV);删除帧协议/notify代码(FrameCodec/McmChannel/OtaManager/Crc);BluetoothManager重写(通用readChar读队列+writeCliChar,scanServices校验特征);连接就绪信号改为bleState 0;OTA页暂停用(notify上行不通待固件修复)
This commit is contained in:
parent
9366d116c4
commit
c65809a6f1
@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { MyApiResult } from "@/src/BluetoothManager"
|
||||
import * as Ble from "@/src/BluetoothManager"
|
||||
import * as McmChannel from "@/src/mcm/McmChannel"
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
let firstBackTime = 0
|
||||
@ -9,20 +8,8 @@ import * as McmChannel from "@/src/mcm/McmChannel"
|
||||
export default {
|
||||
|
||||
globalData: {
|
||||
// 接收蓝牙数据的回调(显式指定 CAIIC-MCM 服务/notify UUID,基于 uni 内置 BLE API)
|
||||
setBleNotity(){
|
||||
Ble.onNotityReadBleData(true,
|
||||
function(res: MyApiResult){
|
||||
if (res.type != 0) {
|
||||
// 1000=订阅成功 1001=订阅失败(type=0 的 notify 数据由 McmChannel 打印)
|
||||
console.log("[MCM] notify subscribe result: type=" + res.type + " msg=" + res.message)
|
||||
}
|
||||
uni.$emit("bleData", res)
|
||||
}
|
||||
)
|
||||
|
||||
},
|
||||
// 连接蓝牙
|
||||
// 连接蓝牙:connect → 服务/特征校验(CLI 读写特征 + 参数只读特征),
|
||||
// 就绪后发 bleState 0(不再订阅 notify,固件 notify 链路已停用)
|
||||
ConnectBle(address: string, autoReconnect: boolean){
|
||||
Ble.connect(address, autoReconnect, function(res: MyApiResult){
|
||||
console.log("[MCM] connect " + address + ": type=" + res.type + " msg=" + res.message)
|
||||
@ -30,8 +17,7 @@ import * as McmChannel from "@/src/mcm/McmChannel"
|
||||
Ble.scanServices(function(resSevice: MyApiResult){
|
||||
console.log("[MCM] scanServices: type=" + resSevice.type + " msg=" + resSevice.message)
|
||||
if (resSevice.type == 0) {
|
||||
(getApp().globalData as any).setBleNotity()
|
||||
uni.$emit("bleState", res.type)
|
||||
uni.$emit("bleState", 0)
|
||||
} else {
|
||||
uni.$emit("bleState", 10000)
|
||||
}
|
||||
@ -45,8 +31,6 @@ import * as McmChannel from "@/src/mcm/McmChannel"
|
||||
},
|
||||
onLaunch: function () {
|
||||
console.log('App Launch')
|
||||
// 注册 CAIIC-MCM 协议通道的全局蓝牙事件监听
|
||||
McmChannel.attach()
|
||||
},
|
||||
onShow: function () {
|
||||
console.log('App Show')
|
||||
|
||||
62
SmartAssiter/docs/修改记录_2026-09-04_App去notify重设计.md
Normal file
62
SmartAssiter/docs/修改记录_2026-09-04_App去notify重设计.md
Normal file
@ -0,0 +1,62 @@
|
||||
# 修改记录 2026-09-04:App 去 notify 重设计(读写特征方式)
|
||||
|
||||
背景:固件 mcm-ddc-ble V1.00.08 起 notify 上行链路不通(帧协议 CLI/INFO_QUERY/OTA
|
||||
应答收不到,下行写入正常),固件已新增两条绕开 notify 的 GATT 接口。本次 App
|
||||
(SmartAssiter)整体切换到新接口,**彻底删除旧的 notify/帧协议代码**。
|
||||
|
||||
## 固件当前可用接口(不再使用 ...c72e0001 下行帧 / ...c72e0002 notify)
|
||||
|
||||
- CLI 读写特征 `00002760-08c2-11e1-9073-0e8ac72e0003`(RD + WRITE_REQ):
|
||||
写原始命令行(≤63B,write-with-response,固件在写回调里同步执行)→
|
||||
读同一特征取回输出文本(≤512B,单读实际上限 mtu-1,连接后协商 MTU 247)。
|
||||
无输出的成功命令回 `ok\r\n`;未知命令/超长行回 `unknown command\r\n`。
|
||||
- 只读参数特征 `00002760-08c2-11e1-9073-0e8ac72e0004`(RD):
|
||||
读出全部信息项 TLV,同 INFO_RSP(all)。信息项 id 不变
|
||||
(0x01 FW_VERSION / 0x02 CHIP_TEMP / 0x03 FAN_RPM / 0x04 VDD_MV /
|
||||
0x05 UPTIME_S / 0x06 FREE_HEAP)。
|
||||
|
||||
## 通信层改动
|
||||
|
||||
- `src/BluetoothManager.ts` 重写:
|
||||
- 删除 notify 订阅(`onNotityReadBleData`)、帧协议写(`sendDataBytes`)、
|
||||
`MCM_WRITE_UUID`/`MCM_NOTIFY_UUID`/`NotityData`,以及空实现兼容残留
|
||||
(`connectTimeout`/`setBtAutoConnectTime`/`cancelAutoConnectBt`/`OpenStateListener`)。
|
||||
- 新增 `writeCliChar(data, cb)`(CLI 特征 write-with-response)与通用
|
||||
`readChar(charId, cb)`:读结果经 `onBLECharacteristicValueChange` 回传,
|
||||
内部读队列串行执行、3s 超时、断连时全部置失败;`readInfoChar` 保留为
|
||||
`readChar(MCM_INFO_UUID)` 的便捷封装。
|
||||
- `scanServices` 增加 CLI/INFO 特征存在性校验(缺失报 10005)。
|
||||
- `bleState` 事件语义不变(0=连接就绪 10000=失败 10001=断开),
|
||||
**0 现在即"连接可用"信号**(原为 notify 订阅成功的 bleData type=1000)。
|
||||
- `src/mcm/` 重构:
|
||||
- **删除** `FrameCodec.ts`(帧编解码)、`McmChannel.ts`(notify 帧通道)、
|
||||
`OtaManager.ts`(OTA 状态机)、`Crc.ts`(CRC16/32)。
|
||||
- 新增 `McmCli.ts`:`execCli(cmd, timeoutMs)` 写命令→读应答,Promise 链串行;
|
||||
`CliResult{ text, status }`(0=ok 1=未知命令/超长 -1=超时 -2=本地超长 -3=写失败 -4=未连接)。
|
||||
- `McmCommands.ts` 重写:getVersion 改走 `readDeviceInfo` 的 FW_VERSION 项;
|
||||
getSysInfo/getBankInfo/execCli 走 `McmCli`;删除无调用方的
|
||||
getTemperature/getFanSpeed。
|
||||
- `McmInfo.ts`:删除 `queryDeviceInfo`(INFO_QUERY 帧路径),
|
||||
LE 读取 helper 内聚到本文件,保留 `parseTlv`/`readDeviceInfo`/`formatVersion`。
|
||||
- `App.vue`:删除 `setBleNotity` 与 `bleData` 事件转发、`McmChannel.attach()`;
|
||||
`ConnectBle` = connect → scanServices(含特征校验)→ `$emit("bleState", 0)`。
|
||||
|
||||
## 页面适配
|
||||
|
||||
- `home.vue`(Device):连接就绪信号从 bleData type=1000 改为 bleState 0
|
||||
(onConnectedReady:setMtu 247 → 加载版本/bank/sysinfo → 启动 2s 参数轮询)。
|
||||
轮询仍走 `readDeviceInfo`(读参数特征),逻辑不变。
|
||||
- `bleTest/bluetoothOperation.vue`(CLI tab):同上切换就绪信号;
|
||||
发命令走 `McmCmd.execCli`(写 CLI 特征 + 读应答),输入输出路径不变。
|
||||
- `ota.vue`:**OTA 暂停用**——当前固件 OTA 应答依赖 notify(链路不通),
|
||||
页面改为显示当前固件/bank 信息 + 中英文停用说明,待固件修复后恢复。
|
||||
|
||||
## 遗留与注意
|
||||
|
||||
- CLI 应答单次读上限 = mtu-1:MTU 247 协商失败时(iOS/部分机型)长输出
|
||||
(如 help/sysinfo 全文)会被截断到 19B,属已知限制。
|
||||
- 命令行 >20B 时同样依赖 MTU 247 协商成功(ATT 写不可分割)。
|
||||
- 旧帧协议/notify 代码已彻底删除;固件修好 notify 后如需恢复 OTA,
|
||||
参考 git 历史中的 OtaManager/McmChannel。
|
||||
- 验证:esbuild 语法检查全部通过(src/mcm 4 文件 + 4 个 .vue script 块);
|
||||
真机回归(连接、温度/风扇轮询、CLI 终端 help/sysinfo/led)待烧录固件后进行。
|
||||
@ -49,9 +49,8 @@ import { ref } from 'vue'
|
||||
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
|
||||
import { MyApiResult } from "../../../src/BluetoothManager"
|
||||
import * as Ble from "../../../src/BluetoothManager"
|
||||
import * as McmChannel from "../../../src/mcm/McmChannel"
|
||||
import * as McmCmd from "../../../src/mcm/McmCommands"
|
||||
import { CliResult } from "../../../src/mcm/McmChannel"
|
||||
import { CliResult } from "../../../src/mcm/McmCli"
|
||||
import * as BleINfo from "../../../common/bleInfo"
|
||||
|
||||
const bleAddress = ref('')
|
||||
@ -88,23 +87,18 @@ function pushMsg(dir: string, text: string) {
|
||||
}
|
||||
|
||||
// ==============================蓝牙事件
|
||||
function onBleDataListener(res: MyApiResult) {
|
||||
if (res.type == 1000) {
|
||||
// notify 订阅成功:协商 MTU 247
|
||||
function onBleStatueListener(state: number) {
|
||||
if (state == 0) {
|
||||
// 连接就绪(connect + 服务/特征校验通过):协商 MTU 247,加大 CLI 应答的单读上限
|
||||
Ble.setMtu(247, (mtuRes: MyApiResult) => {
|
||||
if (mtuRes.type == 0) {
|
||||
McmChannel.setMtuSize(247)
|
||||
pushMsg("message", "set MTU 247")
|
||||
}
|
||||
})
|
||||
bleIsConnect.value = true
|
||||
pushMsg("message", "notify subscribed, ready")
|
||||
pushMsg("message", "connected, ready")
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
function onBleStatueListener(state: number) {
|
||||
if (state == 10000) {
|
||||
} else if (state == 10000) {
|
||||
pushMsg("message", "connect failed")
|
||||
bleIsConnect.value = false
|
||||
uni.hideLoading()
|
||||
@ -120,10 +114,9 @@ function onDisConnect() {
|
||||
Ble.disConnect()
|
||||
}
|
||||
bleIsConnect.value = false
|
||||
McmChannel.resetChannel()
|
||||
}
|
||||
|
||||
// 连接蓝牙(走全局 ConnectBle:connect → scanServices → 订阅 notify,显式 CAIIC-MCM UUID)
|
||||
// 连接蓝牙(走全局 ConnectBle:connect → 服务/特征校验,不再订阅 notify)
|
||||
// 本页是 tab 页,地址取最近连接过的设备(bleInfo),没有则提示去 Device 页连接
|
||||
function onConnect() {
|
||||
if (Ble.isConnected()) {
|
||||
@ -176,14 +169,12 @@ function onGetStatus() {
|
||||
let subscribed = false
|
||||
function subscribe() {
|
||||
if (subscribed) return
|
||||
uni.$on("bleData", onBleDataListener)
|
||||
uni.$on("bleState", onBleStatueListener)
|
||||
subscribed = true
|
||||
}
|
||||
|
||||
function unsubscribe() {
|
||||
if (!subscribed) return
|
||||
uni.$off("bleData", onBleDataListener)
|
||||
uni.$off("bleState", onBleStatueListener)
|
||||
subscribed = false
|
||||
}
|
||||
|
||||
@ -76,7 +76,6 @@ import { ref } from 'vue'
|
||||
import { onLoad, onShow, onHide, onUnload, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { MyApiResult, BleScanResult } from "../../src/BluetoothManager"
|
||||
import * as Ble from "../../src/BluetoothManager"
|
||||
import * as McmChannel from "../../src/mcm/McmChannel"
|
||||
import * as McmCmd from "../../src/mcm/McmCommands"
|
||||
import * as McmInfo from "../../src/mcm/McmInfo"
|
||||
import * as BleINfo from "../../common/bleInfo"
|
||||
@ -146,13 +145,10 @@ async function loadDeviceInfo() {
|
||||
|
||||
// ================= 蓝牙事件 =================
|
||||
|
||||
async function onBleDataListener(res: MyApiResult) {
|
||||
if (res.type == 1000) {
|
||||
// notify 订阅成功:协商 MTU 247(写切片 244),iOS 无效保持 20
|
||||
// 连接就绪(bleState 0:connect + 服务/特征校验已通过):协商 MTU 后加载设备信息
|
||||
function onConnectedReady() {
|
||||
Ble.setMtu(247, (resMtu: MyApiResult) => {
|
||||
if (resMtu.type == 0) {
|
||||
McmChannel.setMtuSize(247)
|
||||
}
|
||||
console.log("[MCM] setMtu 247: type=" + resMtu.type + " msg=" + resMtu.message)
|
||||
})
|
||||
connected.value = true
|
||||
connecting.value = false
|
||||
@ -160,7 +156,6 @@ async function onBleDataListener(res: MyApiResult) {
|
||||
loadDeviceInfo()
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
function onDisConnectUi() {
|
||||
connected.value = false
|
||||
@ -175,8 +170,8 @@ function onDisConnectUi() {
|
||||
|
||||
function onBleStatueListener(state: number) {
|
||||
console.log("home bleState = ", state)
|
||||
if (state == 0) {
|
||||
connecting.value = false
|
||||
if (state == 0) { // 连接就绪
|
||||
onConnectedReady()
|
||||
} else if (state == 10000) { // 连接失败
|
||||
connecting.value = false
|
||||
connected.value = false
|
||||
@ -242,14 +237,12 @@ function onDisconnect() {
|
||||
let subscribed = false
|
||||
function subscribe() {
|
||||
if (subscribed) return
|
||||
uni.$on("bleData", onBleDataListener)
|
||||
uni.$on("bleState", onBleStatueListener)
|
||||
subscribed = true
|
||||
}
|
||||
|
||||
function unsubscribe() {
|
||||
if (!subscribed) return
|
||||
uni.$off("bleData", onBleDataListener)
|
||||
uni.$off("bleState", onBleStatueListener)
|
||||
subscribed = false
|
||||
}
|
||||
|
||||
@ -20,50 +20,22 @@
|
||||
<text class="infoValue">{{ bankInfo }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 固件文件 -->
|
||||
<view class="card">
|
||||
<text class="sectionTitle">Firmware file (Keil bin, ≤224KB)</text>
|
||||
<input class="input" v-model="filePath" placeholder="/sdcard/Download/embeddedSrc.bin" :disabled="upgrading" />
|
||||
<view class="btnRow">
|
||||
<button class="btn" size="mini" :disabled="upgrading" @tap="onLoadFile">Load File</button>
|
||||
</view>
|
||||
<view class="infoRow" v-if="fileLoaded">
|
||||
<text class="infoLabel">File Size</text>
|
||||
<text class="infoValue">{{ fileSize }} bytes</text>
|
||||
</view>
|
||||
<view class="infoRow" v-if="fileLoaded">
|
||||
<text class="infoLabel">Image CRC32</text>
|
||||
<text class="infoValue">0x{{ fileCrcHex }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 目标版本 -->
|
||||
<view class="card">
|
||||
<text class="sectionTitle">Target version (e.g. 1.00.02)</text>
|
||||
<input class="input" v-model="versionText" placeholder="1.00.02" :disabled="upgrading" />
|
||||
</view>
|
||||
|
||||
<!-- 升级控制 -->
|
||||
<view class="card">
|
||||
<view class="progressBox">
|
||||
<view class="progressBar" :style="'width:' + progress + '%'"></view>
|
||||
</view>
|
||||
<text class="progressText">{{ progress }}%</text>
|
||||
<view class="btnRow">
|
||||
<button class="btn" size="mini" v-if="!upgrading" :disabled="!fileLoaded" @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>
|
||||
|
||||
<!-- OTA 停用说明 -->
|
||||
<view class="card">
|
||||
<text class="sectionTitle">OTA Upgrade</text>
|
||||
<text class="warnText">
|
||||
OTA is temporarily unavailable: the current firmware's notify uplink is broken,
|
||||
so upgrade acknowledgements cannot be returned to the phone.
|
||||
This feature will be restored after a firmware update (SWD reflash).
|
||||
</text>
|
||||
<text class="warnText">
|
||||
OTA 暂不可用:当前固件 notify 上行链路异常,升级应答无法回传,
|
||||
待固件更新(SWD 烧录)后恢复。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
<!-- #ifdef APP -->
|
||||
</scroll-view>
|
||||
@ -73,147 +45,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
|
||||
import { MyApiResult } from "../../src/BluetoothManager"
|
||||
import * as Ble from "../../src/BluetoothManager"
|
||||
import * as McmCmd from "../../src/mcm/McmCommands"
|
||||
import * as Ota from "../../src/mcm/OtaManager"
|
||||
import { OtaCallbacks } from "../../src/mcm/OtaManager"
|
||||
import { crc32Ieee } from "../../src/mcm/Crc"
|
||||
|
||||
const connected = ref<boolean>(false)
|
||||
const curVersion = ref<string>("--")
|
||||
const bankInfo = ref<string>("--")
|
||||
const filePath = ref<string>("/sdcard/Download/embeddedSrc.bin")
|
||||
const fileLoaded = ref<boolean>(false)
|
||||
const fileSize = ref<number>(0)
|
||||
const fileCrcHex = ref<string>("")
|
||||
const versionText = ref<string>("")
|
||||
const upgrading = ref<boolean>(false)
|
||||
const progress = ref<number>(0)
|
||||
const logs = ref<string[]>([])
|
||||
|
||||
let imageBytes = [] as number[]
|
||||
|
||||
function addLog(msg: string) {
|
||||
logs.value.push(msg)
|
||||
}
|
||||
|
||||
// ================= 文件读取(App 本地文件,plus.io) =================
|
||||
|
||||
function readFileBytes(path: string, cb: (bytes: number[] | null) => void) {
|
||||
// #ifdef APP-PLUS
|
||||
const plusIo = (window as any).plus?.io
|
||||
if (plusIo == null) {
|
||||
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
|
||||
}
|
||||
|
||||
function onLoadFile() {
|
||||
readFileBytes(filePath.value.trim(), (bytes) => {
|
||||
if (bytes == null) {
|
||||
fileLoaded.value = false
|
||||
uni.showToast({ title: 'Failed to read file, check the path', icon: 'error' })
|
||||
return
|
||||
}
|
||||
if (bytes.length > 0x38000) {
|
||||
fileLoaded.value = false
|
||||
uni.showToast({ title: 'File exceeds the 224KB limit', icon: 'error' })
|
||||
return
|
||||
}
|
||||
imageBytes = bytes
|
||||
fileSize.value = bytes.length
|
||||
fileCrcHex.value = crc32Ieee(bytes).toString(16)
|
||||
fileLoaded.value = true
|
||||
addLog("File loaded: " + bytes.length + " bytes, CRC32=0x" + fileCrcHex.value)
|
||||
})
|
||||
}
|
||||
|
||||
// ================= 版本号解析 "1.00.02" -> 0x010002 =================
|
||||
|
||||
function parseVersion(v: string): number | null {
|
||||
const parts = v.trim().split(".")
|
||||
if (parts.length != 3) return null
|
||||
const a = parseInt(parts[0])
|
||||
const b = parseInt(parts[1])
|
||||
const c = parseInt(parts[2])
|
||||
if (isNaN(a) || isNaN(b) || isNaN(c)) return null
|
||||
if (a < 0 || a > 255 || b < 0 || b > 255 || c < 0 || c > 255) return null
|
||||
return a * 65536 + b * 256 + c
|
||||
}
|
||||
|
||||
// ================= OTA =================
|
||||
|
||||
async function onStartOta() {
|
||||
const version = parseVersion(versionText.value)
|
||||
if (version == null) {
|
||||
uni.showToast({ title: 'Invalid version format (e.g. 1.00.02)', icon: 'error' })
|
||||
return
|
||||
}
|
||||
if (!Ble.isConnected()) {
|
||||
uni.showToast({ title: 'Device not connected', icon: 'error' })
|
||||
return
|
||||
}
|
||||
upgrading.value = true
|
||||
progress.value = 0
|
||||
logs.value = []
|
||||
addLog("Upgrade started, target version " + versionText.value)
|
||||
const cbs: OtaCallbacks = {
|
||||
onProgress: (percent: number) => {
|
||||
progress.value = percent
|
||||
},
|
||||
onState: (msg: string) => {
|
||||
addLog(msg)
|
||||
}
|
||||
} as OtaCallbacks
|
||||
const ok = await Ota.start(imageBytes, version, cbs)
|
||||
upgrading.value = false
|
||||
if (ok) {
|
||||
uni.showToast({ title: 'Upgrade succeeded, device will reboot', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: 'Upgrade failed', icon: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
function onAbortOta() {
|
||||
Ota.abort()
|
||||
addLog("Abort requested...")
|
||||
}
|
||||
|
||||
// ================= 设备信息 =================
|
||||
|
||||
@ -229,12 +66,11 @@ async function loadDeviceInfo() {
|
||||
// ================= 蓝牙状态监听 =================
|
||||
|
||||
function onBleStatueListener(state: number) {
|
||||
if (state == 10000 || state == 10001) {
|
||||
if (state == 0) {
|
||||
connected.value = true
|
||||
loadDeviceInfo()
|
||||
} else if (state == 10000 || state == 10001) {
|
||||
connected.value = false
|
||||
if (upgrading.value) {
|
||||
addLog("Disconnected, upgrade aborted")
|
||||
upgrading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -271,10 +107,6 @@ onHide(() => {
|
||||
|
||||
onUnload(() => {
|
||||
unsubscribe()
|
||||
// 离开页面时若仍在升级则中止(升级期间不建议离开)
|
||||
if (Ota.isRunning()) {
|
||||
Ota.abort()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -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)
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
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,24 +273,11 @@ 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: () => {
|
||||
@ -274,20 +289,6 @@ export function connect(mac: string, autoReconnect: boolean, cb: (res: MyApiResu
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 设置蓝牙连接超时时间(ms)
|
||||
export function connectTimeout(timeout: number): void {
|
||||
connTimeoutMs = timeout
|
||||
}
|
||||
|
||||
// 设置自动连接间隔(保留兼容,未实现)
|
||||
export function setBtAutoConnectTime(timeout: number): void {
|
||||
}
|
||||
|
||||
// 取消自动连接蓝牙(保留兼容,未实现)
|
||||
export function cancelAutoConnectBt(): void {
|
||||
}
|
||||
|
||||
// 断开蓝牙
|
||||
export function disConnect() {
|
||||
@ -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,25 +370,10 @@ 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,
|
||||
characteristicId: MCM_CLI_UUID,
|
||||
value: buf.buffer,
|
||||
writeType: "write",
|
||||
success: () => {
|
||||
@ -412,45 +384,23 @@ export function sendDataBytes(data: number[], writeType: number, cb: (res: MyApi
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ================= 读取 =================
|
||||
|
||||
// 读取设备参数特征(只读 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
|
||||
}
|
||||
// 读特征(通用):结果经 onBLECharacteristicValueChange 回传,内部串行排队,3s 超时;
|
||||
// type==0 时 data 为 number[]
|
||||
export function readChar(charId: string, cb: (res: MyApiResult) => void) {
|
||||
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)
|
||||
readQueue.push({ charId: charId, cb: cb } as ReadReq)
|
||||
pumpReadQueue()
|
||||
}
|
||||
}, 3000)
|
||||
uni.readBLECharacteristicValue({
|
||||
deviceId: id,
|
||||
serviceId: MCM_SERVICE_UUID,
|
||||
characteristicId: MCM_INFO_UUID,
|
||||
success: () => {},
|
||||
fail: (err: any) => {
|
||||
readPendingChar = null
|
||||
readCb = null
|
||||
clearTimeout(readTimer)
|
||||
cb(failRes(10008, err))
|
||||
}
|
||||
})
|
||||
|
||||
// 读取设备参数特征(只读 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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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<boolean> = Promise.resolve(true)
|
||||
let writeChain: Promise<boolean> = Promise.resolve(true)
|
||||
|
||||
let infoJob: InfoJob | null = null
|
||||
let infoChain: Promise<boolean> = 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<MyApiResult> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
const p = writeChain.then((): Promise<boolean> => sendFrameImpl(type, payload))
|
||||
writeChain = p
|
||||
return p
|
||||
}
|
||||
|
||||
function sendCliImpl(cmd: string, timeoutMs: number): Promise<CliResult> {
|
||||
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<CliResult> {
|
||||
attach()
|
||||
const p = cliChain.then((): Promise<CliResult> => sendCliImpl(cmd, timeoutMs))
|
||||
cliChain = p.then((): boolean => true)
|
||||
return p
|
||||
}
|
||||
|
||||
// ================= 信息查询(INFO_QUERY / INFO_RSP) =================
|
||||
|
||||
function queryInfoImpl(items: number[], timeoutMs: number): Promise<number[] | null> {
|
||||
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<number[] | null> {
|
||||
attach()
|
||||
const p = infoChain.then((): Promise<number[] | null> => queryInfoImpl(items, timeoutMs))
|
||||
infoChain = p.then((): boolean => true)
|
||||
return p
|
||||
}
|
||||
91
SmartAssiter/src/mcm/McmCli.ts
Normal file
91
SmartAssiter/src/mcm/McmCli.ts
Normal file
@ -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<any> = 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<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => { resolve() }, ms)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一行 CLI 命令并取回输出文本。所有调用串行排队;只 resolve 不 reject。
|
||||
*/
|
||||
export function execCli(cmd: string, timeoutMs: number = 5000): Promise<CliResult> {
|
||||
const p = cliChain.then(() => doExecCli(cmd, timeoutMs))
|
||||
cliChain = p.catch(() => {})
|
||||
return p
|
||||
}
|
||||
|
||||
async function doExecCli(cmd: string, timeoutMs: number): Promise<CliResult> {
|
||||
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<CliResult> => {
|
||||
const wr = await new Promise<MyApiResult>((resolve) => {
|
||||
Ble.writeCliChar(bytes, (res: MyApiResult) => { resolve(res) })
|
||||
})
|
||||
if (wr.type != 0) {
|
||||
return { text: "", status: wr.type == 10001 ? -4 : -3 } as CliResult
|
||||
}
|
||||
await delay(CLI_RSP_DELAY_MS)
|
||||
const rd = await new Promise<MyApiResult>((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))
|
||||
])
|
||||
}
|
||||
@ -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<string | null> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<number | null> {
|
||||
const info = await queryDeviceInfo([INFO_CHIP_TEMP])
|
||||
return info.chipTemp
|
||||
}
|
||||
|
||||
/** 风扇转速(RPM),无测速硬件/读取失败返回 null */
|
||||
export async function getFanSpeed(): Promise<number | null> {
|
||||
const info = await queryDeviceInfo([INFO_FAN_RPM])
|
||||
return info.fanRpm
|
||||
}
|
||||
|
||||
/** 通用 CLI 命令(调试终端用) */
|
||||
export async function execCli(cmd: string, timeoutMs: number = 5000): Promise<CliResult> {
|
||||
return await sendCli(cmd, timeoutMs)
|
||||
}
|
||||
export { execCli }
|
||||
export type { CliResult }
|
||||
|
||||
@ -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<DeviceInfo> {
|
||||
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<DeviceInfo> {
|
||||
const payload = await new Promise<number[] | null>((resolve) => {
|
||||
|
||||
@ -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<OtaAck> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user