mothercup/SmartAssiter/src/BluetoothManager.ts
evan.liu 3e87d333cb App: adapt to firmware V1.00.23 BLE protocol + field debugging fixes
- BluetoothManager: per-characteristic isolated channels (per-UUID read
  queue/timeout/dispatch, no cross-char write blocking), 10007 delayed
  retry for read/write, 300ms link settle after discovery, full tx/rx
  hex logging
- McmCli/McmInfo/McmOta: command/response, TLV parse and OTA frame logs
- home.vue: Auto refresh switch (polling off by default, no GATT
  interference with OTA), MTU-then-load sequencing
- ota.vue: SAF file picker via plus.android (ACTION_GET_CONTENT) with
  Scanner whole-stream read + magic self-check + per-byte fallback;
  default path moved to _doc (Android 10+ scoped storage)
- docs: change logs for 2026-09-04 protocol adaptation and 2026-09-05
  field debugging; AGENTS.md updated
2026-09-05 06:45:04 +08:00

571 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/**
* 蓝牙管理模块(基于 uni 内置 BLE API)
*
* 当前固件(mcm-ddc-ble V1.00.23)的可用 GATT 接口:
* - CLI 读写特征 ...c72e0003:写原始命令行(write-with-response,≤63B),
* 读同一特征分包取回输出文本(每片 ≤ att_mtu-2,0 长度包=结束)
* - 只读参数特征 ...c72e0004:读出全部信息项 TLV(同 INFO_RSP(all),
* 含 0x07 CUR_BANK 当前运行 bank)
* - OTA 特征 ...c72e0005:OTA 传输帧下行(write-with-response)→
* 读同一特征取回 OTA_RSP(读需轮询至非空),见 docs/ble_protocol.md §6.6
*
* 对外约定:
* - MyApiResult.type == 0 表示成功
* - 10000=连接失败 10001=已断开 10002=蓝牙未开/不可用 10003=无蓝牙权限
* - 10004/10005=服务/特征发现问题 10008=读写/MTU/RSSI 失败或超时
* - 连接状态变化通过 uni.$emit("bleState", state) 通知(0=已连接可用 10000=连接失败 10001=已断开)
*/
// CAIIC-MCM 设备 GATT(固件 mcm-ddc-ble app_rdtss.h 实际值,小写与 Android UUID.toString 格式一致)
export const MCM_SERVICE_UUID = "00002760-08c2-11e1-9073-0e8ac72e1001"
export const MCM_CLI_UUID = "00002760-08c2-11e1-9073-0e8ac72e0003" // 读写:写命令行 / 读输出文本
export const MCM_INFO_UUID = "00002760-08c2-11e1-9073-0e8ac72e0004" // 只读:设备参数 TLV(同 INFO_RSP 全部项)
export const MCM_OTA_UUID = "00002760-08c2-11e1-9073-0e8ac72e0005" // OTA:写传输帧 / 读 OTA_RSP(轮询至非空)
export type MyApiResult = {
type: number,
message: string,
data: any
}
export type BleScanDevice = {
name: string | null,
address: string
}
export type BleScanResult = {
device: BleScanDevice,
rssi: number
}
// ================= 模块状态 =================
let connectedDeviceId: string | null = null
let manualDisconnect = false
let btEnabled = false // 缓存的蓝牙开关状态(由 adapter 状态监听/操作结果维护)
let scanning = false
let scanTimeoutTimer = 0
let negotiatedMtu = 23 // 当前生效的 ATT MTU(默认 23,setMtu 成功后更新)
let stateListenerRegistered = false
let connListenerRegistered = false
let scanListenerRegistered = false
let valueListenerRegistered = false
// 读请求按特征值各自独立排队/跟踪(CLI/参数/OTA 各通道互不影响、可并行):
// uni 的读结果经 onBLECharacteristicValueChange 全局回调返回,按 characteristicId 分发;
// 同一特征上的读串行,不同特征各管各的超时与状态
type ReadReq = {
charId: string,
cb: (res: MyApiResult) => void,
retried: boolean // 10007 已重试过
}
const readQueues: { [key: string]: ReadReq[] } = {}
const readActiveMap: { [key: string]: ReadReq | null } = {}
const readTimers: { [key: string]: number } = {}
const READ_TIMEOUT_MS = 3000
function charKey(charId: string): string {
return charId.toUpperCase()
}
// 注册特征值变化监听(read 结果按特征值分发到各自的待决请求,幂等)
function ensureValueChangeListener() {
if (valueListenerRegistered) return
valueListenerRegistered = true
uni.onBLECharacteristicValueChange((res: any) => {
const charId = res.characteristicId as string
const key = charKey(charId)
if (readActiveMap[key] == null) 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])
}
console.log("[MCM] rx " + charTag(charId) + " " + arr.length + "B: " + bytesHex(arr, 64))
finishRead(key, { type: 0, message: "read ok", data: arr } as MyApiResult)
})
}
function finishRead(key: string, res: MyApiResult) {
const req = readActiveMap[key]
readActiveMap[key] = null
clearTimeout(readTimers[key])
if (req != null) {
if (res.type != 0) {
console.log("[MCM] rx " + charTag(req.charId) + " FAIL: [" + res.type + "] " + res.message)
}
req.cb(res)
}
pumpReadQueue(key)
}
function pumpReadQueue(key: string) {
if (readActiveMap[key] != null) return
const id = connectedDeviceId
const queue = readQueues[key]
if (queue == null) return
const req = queue.shift()
if (req == null) return
if (id == null) {
req.cb({ type: 10001, message: "not connected", data: null } as MyApiResult)
pumpReadQueue(key)
return
}
// 链路稳定期内推迟发出(连接级一次性等待,不做跨特征阻塞)
const waitMs = linkReadyAt - Date.now()
if (waitMs > 0) {
queue.unshift(req)
setTimeout(() => { pumpReadQueue(key) }, waitMs)
return
}
readActiveMap[key] = req
console.log("[MCM] read " + charTag(req.charId) + " ...")
readTimers[key] = setTimeout(() => {
finishRead(key, { 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) => {
const msg: string = (err && err.errMsg) ? err.errMsg : "fail"
// 链路未稳定时读也会被误报 10007 property not support:补等后重试一次
if (!req.retried && msg.indexOf("property not support") >= 0) {
console.log("[MCM] read " + charTag(req.charId) + " 10007, wait " +
WRITE_10007_RETRY_MS + "ms and retry")
readActiveMap[key] = null
clearTimeout(readTimers[key])
req.retried = true
setTimeout(() => {
if (readQueues[key] == null) readQueues[key] = [] as ReadReq[]
readQueues[key].unshift(req)
pumpReadQueue(key)
}, WRITE_10007_RETRY_MS)
return
}
finishRead(key, failRes(10008, err))
}
})
}
function ok(message: string): MyApiResult {
return { type: 0, message: message, data: null } as MyApiResult
}
function failRes(type: number, err: any): MyApiResult {
return { type: type, message: (err && err.errMsg) ? err.errMsg : "fail", data: null } as MyApiResult
}
// uni BLE 错误 → 本模块 type 约定:蓝牙未开/不可用 10002,权限类 10003
function mapBleError(err: any): number {
const msg: string = (err && err.errMsg) ? err.errMsg : ""
if (msg.indexOf("ermission") >= 0) return 10003
return 10002
}
function uuidEq(a: string, b: string): boolean {
return a.toUpperCase() == b.toUpperCase()
}
// 字节数组转 hex 串(日志用,超过 maxLen 截断并标注总长度)
function bytesHex(arr: number[], maxLen: number): string {
let s = ""
const n = Math.min(arr.length, maxLen)
for (let i = 0; i < n; i++) {
s += ("0" + arr[i].toString(16)).slice(-2).toUpperCase() + " "
}
if (arr.length > maxLen) s += "..(" + arr.length + "B)"
return s
}
// 特征 UUID 短名(日志用,如 "...E0003")
function charTag(charId: string): string {
return "..." + charId.slice(-4).toUpperCase()
}
// 注册系统蓝牙开关状态监听并同步一次当前状态(幂等)
function ensureStateListener() {
if (stateListenerRegistered) return
stateListenerRegistered = true
uni.onBluetoothAdapterStateChange((res: any) => {
btEnabled = res.available
})
uni.getBluetoothAdapterState({
success: (res: any) => {
btEnabled = res.available
},
fail: () => {}
})
}
// 注册连接状态监听(幂等):意外断开时广播 bleState 10001
function ensureConnListener() {
if (connListenerRegistered) return
connListenerRegistered = true
uni.onBLEConnectionStateChange((res: any) => {
if (res.connected) {
connectedDeviceId = res.deviceId
console.log("[MCM] connected: " + res.deviceId)
} else {
console.log("[MCM] disconnected: " + res.deviceId + (manualDisconnect ? " (manual)" : ""))
const wasConnected = connectedDeviceId != null && connectedDeviceId == res.deviceId
connectedDeviceId = null
negotiatedMtu = 23
linkReadyAt = 0
// 断连后所有特征的待决/排队读请求立即失败(逐通道清理)
const keys = [] as string[]
for (const k in readActiveMap) keys.push(k)
for (const k in readQueues) {
if (keys.indexOf(k) < 0) keys.push(k)
}
for (let i = 0; i < keys.length; i++) {
const k = keys[i]
if (readActiveMap[k] != null) {
finishRead(k, { type: 10001, message: "disconnected", data: null } as MyApiResult)
}
// finishRead→pumpReadQueue 会以 not connected 依次排空该通道队列;
// 对在稳定期推迟的队列(无待决请求)也主动触发一次排空
pumpReadQueue(k)
}
if (wasConnected && !manualDisconnect) {
uni.$emit("bleState", 10001)
}
manualDisconnect = false
}
})
}
// ================= 适配器 =================
// 手机蓝牙是否打开 true-打开 false-未打开
export function isEnabled(): boolean {
ensureStateListener()
return btEnabled
}
// 打开蓝牙(初始化蓝牙适配器;蓝牙关闭/无权限时走 fail)
export function openBluetooth(cb: (res: MyApiResult) => void) {
ensureStateListener()
uni.openBluetoothAdapter({
success: () => {
btEnabled = true
cb(ok("bluetooth adapter opened"))
},
fail: (err: any) => {
cb(failRes(mapBleError(err), err))
}
})
}
// ================= 扫描 =================
// 开始蓝牙扫描:每个发现的设备回调一次 {type:0, data: BleScanResult};超时自动停止
export function startScan(timeoutMs: number, cb: (res: MyApiResult) => void) {
ensureStateListener()
uni.openBluetoothAdapter({
success: () => {
btEnabled = true
if (scanning) return
if (!scanListenerRegistered) {
scanListenerRegistered = true
uni.onBluetoothDeviceFound((res: any) => {
const devices = res.devices as any[]
for (let i = 0; i < devices.length; i++) {
const d = devices[i]
let name: string | null = d.name as string
if (name == null || name.length == 0) {
const ln = (d.localName ?? "") as string
name = ln.length > 0 ? ln : null
}
const sr: BleScanResult = {
device: { name: name, address: d.deviceId } as BleScanDevice,
rssi: d.RSSI as number
} as BleScanResult
cb({ type: 0, message: "device found", data: sr } as MyApiResult)
}
})
}
scanning = true
uni.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false,
success: () => {},
fail: (err: any) => {
scanning = false
cb(failRes(mapBleError(err), err))
}
})
if (scanTimeoutTimer != 0) {
clearTimeout(scanTimeoutTimer)
}
scanTimeoutTimer = setTimeout(() => {
stopScan()
}, timeoutMs)
},
fail: (err: any) => {
cb(failRes(mapBleError(err), err))
}
})
}
// 停止蓝牙扫描
export function stopScan() {
if (scanTimeoutTimer != 0) {
clearTimeout(scanTimeoutTimer)
scanTimeoutTimer = 0
}
if (!scanning) return
scanning = false
uni.stopBluetoothDevicesDiscovery({
success: () => {},
fail: () => {}
})
}
// ================= 连接 =================
// 蓝牙是否已连接
export function isConnected(): boolean {
return connectedDeviceId != null
}
// 获取已连接设备id(未连接返回空串)
export function getConnectMac(): string {
return connectedDeviceId ?? ""
}
// 连接蓝牙
export function connect(mac: string, autoReconnect: boolean, cb: (res: MyApiResult) => void) {
ensureStateListener()
ensureConnListener()
manualDisconnect = false
uni.createBLEConnection({
deviceId: mac,
success: () => {
connectedDeviceId = mac
cb(ok("connected"))
},
fail: (err: any) => {
cb(failRes(10000, err))
}
})
}
// 断开蓝牙
export function disConnect() {
const id = connectedDeviceId
manualDisconnect = true
connectedDeviceId = null
if (id != null) {
uni.closeBLEConnection({
deviceId: id,
success: () => {},
fail: () => {}
})
}
}
// ================= 服务发现 =================
// 连接后扫描服务:确认 MCM 服务及 CLI 读写/参数只读特征存在
export function scanServices(cb: (res: MyApiResult) => void) {
const id = connectedDeviceId
if (id == null) {
cb({ type: 10001, message: "not connected", data: null } as MyApiResult)
return
}
uni.getBLEDeviceServices({
deviceId: id,
success: (res: any) => {
const services = res.services as any[]
let found = false
for (let i = 0; i < services.length; i++) {
if (uuidEq(services[i].uuid as string, MCM_SERVICE_UUID)) {
found = true
break
}
}
if (!found) {
cb({ type: 10004, message: "MCM service not found", data: null } as MyApiResult)
return
}
uni.getBLEDeviceCharacteristics({
deviceId: id as string,
serviceId: MCM_SERVICE_UUID,
success: (resCh: any) => {
const chars = resCh.characteristics as any[]
let hasCli = false
let hasInfo = false
let hasOta = false
for (let i = 0; i < chars.length; i++) {
const cu = chars[i].uuid as string
// 打印手机侧实际发现的特征及属性(排查 GATT 缓存/权限位用)
const props = chars[i].properties as any
console.log("[MCM] char " + cu +
" read=" + (props && props.read) +
" write=" + (props && props.write) +
" writeNoRsp=" + (props && props.writeNoResponse) +
" notify=" + (props && props.notify))
if (uuidEq(cu, MCM_CLI_UUID)) hasCli = true
if (uuidEq(cu, MCM_INFO_UUID)) hasInfo = true
if (uuidEq(cu, MCM_OTA_UUID)) hasOta = true
}
if (!hasCli || !hasInfo || !hasOta) {
cb({ type: 10005, message: "MCM cli/info/ota characteristic not found", data: null } as MyApiResult)
return
}
// 链路稳定期:首个 GATT 读写操作会等到此刻之后发出(安卓时序兼容)
linkReadyAt = Date.now() + GATT_SETTLE_MS
cb(ok("services ready"))
},
fail: (err: any) => {
cb(failRes(10005, err))
}
})
},
fail: (err: any) => {
cb(failRes(10004, err))
}
})
}
// ================= 读写 =================
// 各特征值的读写相互独立、可并行:不做跨特征的全局串行化。
// 仅保留两个连接级保护:服务发现后的短暂稳定期(安卓链路未稳时
// 读会无声丢失、写会误报 10007),以及写 10007 的延时重试。
const GATT_SETTLE_MS = 300 // scanServices 成功后首个 GATT 操作前的稳定等待
const WRITE_10007_RETRY_MS = 1600 // 10007 重试前的补等时长
let linkReadyAt = 0 // 链路就绪时刻(scanServices 成功时设置,断连清零)
// 写特征(通用,write-with-response):data 为原始字节
export function writeChar(charId: string, data: number[], cb: (res: MyApiResult) => void) {
const id = connectedDeviceId
if (id == null) {
cb({ type: 10001, message: "not connected", data: null } as MyApiResult)
return
}
const attempt = (isRetry: boolean) => {
if (connectedDeviceId == null) {
cb({ type: 10001, message: "disconnected", data: null } as MyApiResult)
return
}
const waitMs = linkReadyAt - Date.now()
if (waitMs > 0) {
setTimeout(() => { attempt(isRetry) }, waitMs)
return
}
const buf = new Uint8Array(data.length)
for (let i = 0; i < data.length; i++) {
buf[i] = data[i] & 0xFF
}
uni.writeBLECharacteristicValue({
deviceId: id,
serviceId: MCM_SERVICE_UUID,
characteristicId: charId,
value: buf.buffer,
writeType: "write",
success: () => {
console.log("[MCM] tx " + charTag(charId) + " " + data.length + "B ok: " + bytesHex(data, 48))
cb(ok("write ok"))
},
fail: (err: any) => {
const msg: string = (err && err.errMsg) ? err.errMsg : "fail"
// 部分安卓机型链路未稳定时误报 10007 property not support:补等后重试一次
if (!isRetry && msg.indexOf("property not support") >= 0) {
console.log("[MCM] tx " + charTag(charId) + " 10007, wait " +
WRITE_10007_RETRY_MS + "ms and retry")
setTimeout(() => { attempt(true) }, WRITE_10007_RETRY_MS)
return
}
console.log("[MCM] tx " + charTag(charId) + " FAIL: " + msg)
cb(failRes(10008, err))
}
})
}
attempt(false)
}
// 写 CLI 特征(write-with-response):data 为原始命令行字节(≤63B,且不超 mtu-3)
export function writeCliChar(data: number[], cb: (res: MyApiResult) => void) {
writeChar(MCM_CLI_UUID, data, cb)
}
// 写 OTA 特征(write-with-response):data 为一帧完整 0xCA OTA 传输帧;
// 应答经 readChar(MCM_OTA_UUID) 轮询取回(读可能先返回空,需读到非空为止)
export function writeOtaChar(data: number[], cb: (res: MyApiResult) => void) {
writeChar(MCM_OTA_UUID, data, cb)
}
// 读特征(通用):结果经 onBLECharacteristicValueChange 按 characteristicId 分发,
// 同一特征上的读串行排队,不同特征各自独立、可并行;每请求 3s 超时;
// type==0 时 data 为 number[]
export function readChar(charId: string, cb: (res: MyApiResult) => void) {
ensureValueChangeListener()
const key = charKey(charId)
if (readQueues[key] == null) readQueues[key] = [] as ReadReq[]
readQueues[key].push({ charId: charId, cb: cb, retried: false } as ReadReq)
pumpReadQueue(key)
}
// 读取设备参数特征(只读 MCM_INFO_UUID,内容为全部信息项 TLV)
export function readInfoChar(cb: (res: MyApiResult) => void) {
readChar(MCM_INFO_UUID, cb)
}
// ================= 其他 =================
// 设置 mtu(仅 App 端有效;其他平台回调失败,调用方保持默认 20 字节上限)
export function setMtu(len: number, cb: (res: MyApiResult) => void) {
const id = connectedDeviceId
if (id == null) {
cb({ type: 10001, message: "not connected", data: null } as MyApiResult)
return
}
// #ifdef APP-PLUS
uni.setBLEMTU({
deviceId: id,
mtu: len,
success: (res: any) => {
// 部分平台(实测有的 Android 机型/uni 版本)成功回调不带 mtu 字段:
// 此时按请求值记录(设备侧每连接也会主动发起 MTU=247 交换)
const m = res ? (res.mtu as number) : (undefined as any)
negotiatedMtu = (typeof m == "number" && m >= 23) ? m : len
cb({ type: 0, message: "mtu " + negotiatedMtu, data: null } as MyApiResult)
},
fail: (err: any) => {
cb(failRes(10008, err))
}
})
// #endif
// #ifndef APP-PLUS
cb({ type: 10008, message: "set mtu not supported on this platform", data: null } as MyApiResult)
// #endif
}
// 当前协商的 ATT MTU(未调用过 setMtu 或协商失败时为默认 23)
export function getMtu(): number {
return negotiatedMtu
}
// 读取 rssi 信号 需要蓝牙已经连接
export function readRssi(cb: (res: MyApiResult) => void) {
const id = connectedDeviceId
if (id == null) {
cb({ type: 10001, message: "not connected", data: null } as MyApiResult)
return
}
uni.getBLEDeviceRSSI({
deviceId: id,
success: (res: any) => {
cb({ type: 0, message: "rssi", data: res.RSSI } as MyApiResult)
},
fail: (err: any) => {
cb(failRes(10008, err))
}
})
}