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:
evan.liu 2026-09-04 09:21:08 +08:00
parent 9366d116c4
commit c65809a6f1
13 changed files with 348 additions and 1170 deletions

View File

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { MyApiResult } from "@/src/BluetoothManager" import { MyApiResult } from "@/src/BluetoothManager"
import * as Ble from "@/src/BluetoothManager" import * as Ble from "@/src/BluetoothManager"
import * as McmChannel from "@/src/mcm/McmChannel"
// #ifdef APP-PLUS // #ifdef APP-PLUS
let firstBackTime = 0 let firstBackTime = 0
@ -9,20 +8,8 @@ import * as McmChannel from "@/src/mcm/McmChannel"
export default { export default {
globalData: { globalData: {
// 接收蓝牙数据的回调(显式指定 CAIIC-MCM 服务/notify UUID,基于 uni 内置 BLE API) // 连接蓝牙:connect → 服务/特征校验(CLI 读写特征 + 参数只读特征),
setBleNotity(){ // 就绪后发 bleState 0(不再订阅 notify,固件 notify 链路已停用)
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)
}
)
},
// 连接蓝牙
ConnectBle(address: string, autoReconnect: boolean){ ConnectBle(address: string, autoReconnect: boolean){
Ble.connect(address, autoReconnect, function(res: MyApiResult){ Ble.connect(address, autoReconnect, function(res: MyApiResult){
console.log("[MCM] connect " + address + ": type=" + res.type + " msg=" + res.message) 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){ Ble.scanServices(function(resSevice: MyApiResult){
console.log("[MCM] scanServices: type=" + resSevice.type + " msg=" + resSevice.message) console.log("[MCM] scanServices: type=" + resSevice.type + " msg=" + resSevice.message)
if (resSevice.type == 0) { if (resSevice.type == 0) {
(getApp().globalData as any).setBleNotity() uni.$emit("bleState", 0)
uni.$emit("bleState", res.type)
} else { } else {
uni.$emit("bleState", 10000) uni.$emit("bleState", 10000)
} }
@ -45,8 +31,6 @@ import * as McmChannel from "@/src/mcm/McmChannel"
}, },
onLaunch: function () { onLaunch: function () {
console.log('App Launch') console.log('App Launch')
// 注册 CAIIC-MCM 协议通道的全局蓝牙事件监听
McmChannel.attach()
}, },
onShow: function () { onShow: function () {
console.log('App Show') console.log('App Show')

View 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)待烧录固件后进行。

View File

@ -49,9 +49,8 @@ import { ref } from 'vue'
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app' import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
import { MyApiResult } from "../../../src/BluetoothManager" import { MyApiResult } from "../../../src/BluetoothManager"
import * as Ble 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 McmCmd from "../../../src/mcm/McmCommands"
import { CliResult } from "../../../src/mcm/McmChannel" import { CliResult } from "../../../src/mcm/McmCli"
import * as BleINfo from "../../../common/bleInfo" import * as BleINfo from "../../../common/bleInfo"
const bleAddress = ref('') const bleAddress = ref('')
@ -88,23 +87,18 @@ function pushMsg(dir: string, text: string) {
} }
// ==============================蓝牙事件 // ==============================蓝牙事件
function onBleDataListener(res: MyApiResult) { function onBleStatueListener(state: number) {
if (res.type == 1000) { if (state == 0) {
// notify 订阅成功:协商 MTU 247 // 连接就绪(connect + 服务/特征校验通过):协商 MTU 247,加大 CLI 应答的单读上限
Ble.setMtu(247, (mtuRes: MyApiResult) => { Ble.setMtu(247, (mtuRes: MyApiResult) => {
if (mtuRes.type == 0) { if (mtuRes.type == 0) {
McmChannel.setMtuSize(247)
pushMsg("message", "set MTU 247") pushMsg("message", "set MTU 247")
} }
}) })
bleIsConnect.value = true bleIsConnect.value = true
pushMsg("message", "notify subscribed, ready") pushMsg("message", "connected, ready")
uni.hideLoading() uni.hideLoading()
} } else if (state == 10000) {
}
function onBleStatueListener(state: number) {
if (state == 10000) {
pushMsg("message", "connect failed") pushMsg("message", "connect failed")
bleIsConnect.value = false bleIsConnect.value = false
uni.hideLoading() uni.hideLoading()
@ -120,10 +114,9 @@ function onDisConnect() {
Ble.disConnect() Ble.disConnect()
} }
bleIsConnect.value = false bleIsConnect.value = false
McmChannel.resetChannel()
} }
// 连接蓝牙(走全局 ConnectBle:connect → scanServices → 订阅 notify,显式 CAIIC-MCM UUID) // 连接蓝牙(走全局 ConnectBle:connect → 服务/特征校验,不再订阅 notify)
// 本页是 tab 页,地址取最近连接过的设备(bleInfo),没有则提示去 Device 页连接 // 本页是 tab 页,地址取最近连接过的设备(bleInfo),没有则提示去 Device 页连接
function onConnect() { function onConnect() {
if (Ble.isConnected()) { if (Ble.isConnected()) {
@ -176,14 +169,12 @@ function onGetStatus() {
let subscribed = false let subscribed = false
function subscribe() { function subscribe() {
if (subscribed) return if (subscribed) return
uni.$on("bleData", onBleDataListener)
uni.$on("bleState", onBleStatueListener) uni.$on("bleState", onBleStatueListener)
subscribed = true subscribed = true
} }
function unsubscribe() { function unsubscribe() {
if (!subscribed) return if (!subscribed) return
uni.$off("bleData", onBleDataListener)
uni.$off("bleState", onBleStatueListener) uni.$off("bleState", onBleStatueListener)
subscribed = false subscribed = false
} }

View File

@ -76,7 +76,6 @@ import { ref } from 'vue'
import { onLoad, onShow, onHide, onUnload, onPullDownRefresh } from '@dcloudio/uni-app' import { onLoad, onShow, onHide, onUnload, onPullDownRefresh } from '@dcloudio/uni-app'
import { MyApiResult, BleScanResult } from "../../src/BluetoothManager" import { MyApiResult, BleScanResult } from "../../src/BluetoothManager"
import * as Ble 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 McmCmd from "../../src/mcm/McmCommands"
import * as McmInfo from "../../src/mcm/McmInfo" import * as McmInfo from "../../src/mcm/McmInfo"
import * as BleINfo from "../../common/bleInfo" import * as BleINfo from "../../common/bleInfo"
@ -146,20 +145,16 @@ async function loadDeviceInfo() {
// ================= 蓝牙事件 ================= // ================= 蓝牙事件 =================
async function onBleDataListener(res: MyApiResult) { // 连接就绪(bleState 0:connect + 服务/特征校验已通过):协商 MTU 后加载设备信息
if (res.type == 1000) { function onConnectedReady() {
// notify 订阅成功:协商 MTU 247(写切片 244),iOS 无效保持 20 Ble.setMtu(247, (resMtu: MyApiResult) => {
Ble.setMtu(247, (resMtu: MyApiResult) => { console.log("[MCM] setMtu 247: type=" + resMtu.type + " msg=" + resMtu.message)
if (resMtu.type == 0) { })
McmChannel.setMtuSize(247) connected.value = true
} connecting.value = false
}) deviceMac.value = Ble.getConnectMac()
connected.value = true loadDeviceInfo()
connecting.value = false startPolling()
deviceMac.value = Ble.getConnectMac()
loadDeviceInfo()
startPolling()
}
} }
function onDisConnectUi() { function onDisConnectUi() {
@ -175,8 +170,8 @@ function onDisConnectUi() {
function onBleStatueListener(state: number) { function onBleStatueListener(state: number) {
console.log("home bleState = ", state) console.log("home bleState = ", state)
if (state == 0) { if (state == 0) { // 连接就绪
connecting.value = false onConnectedReady()
} else if (state == 10000) { // 连接失败 } else if (state == 10000) { // 连接失败
connecting.value = false connecting.value = false
connected.value = false connected.value = false
@ -242,14 +237,12 @@ function onDisconnect() {
let subscribed = false let subscribed = false
function subscribe() { function subscribe() {
if (subscribed) return if (subscribed) return
uni.$on("bleData", onBleDataListener)
uni.$on("bleState", onBleStatueListener) uni.$on("bleState", onBleStatueListener)
subscribed = true subscribed = true
} }
function unsubscribe() { function unsubscribe() {
if (!subscribed) return if (!subscribed) return
uni.$off("bleData", onBleDataListener)
uni.$off("bleState", onBleStatueListener) uni.$off("bleState", onBleStatueListener)
subscribed = false subscribed = false
} }

View File

@ -20,50 +20,22 @@
<text class="infoValue">{{ bankInfo }}</text> <text class="infoValue">{{ bankInfo }}</text>
</view> </view>
</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> </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> </view>
<!-- #ifdef APP --> <!-- #ifdef APP -->
</scroll-view> </scroll-view>
@ -73,147 +45,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app' import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
import { MyApiResult } from "../../src/BluetoothManager"
import * as Ble from "../../src/BluetoothManager" import * as Ble from "../../src/BluetoothManager"
import * as McmCmd from "../../src/mcm/McmCommands" 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 connected = ref<boolean>(false)
const curVersion = ref<string>("--") const curVersion = ref<string>("--")
const bankInfo = 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) { 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 connected.value = false
if (upgrading.value) {
addLog("Disconnected, upgrade aborted")
upgrading.value = false
}
} }
} }
@ -271,10 +107,6 @@ onHide(() => {
onUnload(() => { onUnload(() => {
unsubscribe() unsubscribe()
// 离开页面时若仍在升级则中止(升级期间不建议离开)
if (Ota.isRunning()) {
Ota.abort()
}
}) })
</script> </script>
@ -309,13 +141,6 @@ onUnload(() => {
margin-bottom: 12rpx; margin-bottom: 12rpx;
} }
.input {
border: 1px solid #dddddd;
border-radius: 12rpx;
padding: 16rpx;
font-size: 26rpx;
}
.infoRow { .infoRow {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -337,52 +162,10 @@ onUnload(() => {
margin-left: 20rpx; 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 { .warnText {
font-size: 24rpx; font-size: 24rpx;
color: #dd524d; color: #dd524d;
text-align: center;
margin-top: 12rpx; margin-top: 12rpx;
}
.logText {
font-size: 22rpx;
color: #333333;
font-family: monospace;
line-height: 1.5; line-height: 1.5;
} }
</style> </style>

View File

@ -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 表示成功 * - MyApiResult.type == 0 表示成功
* - 1000=notify 订阅成功 1001=订阅失败
* - 10000=连接失败 10001=已断开 10002=蓝牙未开/不可用 10003=无蓝牙权限 * - 10000=连接失败 10001=已断开 10002=蓝牙未开/不可用 10003=无蓝牙权限
* - notify 数据通过回调返回 {type:0, data: NotityData},由 App.vue 转发 uni.$emit("bleData") * - 10004/10005=服务/特征发现问题 10008=读写/MTU/RSSI 失败或超时
* - 连接状态变化通过 uni.$emit("bleState", state) 通知(0=已连接 10000=连接失败 10001=已断开) * - 连接状态变化通过 uni.$emit("bleState", state) 通知(0=已连接可用 10000=连接失败 10001=已断开)
*/ */
// CAIIC-MCM 设备 GATT(固件 mcm-ddc-ble app_rdtss.h 实际值,小写与 Android UUID.toString 格式一致) // 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_SERVICE_UUID = "00002760-08c2-11e1-9073-0e8ac72e1001"
export const MCM_WRITE_UUID = "00002760-08c2-11e1-9073-0e8ac72e0001" // Write Without Response export const MCM_CLI_UUID = "00002760-08c2-11e1-9073-0e8ac72e0003" // 读写:写命令行 / 读输出文本
export const MCM_NOTIFY_UUID = "00002760-08c2-11e1-9073-0e8ac72e0002" // Notify
export const MCM_INFO_UUID = "00002760-08c2-11e1-9073-0e8ac72e0004" // 只读:设备参数 TLV(同 INFO_RSP 全部项) export const MCM_INFO_UUID = "00002760-08c2-11e1-9073-0e8ac72e0004" // 只读:设备参数 TLV(同 INFO_RSP 全部项)
export type MyApiResult = { export type MyApiResult = {
@ -32,17 +36,10 @@ export type BleScanResult = {
rssi: number rssi: number
} }
// notify 回调数据结构(对齐原插件 NotityData 的 {data, mac} 字段)
export type NotityData = {
data: number[],
mac: string
}
// ================= 模块状态 ================= // ================= 模块状态 =================
let connectedDeviceId: string | null = null let connectedDeviceId: string | null = null
let manualDisconnect = false let manualDisconnect = false
let connTimeoutMs = 0 // createBLEConnection 超时,0 表示不设置
let btEnabled = false // 缓存的蓝牙开关状态(由 adapter 状态监听/操作结果维护) let btEnabled = false // 缓存的蓝牙开关状态(由 adapter 状态监听/操作结果维护)
let scanning = false let scanning = false
let scanTimeoutTimer = 0 let scanTimeoutTimer = 0
@ -50,39 +47,68 @@ let scanTimeoutTimer = 0
let stateListenerRegistered = false let stateListenerRegistered = false
let connListenerRegistered = false let connListenerRegistered = false
let scanListenerRegistered = false let scanListenerRegistered = false
let notifyListenerRegistered = false let valueListenerRegistered = false
let notifyDataCb: ((res: MyApiResult) => void) | null = null
let readPendingChar: string | null = null // 等待读结果的特征 UUID // 读请求队列:uni 的读结果经 onBLECharacteristicValueChange 全局回调返回,
let readCb: ((res: MyApiResult) => void) | null = null // 同一时间只能跟踪一个待决读请求,多余请求排队串行执行
type ReadReq = {
charId: string,
cb: (res: MyApiResult) => void
}
const readQueue: ReadReq[] = []
let readActive: ReadReq | null = null
let readTimer = 0 let readTimer = 0
// 注册特征值变化监听(notify 数据与 read 结果都经此回调分发,幂等) const READ_TIMEOUT_MS = 3000
// 注册特征值变化监听(read 结果经此回调分发,幂等)
function ensureValueChangeListener() { function ensureValueChangeListener() {
if (notifyListenerRegistered) return if (valueListenerRegistered) return
notifyListenerRegistered = true valueListenerRegistered = true
uni.onBLECharacteristicValueChange((res: any) => { uni.onBLECharacteristicValueChange((res: any) => {
if (readActive == null) return
const charId = res.characteristicId as string const charId = res.characteristicId as string
if (!uuidEq(charId, readActive.charId)) return
const bytes = new Uint8Array(res.value as ArrayBuffer) const bytes = new Uint8Array(res.value as ArrayBuffer)
const arr = [] as number[] const arr = [] as number[]
for (let i = 0; i < bytes.length; i++) { for (let i = 0; i < bytes.length; i++) {
arr.push(bytes[i]) arr.push(bytes[i])
} }
if (uuidEq(charId, MCM_NOTIFY_UUID)) { finishRead({ type: 0, message: "read ok", data: arr } as MyApiResult)
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) function finishRead(res: MyApiResult) {
} const req = readActive
return readActive = null
} clearTimeout(readTimer)
if (readPendingChar != null && uuidEq(charId, readPendingChar)) { if (req != null) {
const c = readCb req.cb(res)
readCb = null }
readPendingChar = null pumpReadQueue()
clearTimeout(readTimer) }
if (c != null) {
c({ type: 0, message: "read ok", data: arr } as MyApiResult) 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 { } else {
const wasConnected = connectedDeviceId != null && connectedDeviceId == res.deviceId const wasConnected = connectedDeviceId != null && connectedDeviceId == res.deviceId
connectedDeviceId = null 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) { if (wasConnected && !manualDisconnect) {
uni.$emit("bleState", 10001) uni.$emit("bleState", 10001)
} }
@ -147,14 +183,6 @@ export function isEnabled(): boolean {
return btEnabled 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) // 打开蓝牙(初始化蓝牙适配器;蓝牙关闭/无权限时走 fail)
export function openBluetooth(cb: (res: MyApiResult) => void) { export function openBluetooth(cb: (res: MyApiResult) => void) {
ensureStateListener() ensureStateListener()
@ -245,48 +273,21 @@ export function getConnectMac(): string {
return connectedDeviceId ?? "" return connectedDeviceId ?? ""
} }
// 连接蓝牙(autoReconnect 保留兼容签名,当前业务恒传 false,未实现自动重连) // 连接蓝牙
export function connect(mac: string, autoReconnect: boolean, cb: (res: MyApiResult) => void) { export function connect(mac: string, autoReconnect: boolean, cb: (res: MyApiResult) => void) {
ensureStateListener() ensureStateListener()
ensureConnListener() ensureConnListener()
manualDisconnect = false manualDisconnect = false
if (connTimeoutMs > 0) { uni.createBLEConnection({
uni.createBLEConnection({ deviceId: mac,
deviceId: mac, success: () => {
timeout: connTimeoutMs, connectedDeviceId = mac
success: () => { cb(ok("connected"))
connectedDeviceId = mac },
cb(ok("connected")) fail: (err: any) => {
}, cb(failRes(10000, err))
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 {
} }
// 断开蓝牙 // 断开蓝牙
@ -303,9 +304,9 @@ export function disConnect() {
} }
} }
// ================= 服务与订阅 ================= // ================= 服务发现 =================
// 连接后扫描服务:确认 MCM 服务及写/notify 特征存在(替代插件的自动识别) // 连接后扫描服务:确认 MCM 服务及 CLI 读写/参数只读特征存在
export function scanServices(cb: (res: MyApiResult) => void) { export function scanServices(cb: (res: MyApiResult) => void) {
const id = connectedDeviceId const id = connectedDeviceId
if (id == null) { if (id == null) {
@ -330,7 +331,19 @@ export function scanServices(cb: (res: MyApiResult) => void) {
uni.getBLEDeviceCharacteristics({ uni.getBLEDeviceCharacteristics({
deviceId: id as string, deviceId: id as string,
serviceId: MCM_SERVICE_UUID, 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")) cb(ok("services ready"))
}, },
fail: (err: any) => { 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))
}
})
}
// ================= 写入 ================= // 写 CLI 特征(write-with-response):data 为原始命令行字节(≤63B,且不超 mtu-3)
export function writeCliChar(data: number[], cb: (res: MyApiResult) => void) {
// 发送数据(显式写类型):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) {
const id = connectedDeviceId const id = connectedDeviceId
if (id == null) { if (id == null) {
cb({ type: 10001, message: "not connected", data: null } as MyApiResult) 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++) { for (let i = 0; i < data.length; i++) {
buf[i] = data[i] & 0xFF buf[i] = data[i] & 0xFF
} }
if (writeType == 1) { uni.writeBLECharacteristicValue({
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({
deviceId: id, deviceId: id,
serviceId: MCM_SERVICE_UUID, serviceId: MCM_SERVICE_UUID,
characteristicId: MCM_INFO_UUID, characteristicId: MCM_CLI_UUID,
success: () => {}, value: buf.buffer,
writeType: "write",
success: () => {
cb(ok("write ok"))
},
fail: (err: any) => { fail: (err: any) => {
readPendingChar = null
readCb = null
clearTimeout(readTimer)
cb(failRes(10008, err)) 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) { export function setMtu(len: number, cb: (res: MyApiResult) => void) {
const id = connectedDeviceId const id = connectedDeviceId
if (id == null) { if (id == null) {

View File

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

View File

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

View File

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

View 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))
])
}

View File

@ -1,51 +1,34 @@
/** /**
* CAIIC-MCM 业务命令封装 * CAIIC-MCM 业务命令封装(读写特征方式,不依赖 notify)
* 温度/风扇/固件版本走 INFO_QUERY(协议 V1.1 第 5 节); * 固件版本走只读参数特征(McmInfo.readDeviceInfo);
* sysinfo/bankinfo 等系统状态仍走 CLI 透传,命令名集中在顶部常量。 * sysinfo/bankinfo 等系统状态走 CLI 特征透传,命令名集中在顶部常量。
*/ */
import { sendCli, CliResult } from "./McmChannel" import { execCli, CliResult } from "./McmCli"
import { import { readDeviceInfo, formatVersion } from "./McmInfo"
queryDeviceInfo, formatVersion,
INFO_FW_VERSION, INFO_CHIP_TEMP, INFO_FAN_RPM
} from "./McmInfo"
const CMD_SYSINFO = "sysinfo" const CMD_SYSINFO = "sysinfo"
const CMD_BANKINFO = "bankinfo" 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> { export async function getVersion(): Promise<string | null> {
const info = await queryDeviceInfo([INFO_FW_VERSION]) const info = await readDeviceInfo()
const v = info.fwVersion const v = info.fwVersion
return v != null ? formatVersion(v) : null return v != null ? formatVersion(v) : null
} }
/** 系统运行状态原文(运行时间/任务数/heap/任务表),失败返回 null */ /** 系统运行状态原文(运行时间/任务数/heap/任务表),失败返回 null */
export async function getSysInfo(): Promise<string | 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 if (r.status != 0) return null
return r.text return r.text
} }
/** bank 信息原文(当前运行 bank 与 bootsetting),失败返回 null */ /** bank 信息原文(当前运行 bank 与 bootsetting),失败返回 null */
export async function getBankInfo(): Promise<string | 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 if (r.status != 0) return null
return r.text return r.text
} }
/** 温度(℃),读取失败返回 null */ export { execCli }
export async function getTemperature(): Promise<number | null> { export type { CliResult }
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)
}

View File

@ -1,10 +1,9 @@
/** /**
* CAIIC-MCM 设备信息查询(协议 V1.1 第 5 节:INFO_QUERY / INFO_RSP) * CAIIC-MCM 设备信息(只读参数特征 ...c72e0004)
* INFO_RSP payload 为 TLV 序列 {id u8, len u8, value…}×n,数值均小端; * 读出内容为全部信息项的 TLV 序列 {id u8, len u8, value…}×n,数值均小端,
* 设备不认识的 id 不出现在应答中;解析时未知 id 按 len 跳过。 * 与帧协议 INFO_RSP(all) 的 payload 一致;解析时未知 id 按 len 跳过。
* 不依赖 notify 通道。
*/ */
import { queryInfo } from "./McmChannel"
import { readU16LE, readI16LE, readU32LE } from "./FrameCodec"
import * as Ble from "../BluetoothManager" import * as Ble from "../BluetoothManager"
import { MyApiResult } from "../BluetoothManager" import { MyApiResult } from "../BluetoothManager"
@ -36,7 +35,20 @@ function emptyInfo(): DeviceInfo {
} as 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 { export function parseTlv(payload: number[]): DeviceInfo {
const info = emptyInfo() const info = emptyInfo()
let i = 0 let i = 0
@ -67,18 +79,8 @@ export function parseTlv(payload: number[]): DeviceInfo {
} }
/** /**
* 查询设备信息;items 为空数组 = 查询全部。 * 读只读参数特征(MCM_INFO_UUID,内容为全部信息项 TLV)。
* 超时/断连/发送失败返回各字段全 null 的 DeviceInfo。 * 失败/超时返回各字段全 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。
*/ */
export async function readDeviceInfo(): Promise<DeviceInfo> { export async function readDeviceInfo(): Promise<DeviceInfo> {
const payload = await new Promise<number[] | null>((resolve) => { const payload = await new Promise<number[] | null>((resolve) => {

View File

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