/** * 蓝牙管理模块(基于 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)) } }) }