977 lines
40 KiB
Plaintext
977 lines
40 KiB
Plaintext
// Minimal ProtocolHandler runtime class used by pages and components.
|
||
// This class adapts the platform `BluetoothService` to a small protocol API
|
||
// expected by pages: setConnectionParameters, initialize, testBatteryLevel,
|
||
// testVersionInfo. Implemented conservatively to avoid heavy dependencies.
|
||
|
||
import type { BluetoothService, AutoBleInterfaces, BleService, BleCharacteristic, BleProtocolType, BleDevice, ScanDevicesOptions, BleConnectOptionsExt, SendDataPayload, BleOptions, HealthSubscription, WbbpPacket, HealthData } from './interface.uts'
|
||
|
||
export type PingResult = {
|
||
seq : number;
|
||
latencyMs : number;
|
||
payload : Uint8Array;
|
||
raw : Uint8Array;
|
||
ok : boolean;
|
||
}
|
||
|
||
type PendingPingRequest = {
|
||
seq : number;
|
||
resolve : (result : PingResult) => void;
|
||
reject : (reason ?: any) => void;
|
||
startedAt : number;
|
||
timer : number | null;
|
||
}
|
||
|
||
export class ProtocolHandler {
|
||
// bluetoothService may be omitted for lightweight wrappers; allow null
|
||
bluetoothService : BluetoothService | null = null
|
||
protocol : BleProtocolType = 'standard'
|
||
deviceId : string | null = null
|
||
serviceId : string | null = null
|
||
writeCharId : string | null = null
|
||
notifyCharId : string | null = null
|
||
|
||
healthSubscriptions : HealthSubscription[] = []
|
||
initialized : boolean = false
|
||
controlResolved : boolean = false
|
||
pingSequence : number = 1
|
||
pendingPingRequests : PendingPingRequest[] = []
|
||
|
||
// Accept an optional BluetoothService-like object so wrapper subclasses can call
|
||
// `super()` without forcing a runtime instance. Allow broader inputs to satisfy
|
||
// legacy callers that pass platform-specific subclasses at runtime.
|
||
constructor(bluetoothService ?: BluetoothService | any) {
|
||
if (bluetoothService != null) {
|
||
this.bluetoothService = bluetoothService as BluetoothService
|
||
}
|
||
}
|
||
// Store active health subscriptions so we can unsubscribe later
|
||
|
||
|
||
// onData will be called with a parsed HealthData object
|
||
async subscribeHealthNotifications(onData : (data : HealthData) => void) {
|
||
if (this.deviceId == null) throw new Error('deviceId not set')
|
||
if (this.bluetoothService == null) throw new Error('bluetoothService not set')
|
||
const dev = '' + this.deviceId
|
||
const bsvc = this.bluetoothService
|
||
const self = this
|
||
|
||
function wrapHealthPacket(data: HealthData): HealthData {
|
||
// Since healthble.uvue handles both direct property access and method calls,
|
||
// we can just return the data as-is
|
||
return data
|
||
}
|
||
|
||
const deliver = (payload : HealthData) => {
|
||
try {
|
||
onData(wrapHealthPacket(payload))
|
||
} catch (deliverError) {
|
||
console.warn('[ProtocolHandler] health notify delivery error', deliverError)
|
||
}
|
||
}
|
||
|
||
// candidate UUIDs per protocol doc
|
||
const CUSTOM_HEALTH_SERVICE = '6e400010-b5a3-f393-e0a9-e50e24dcca9e'
|
||
const CUSTOM_HEART = '6e400011-b5a3-f393-e0a9-e50e24dcca9e'
|
||
const CUSTOM_STEP = '6e400012-b5a3-f393-e0a9-e50e24dcca9e'
|
||
const CUSTOM_SLEEP = '6e400013-b5a3-f393-e0a9-e50e24dcca9e'
|
||
// also consider standard characteristics
|
||
const STD_HEART_CHAR = '00002a37-0000-1000-8000-00805f9b34fb' // 2A37
|
||
const STD_SPO2_CHAR = '00002a5f-0000-1000-8000-00805f9b34fb' // 2A5F (if present)
|
||
|
||
function xorCheck(buf : Uint8Array) : boolean {
|
||
if (buf == null || buf.length < 4) return false
|
||
let x = 0
|
||
for (let i = 1; i < buf.length - 1; i++) x ^= buf[i]
|
||
return (x & 0xff) == buf[buf.length - 1]
|
||
}
|
||
|
||
|
||
function parseWbbp(buf : Uint8Array) : WbbpPacket | null {
|
||
// STX LEN CMD SEQ DATA.. CRC(1 or 2 bytes)
|
||
if (buf == null || buf.length < 5) return null
|
||
if (buf[0] != 0xAA) return null
|
||
const len = buf[1]
|
||
const minTotal = len + 3 // STX + LEN + LEN bytes + CRC(1)
|
||
const altTotal = len + 4 // allow CRC(2) variant observed on newer devices
|
||
if (buf.length < minTotal) return null
|
||
|
||
const dataEnd = 2 + len // index after CMD+SEQ+payload
|
||
const crcLength = buf.length - dataEnd
|
||
if (crcLength < 1) return null
|
||
|
||
if (crcLength == 1) {
|
||
// original XOR checksum validation
|
||
let x = 0
|
||
for (let i = 1; i < dataEnd; i++) {
|
||
x ^= buf[i]
|
||
}
|
||
if ((x & 0xff) != buf[buf.length - 1]) {
|
||
console.warn('[ProtocolHandler] WBBP checksum mismatch (xor)', { len, data: Array.from(buf) })
|
||
return null
|
||
}
|
||
} else {
|
||
// two-byte checksum present – accept packet even without knowing CRC16 polynomial
|
||
if (buf.length != altTotal) {
|
||
console.warn('[ProtocolHandler] WBBP packet length unexpected', { len, total: buf.length })
|
||
return null
|
||
}
|
||
}
|
||
|
||
const cmd = buf[2]
|
||
const seq = buf[3]
|
||
const payloadLength = Math.max(0, len - 2)
|
||
const payload = buf.slice(4, 4 + payloadLength)
|
||
return { cmd, seq, payload }
|
||
}
|
||
|
||
function parseHeartStandard(buf : Uint8Array) {
|
||
// Bluetooth SIG Heart Rate Measurement (2A37)
|
||
if (buf == null || buf.length < 2) return null
|
||
const flags = buf[0]
|
||
const hrFormat16 = (flags & 0x01) != 0
|
||
let hr = 0
|
||
if (!hrFormat16) {
|
||
hr = buf[1]
|
||
} else {
|
||
hr = buf[1] | (buf[2] << 8)
|
||
}
|
||
return { heartRate: hr }
|
||
}
|
||
|
||
// subscribe helper that registers a callback and stores subscription info
|
||
async function subscribeChar(svc : string, char : string) {
|
||
try {
|
||
console.log('[ProtocolHandler] subscribing to', svc, char)
|
||
await bsvc.subscribeCharacteristic(dev, svc, char, (data : Uint8Array) => {
|
||
try {
|
||
// try WBBP parsing first
|
||
console.log(char,data)
|
||
const pkt = parseWbbp(data) as WbbpPacket | null
|
||
if (pkt != null) {
|
||
const cmd = pkt.cmd
|
||
const seq = pkt.seq
|
||
const p = pkt.payload
|
||
if (cmd == 0x00) {
|
||
self.handlePingNotify(seq, data, p)
|
||
return
|
||
} else if (cmd == 0x10) {
|
||
// heart rate packet per doc: timestamp(4) + heart(1) + quality(1)
|
||
if (p.length >= 6) {
|
||
const ts = (p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]
|
||
const heart = p[4]
|
||
const quality = p[5]
|
||
deliver({ type: 'heart', cmd, seq, timestamp: ts, heartRate: heart, quality })
|
||
return
|
||
}
|
||
} else if (cmd == 0x14) {
|
||
// (subscription recorded later)
|
||
if (p.length >= 8) {
|
||
const ts = (p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]
|
||
const spo2 = p[4]
|
||
const quality = p[5]
|
||
const pulse = p[6]
|
||
deliver({ type: 'spo2', cmd, seq, timestamp: ts, spo2, pulse, quality })
|
||
return
|
||
}
|
||
} else if (cmd == 0x11) {
|
||
// step count: timestamp(4) + steps(4) + distance(2) + calories(2) + activity(1)
|
||
if (p.length >= 13) {
|
||
const ts = (p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]
|
||
const steps = (p[4] << 24) | (p[5] << 16) | (p[6] << 8) | p[7]
|
||
const distance = (p[8] << 8) | p[9]
|
||
const calories = (p[10] << 8) | p[11]
|
||
const activity = p[12]
|
||
deliver({ type: 'steps', cmd, seq, timestamp: ts, steps, distance, calories, activity })
|
||
return
|
||
}
|
||
}
|
||
}
|
||
// if not WBBP or parsing failed, try standard heart/spo2 formats
|
||
const stdHeart = parseHeartStandard(data)
|
||
if (stdHeart != null) {
|
||
const heartRate = (stdHeart as UTSJSONObject)['heartRate'] as number
|
||
const data : HealthData = { type: 'heart', heartRate: heartRate }
|
||
deliver(data)
|
||
return
|
||
}
|
||
// fallback: raw notify
|
||
deliver({ type: 'raw', raw: data })
|
||
} catch (e) { console.warn('[ProtocolHandler] health notify handler error', e) }
|
||
})
|
||
// remember to unsubscribe later
|
||
self.healthSubscriptions.push({ serviceUuid: svc, charUuid: char })
|
||
console.log('[ProtocolHandler] subscribeChar registered', svc, char)
|
||
} catch (e) { console.warn('[ProtocolHandler] subscribeChar failed', svc, char, e) }
|
||
}
|
||
|
||
// try subscribe to custom health service chars first
|
||
try {
|
||
const allSvcs = await bsvc.getServices(dev)
|
||
console.log('[ProtocolHandler] allServices', allSvcs)
|
||
const svcSet = new Set(allSvcs.map((s : BleService) => ('' + s.uuid).toLowerCase()))
|
||
if (svcSet.has(CUSTOM_HEALTH_SERVICE) || Array.from(svcSet).some((u) => (u as string).indexOf('6e400010') != -1)) {
|
||
console.log('[ProtocolHandler] going to subscribe', CUSTOM_HEALTH_SERVICE, CUSTOM_HEART, CUSTOM_STEP, CUSTOM_SLEEP)
|
||
// prefer custom UUIDs
|
||
await subscribeChar(CUSTOM_HEALTH_SERVICE, CUSTOM_HEART)
|
||
await subscribeChar(CUSTOM_HEALTH_SERVICE, CUSTOM_STEP)
|
||
await subscribeChar(CUSTOM_HEALTH_SERVICE, CUSTOM_SLEEP)
|
||
} else {
|
||
// fallback: try standard heart and spo2 characteristics across services
|
||
for (let i = 0; i < allSvcs.length; i++) {
|
||
const s = allSvcs[i]
|
||
if (s == null) continue
|
||
const sUuid = '' + s.uuid
|
||
const chars = await bsvc.getCharacteristics(dev, sUuid)
|
||
for (let j = 0; j < chars.length; j++) {
|
||
const c = chars[j]
|
||
if (c == null || c.uuid == null) continue
|
||
const id = ('' + c.uuid).toLowerCase()
|
||
if (id == STD_HEART_CHAR || id.indexOf('2a37') != -1) {
|
||
await subscribeChar(sUuid, c.uuid)
|
||
}
|
||
if (id == STD_SPO2_CHAR || id.indexOf('2a5f') != -1) {
|
||
await subscribeChar(sUuid, c.uuid)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { console.warn('[ProtocolHandler] subscribeHealthNotifications failed', e) }
|
||
// If we didn't find standard/custom health characteristics, try a conservative
|
||
// fallback: subscribe to any notify/indicate-capable characteristic so we can
|
||
// receive vendor-specific notifies. Cap the number to avoid too many subscriptions.
|
||
if (this.healthSubscriptions.length == 0) {
|
||
console.log('[ProtocolHandler] no known health chars found — attempting auto-subscribe to notify-capable characteristics')
|
||
try {
|
||
let autoCount = 0
|
||
const svcList = await bsvc.getServices(dev)
|
||
for (let si = 0; si < svcList.length; si++) {
|
||
if (autoCount >= 6) break
|
||
const s = svcList[si]
|
||
if (s == null) continue
|
||
const sUuid = '' + s.uuid
|
||
const chars = await bsvc.getCharacteristics(dev, sUuid)
|
||
for (let ci = 0; ci < chars.length; ci++) {
|
||
if (autoCount >= 6) break
|
||
const c = chars[ci]
|
||
if (c == null) continue
|
||
try {
|
||
const _propsAny = c.properties
|
||
if (c.properties != null && (_propsAny.notify || _propsAny.indicate)) {
|
||
await subscribeChar(sUuid, c.uuid)
|
||
autoCount++
|
||
console.log('[ProtocolHandler] auto-subscribed to', sUuid, c.uuid)
|
||
}
|
||
} catch (e) { console.warn('[ProtocolHandler] auto-subscribe char failed', sUuid, c.uuid, e) }
|
||
}
|
||
}
|
||
} catch (e) { console.warn('[ProtocolHandler] auto-subscribe notify-capable failed', e) }
|
||
}
|
||
console.log('[ProtocolHandler] subscribeHealthNotifications complete, subscriptions=', this.healthSubscriptions.length)
|
||
}
|
||
|
||
handlePingNotify(seq : number, raw : Uint8Array, payload : Uint8Array) : void {
|
||
try {
|
||
console.log('[ProtocolHandler] PING received', { seq, raw: Array.from(raw) })
|
||
} catch (e) { }
|
||
for (let i = 0; i < this.pendingPingRequests.length; i++) {
|
||
const entry = this.pendingPingRequests[i]
|
||
if (entry == null) continue
|
||
if (entry.seq != seq) continue
|
||
if (entry.timer != null) {
|
||
clearTimeout(entry.timer as number)
|
||
entry.timer = null
|
||
}
|
||
this.pendingPingRequests.splice(i, 1)
|
||
const latency = Math.max(0, Date.now() - entry.startedAt)
|
||
try {
|
||
const payloadCopy = payload != null ? payload.slice(0) : new Uint8Array(0)
|
||
const rawCopy = raw != null ? raw.slice(0) : new Uint8Array(0)
|
||
entry.resolve({ ok: true, seq, latencyMs: latency, payload: payloadCopy, raw: rawCopy })
|
||
} catch (resolveError) {
|
||
console.warn('[ProtocolHandler] ping resolve error', resolveError)
|
||
}
|
||
return
|
||
}
|
||
// no pending ping matched — still log for diagnostics
|
||
try {
|
||
console.warn('[ProtocolHandler] ping notification with no pending request', { seq })
|
||
} catch (e) { }
|
||
}
|
||
|
||
removePendingPing(entry : PendingPingRequest | null) : void {
|
||
if (entry == null) return
|
||
if (entry.timer != null) {
|
||
clearTimeout(entry.timer as number)
|
||
entry.timer = null
|
||
}
|
||
const idx = this.pendingPingRequests.indexOf(entry)
|
||
if (idx != -1) {
|
||
this.pendingPingRequests.splice(idx, 1)
|
||
}
|
||
}
|
||
|
||
rejectPendingPings(reason : string) : void {
|
||
const err = new Error(reason)
|
||
for (let i = 0; i < this.pendingPingRequests.length; i++) {
|
||
const entry = this.pendingPingRequests[i]
|
||
if (entry == null) continue
|
||
if (entry.timer != null) {
|
||
clearTimeout(entry.timer!!)
|
||
entry.timer = null
|
||
}
|
||
try {
|
||
entry.reject(err)
|
||
} catch (rejectError) {
|
||
console.warn('[ProtocolHandler] ping reject error', rejectError)
|
||
}
|
||
}
|
||
this.pendingPingRequests = []
|
||
}
|
||
|
||
async unsubscribeHealthNotifications() {
|
||
if (this.deviceId == null) return
|
||
if (this.bluetoothService == null) return
|
||
const dev = '' + this.deviceId
|
||
const bsvc = this.bluetoothService
|
||
for (let i = 0; i < this.healthSubscriptions.length; i++) {
|
||
const s = this.healthSubscriptions[i]
|
||
try { await bsvc.unsubscribeCharacteristic(dev, s.serviceUuid, s.charUuid) } catch (e) { console.warn('[ProtocolHandler] unsubscribe failed', s, e) }
|
||
}
|
||
this.healthSubscriptions = []
|
||
this.rejectPendingPings('notifications cancelled')
|
||
}
|
||
|
||
setConnectionParameters(deviceId : string, serviceId : string, writeCharId : string, notifyCharId : string) {
|
||
const previousDevice = this.deviceId
|
||
if (previousDevice != null && previousDevice != deviceId) {
|
||
this.rejectPendingPings('device changed')
|
||
}
|
||
this.deviceId = deviceId
|
||
this.serviceId = serviceId
|
||
this.writeCharId = writeCharId
|
||
this.notifyCharId = notifyCharId
|
||
const hasService = serviceId != null && serviceId != ''
|
||
const hasWrite = writeCharId != null && writeCharId != ''
|
||
const hasNotify = notifyCharId != null && notifyCharId != ''
|
||
this.controlResolved = hasService && hasWrite && hasNotify
|
||
if (!this.controlResolved) {
|
||
this.pingSequence = 1
|
||
}
|
||
}
|
||
|
||
nextPingSequence() : number {
|
||
const seq = this.pingSequence & 0xff
|
||
this.pingSequence = (this.pingSequence + 1) & 0xff
|
||
if (this.pingSequence == 0) this.pingSequence = 1
|
||
return seq == 0 ? 1 : seq
|
||
}
|
||
|
||
buildVendorPacket(cmd : number, seq : number, data ?: number[]) : Uint8Array {
|
||
const payload = data != null ? data : [] as number[]
|
||
const len = 2 + payload.length
|
||
const arr : number[] = []
|
||
arr.push(len)
|
||
arr.push(cmd & 0xff)
|
||
arr.push(seq & 0xff)
|
||
for (let i = 0; i < payload.length; i++) arr.push(payload[i] & 0xff)
|
||
let crc = 0
|
||
for (let i = 0; i < arr.length; i++) crc ^= (arr[i] & 0xff)
|
||
const pkt = new Uint8Array(arr.length + 2)
|
||
pkt[0] = 0xAA
|
||
for (let i = 0; i < arr.length; i++) pkt[i + 1] = arr[i] & 0xff
|
||
pkt[pkt.length - 1] = crc & 0xff
|
||
return pkt
|
||
}
|
||
|
||
async prepareControlChannel(force : boolean = false) : Promise<AutoBleInterfaces | null> {
|
||
return await this.ensureControlChannel(force)
|
||
}
|
||
|
||
async ensureControlChannel(force : boolean = false) : Promise<AutoBleInterfaces | null> {
|
||
const deviceIdLocal = this.deviceId
|
||
const bsvc = this.bluetoothService
|
||
if (deviceIdLocal == null || deviceIdLocal == '') return null
|
||
if (bsvc == null) throw new Error('bluetoothService not set')
|
||
if (!force) {
|
||
const hasService = this.serviceId != null && this.serviceId != ''
|
||
const hasWrite = this.writeCharId != null && this.writeCharId != ''
|
||
const hasNotify = this.notifyCharId != null && this.notifyCharId != ''
|
||
if (hasService && hasWrite && hasNotify) {
|
||
return { serviceId: this.serviceId ?? '', writeCharId: this.writeCharId ?? '', notifyCharId: this.notifyCharId ?? '' }
|
||
}
|
||
}
|
||
let resolved : AutoBleInterfaces | null = null
|
||
try {
|
||
const candidate = await bsvc.getAutoBleInterfaces(deviceIdLocal)
|
||
if (candidate != null && candidate.serviceId != null && candidate.serviceId != '' && candidate.writeCharId != null && candidate.writeCharId != '' && candidate.notifyCharId != null && candidate.notifyCharId != '') {
|
||
resolved = candidate
|
||
}
|
||
} catch (autoErr) {
|
||
console.warn('[ProtocolHandler] getAutoBleInterfaces failed', autoErr)
|
||
}
|
||
if (resolved == null) {
|
||
resolved = await this.findVendorControlChannel(deviceIdLocal)
|
||
}
|
||
if (resolved != null) {
|
||
this.serviceId = '' + resolved.serviceId
|
||
this.writeCharId = '' + resolved.writeCharId
|
||
this.notifyCharId = '' + resolved.notifyCharId
|
||
this.controlResolved = true
|
||
return { serviceId: this.serviceId ?? '', writeCharId: this.writeCharId ?? '', notifyCharId: this.notifyCharId ?? '' }
|
||
}
|
||
return null
|
||
}
|
||
|
||
async findVendorControlChannel(deviceId : string) : Promise<AutoBleInterfaces | null> {
|
||
const bsvc = this.bluetoothService
|
||
if (bsvc == null) return null
|
||
try {
|
||
const services = await bsvc.getServices(deviceId)
|
||
if (services == null || services.length == 0) return null
|
||
const UART_SERVICE = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
|
||
const UART_TX = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'
|
||
const UART_RX = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'
|
||
const UART_CTRL = '6e400004-b5a3-f393-e0a9-e50e24dcca9e'
|
||
let chosenService = ''
|
||
for (let i = 0; i < services.length; i++) {
|
||
const svc = services[i]
|
||
if (svc == null || svc.uuid == null) continue
|
||
const su = ('' + svc.uuid).toLowerCase()
|
||
if (su.indexOf('6e400001') != -1 || su == UART_SERVICE) {
|
||
chosenService = '' + svc.uuid
|
||
break
|
||
}
|
||
}
|
||
if (chosenService == '') {
|
||
const fallbackSvc = services[0]
|
||
if (fallbackSvc != null && fallbackSvc.uuid != null) chosenService = '' + fallbackSvc.uuid
|
||
}
|
||
if (chosenService == '') return null
|
||
const chars = await bsvc.getCharacteristics(deviceId, chosenService)
|
||
if (chars == null || chars.length == 0) return null
|
||
let ctrlCandidate = ''
|
||
let txCandidate = ''
|
||
let notifyCandidate = ''
|
||
let fallbackWrite = ''
|
||
let fallbackNotify = ''
|
||
for (let i = 0; i < chars.length; i++) {
|
||
const c = chars[i]
|
||
if (c == null || c.uuid == null) continue
|
||
const idLower = ('' + c.uuid).toLowerCase()
|
||
if (idLower == UART_CTRL) ctrlCandidate = '' + c.uuid
|
||
if (idLower == UART_TX) txCandidate = '' + c.uuid
|
||
if (idLower == UART_RX) notifyCandidate = '' + c.uuid
|
||
if (fallbackWrite == '' && c.properties != null && (c.properties.write || c.properties.writeWithoutResponse == true)) fallbackWrite = '' + c.uuid
|
||
if (fallbackNotify == '' && c.properties != null && (c.properties.notify || c.properties.indicate)) fallbackNotify = '' + c.uuid
|
||
}
|
||
let writeChar = ctrlCandidate != '' ? ctrlCandidate : txCandidate
|
||
if (writeChar == '' && fallbackWrite != '') writeChar = fallbackWrite
|
||
let notifyChar = notifyCandidate != '' ? notifyCandidate : fallbackNotify
|
||
if (writeChar == '' || notifyChar == '') return null
|
||
return { serviceId: chosenService, writeCharId: writeChar, notifyCharId: notifyChar }
|
||
} catch (err) {
|
||
console.warn('[ProtocolHandler] findVendorControlChannel failed', err)
|
||
}
|
||
return null
|
||
}
|
||
|
||
async sendPing(timeoutMs : number = 3000) : Promise<PingResult> {
|
||
if (timeoutMs <= 0) timeoutMs = 3000
|
||
const deviceIdLocal = this.deviceId
|
||
const bsvc = this.bluetoothService
|
||
if (deviceIdLocal == null || deviceIdLocal == '') throw new Error('deviceId not set')
|
||
if (bsvc == null) throw new Error('bluetoothService not set')
|
||
const channel = await this.ensureControlChannel(false)
|
||
if (channel == null || channel.serviceId == null || channel.serviceId == '' || channel.writeCharId == null || channel.writeCharId == '') {
|
||
throw new Error('control channel unavailable')
|
||
}
|
||
const seq = this.nextPingSequence()
|
||
const frame = this.buildVendorPacket(0x00, seq, [0x00])
|
||
const startedAt = Date.now()
|
||
const self = this
|
||
return await new Promise<PingResult>((resolve, reject) => {
|
||
const entry : PendingPingRequest = { seq, resolve, reject, startedAt, timer: null }
|
||
const timeoutHandle = setTimeout(() => {
|
||
self.removePendingPing(entry)
|
||
reject(new Error('ping timeout'))
|
||
}, timeoutMs)
|
||
entry.timer = timeoutHandle as number
|
||
self.pendingPingRequests.push(entry)
|
||
const serviceUuid = channel.serviceId
|
||
const writeUuid = channel.writeCharId
|
||
;(async () => {
|
||
try {
|
||
const ok = await bsvc.writeCharacteristic(deviceIdLocal, serviceUuid, writeUuid, frame, { waitForResponse: true })
|
||
if (!ok) {
|
||
self.removePendingPing(entry)
|
||
reject(new Error('ping write failed'))
|
||
}
|
||
} catch (err) {
|
||
self.removePendingPing(entry)
|
||
reject(err)
|
||
}
|
||
})()
|
||
})
|
||
}
|
||
|
||
// initialize: optional setup, returns a Promise that resolves when ready
|
||
async initialize() : Promise<void> {
|
||
// Simple async initializer — keep implementation minimal and generator-friendly.
|
||
try {
|
||
// If bluetoothService exposes any protocol-specific setup, call it here.
|
||
this.initialized = true
|
||
return
|
||
} catch (e) {
|
||
throw e
|
||
}
|
||
}
|
||
|
||
// Protocol lifecycle / operations — default no-ops so generated code has
|
||
// concrete member references and platform-specific handlers can override.
|
||
async scanDevices(options ?: ScanDevicesOptions) : Promise<void> { return; }
|
||
async connect(device : BleDevice, options ?: BleConnectOptionsExt) : Promise<void> { return; }
|
||
async disconnect(device : BleDevice) : Promise<void> { return; }
|
||
async sendData(device : BleDevice, payload ?: SendDataPayload, options ?: BleOptions) : Promise<void> { return; }
|
||
async autoConnect(device : BleDevice, options ?: BleConnectOptionsExt) : Promise<AutoBleInterfaces> { return { serviceId: '', writeCharId: '', notifyCharId: '' }; }
|
||
|
||
// Example: testBatteryLevel will attempt to read the battery characteristic
|
||
// if write/notify-based protocol is not available. Returns number percentage.
|
||
async testBatteryLevel() : Promise<number> {
|
||
if (this.deviceId == null) throw new Error('deviceId not set')
|
||
// copy to local so Kotlin generator can smart-cast the value across awaits
|
||
const deviceId = this.deviceId
|
||
// try reading standard Battery characteristic (180F -> 2A19)
|
||
if (this.bluetoothService == null) throw new Error('bluetoothService not set')
|
||
const services = await this.bluetoothService.getServices(deviceId)
|
||
|
||
|
||
let found : BleService | null = null
|
||
for (let i = 0; i < services.length; i++) {
|
||
const s = services[i]
|
||
const uuidCandidate : string | null = (s != null && s.uuid != null ? s.uuid : null)
|
||
const uuid = uuidCandidate != null ? ('' + uuidCandidate).toLowerCase() : ''
|
||
if (uuid.indexOf('180f') != -1) { found = s; break }
|
||
}
|
||
if (found == null) {
|
||
// fallback: if writeCharId exists and notify available use protocol (not implemented)
|
||
return -1
|
||
}
|
||
const foundUuid = found!.uuid
|
||
const charsRaw = await this.bluetoothService.getCharacteristics(deviceId, foundUuid)
|
||
const chars : BleCharacteristic[] = charsRaw
|
||
const batChar = chars.find((c : BleCharacteristic) => ((c.properties != null && c.properties.read) || (c.uuid != null && ('' + c.uuid).toLowerCase().includes('2a19'))))
|
||
if (batChar == null) return -1
|
||
const buf = await this.bluetoothService.readCharacteristic(deviceId, foundUuid, batChar.uuid)
|
||
const arr = new Uint8Array(buf)
|
||
try {
|
||
const hex = Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join(' ')
|
||
console.log('[ProtocolHandler] testBatteryLevel raw hex:', hex, 'len=', arr.length)
|
||
} catch (e) { }
|
||
if (arr.length > 0) {
|
||
console.log('[ProtocolHandler] testBatteryLevel parsed battery:', arr[0])
|
||
return arr[0]
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// Best-effort time synchronization on connect.
|
||
// Try writing standard Current Time Service (CTS: 0x1805 / char 0x2A2B).
|
||
// Keep H5-compatible: return Promise<string> with 'timeSynced' or 'timeFailed' or ''.
|
||
async synchronizeOnConnect() : Promise<string> {
|
||
const deviceId = this.deviceId
|
||
const bsvc = this.bluetoothService
|
||
if (deviceId == null || bsvc == null) return Promise.resolve('')
|
||
const dev = '' + deviceId
|
||
try {
|
||
console.log('[ProtocolHandler] synchronizeOnConnect: attempting CTS time write')
|
||
const svcs = await bsvc.getServices(dev)
|
||
for (let i = 0; i < svcs.length; i++) {
|
||
const s = svcs[i]
|
||
if (s == null || s.uuid == null) continue
|
||
const su = ('' + s.uuid).toLowerCase()
|
||
if (su.indexOf('1805') == -1 && su.indexOf('current') == -1) continue
|
||
const svcUuid = '' + s.uuid
|
||
try {
|
||
const chars = await bsvc.getCharacteristics(dev, svcUuid)
|
||
for (let j = 0; j < chars.length; j++) {
|
||
const c = chars[j]
|
||
if (c == null || c.uuid == null) continue
|
||
const id = ('' + c.uuid).toLowerCase()
|
||
if (id.indexOf('2a2b') != -1) {
|
||
const now = new Date()
|
||
const year = now.getFullYear()
|
||
const month = now.getMonth() + 1
|
||
const day = now.getDate()
|
||
const hours = now.getHours()
|
||
const minutes = now.getMinutes()
|
||
const seconds = now.getSeconds()
|
||
const jsDay = now.getDay()
|
||
const dayOfWeek = jsDay == 0 ? 7 : jsDay
|
||
const payload = new Uint8Array(10)
|
||
payload[0] = year & 0xff
|
||
payload[1] = (year >> 8) & 0xff
|
||
payload[2] = month
|
||
payload[3] = day
|
||
payload[4] = hours
|
||
payload[5] = minutes
|
||
payload[6] = seconds
|
||
payload[7] = dayOfWeek
|
||
payload[8] = 0 // fractions256
|
||
payload[9] = 1 // adjust reason: manual update
|
||
try {
|
||
await bsvc.writeCharacteristic(dev, svcUuid, c.uuid, payload, { waitForResponse: true })
|
||
console.log('[ProtocolHandler] CTS time write succeeded')
|
||
return 'timeSynced'
|
||
} catch (e) {
|
||
console.warn('[ProtocolHandler] CTS write failed', e)
|
||
// try other chars/services
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[ProtocolHandler] error enumerating CTS chars', e)
|
||
}
|
||
}
|
||
console.log('[ProtocolHandler] CTS not available or writes failed')
|
||
return 'timeFailed'
|
||
} catch (e) {
|
||
console.warn('[ProtocolHandler] synchronizeOnConnect unexpected error', e)
|
||
return ''
|
||
}
|
||
}
|
||
|
||
// testVersionInfo: try to read Device Information characteristics or return empty
|
||
async testVersionInfo(hw : boolean) : Promise<string> {
|
||
// copy to local so Kotlin generator can smart-cast the value across awaits
|
||
console.log('testVersionInfo:',hw,this.deviceId,this.bluetoothService)
|
||
const deviceId = this.deviceId
|
||
if (deviceId == null) return ''
|
||
// Device Information service 180A, characteristics: 2A26 (SW), 2A27 (HW) sometimes
|
||
if (this.bluetoothService == null) return ''
|
||
// Add delay to allow service discovery to complete on Android reconnection
|
||
await new Promise<void>((resolve) => { setTimeout(() => resolve(), 1000) })
|
||
const _services = await this.bluetoothService.getServices(deviceId)
|
||
const services2 : BleService[] = _services
|
||
console.log('[ProtocolHandler] services2 length:', services2.length)
|
||
for (let i = 0; i < services2.length; i++) {
|
||
const s = services2[i]
|
||
console.log('[ProtocolHandler] service', i, 'uuid:', s?.uuid)
|
||
}
|
||
let found2 : BleService | null = null
|
||
for (let i = 0; i < services2.length; i++) {
|
||
const s = services2[i]
|
||
const uuidCandidate : string | null = (s != null && s.uuid != null ? s.uuid : null)
|
||
const uuid = uuidCandidate != null ? ('' + uuidCandidate).toLowerCase() : ''
|
||
if (uuid.indexOf('180a') != -1) { found2 = s; break }
|
||
}
|
||
console.log('testVersionInfo 1:',hw)
|
||
// If Device Information service exists, locate candidate characteristics.
|
||
// Do NOT return early if the service is missing — fallbacks (including UART) exist below.
|
||
let found2Uuid : string | null = null
|
||
let chars : BleCharacteristic[] = []
|
||
let target : BleCharacteristic | null = null
|
||
if (found2 != null) {
|
||
console.log('testVersionInfo 2:',found2)
|
||
const found2NonNull = found2 as BleService
|
||
found2Uuid = '' + found2NonNull.uuid
|
||
try {
|
||
console.log('testVersionInfo 2:',found2)
|
||
const _chars = await this.bluetoothService.getCharacteristics(deviceId, found2Uuid)
|
||
console.log('ak _chars:', _chars)
|
||
chars = _chars as BleCharacteristic[]
|
||
target = chars.find((c) => {
|
||
const id = ('' + c.uuid).toLowerCase()
|
||
console.log('[ProtocolHandler] checking char uuid:', c.uuid, 'id:', id, 'hw:', hw)
|
||
if (hw) {
|
||
const match = id.includes('2a27') || id.includes('hardware')
|
||
console.log('[ProtocolHandler] hw match:', match)
|
||
return match
|
||
}
|
||
const match1 = id.includes('2a26') || id.includes('firmware')
|
||
const match2 = id.includes('2a28') || id.includes('software')
|
||
console.log('[ProtocolHandler] sw match1:', match1, 'match2:', match2)
|
||
return match1 || match2
|
||
}) as BleCharacteristic | null
|
||
} catch (e) {
|
||
console.warn('[ProtocolHandler] failed to get DeviceInfo chars', e)
|
||
chars = []
|
||
target = null
|
||
}
|
||
}
|
||
|
||
// debug: log which characteristic was selected and its uuid (may be null)
|
||
console.log('[ProtocolHandler] testVersionInfo selected characteristic:', target != null ? target.uuid : null, 'hw=', hw)
|
||
// If we found the Device Information service, log available characteristics to help
|
||
// diagnose Android path where IDs or properties may differ from Web shapes.
|
||
if (found2Uuid != null) {
|
||
try {
|
||
console.log('[ProtocolHandler] DeviceInfo service UUID=', found2Uuid, 'chars.length=', chars.length)
|
||
for (let i = 0; i < chars.length; i++) {
|
||
const c = chars[i]
|
||
try {
|
||
console.log('[ProtocolHandler] char:', i, 'uuid=', c.uuid, 'props=', c.properties)
|
||
} catch (e) { }
|
||
}
|
||
} catch (e) { }
|
||
}
|
||
|
||
// alias bluetoothService to local variable for inner functions
|
||
const bsvc = this.bluetoothService
|
||
// local non-null device id for inner functions (we already guard above)
|
||
const dev = deviceId as string
|
||
|
||
|
||
// helper: treat short / non-printable results as invalid version strings
|
||
function isPrintableVersion(arr : Uint8Array) : boolean {
|
||
if (arr == null || arr.length == 0) return false
|
||
// reject obvious single-byte numeric measurements (battery etc.)
|
||
if (arr.length == 1) return false
|
||
let printable = 0
|
||
for (let i = 0; i < arr.length; i++) {
|
||
const b = arr[i]
|
||
if (b >= 0x20 && b <= 0x7e) printable++
|
||
}
|
||
// accept if at least 50% printable characters
|
||
if ((printable / arr.length) >= 0.5) return true
|
||
// also accept common version-like ASCII (e.g. "1.0.3", "v2.1") even if ratio is low
|
||
try {
|
||
const s = new TextDecoder().decode(arr).trim()
|
||
if (s.length >= 2 && /^[vV]?\d[\d\.\- ]+$/.test(s)) return true
|
||
} catch (e) { /* ignore decode errors */ }
|
||
return false
|
||
}
|
||
|
||
|
||
async function tryReadChar(serviceUuid : string, charUuid : string) : Promise<string> {
|
||
await new Promise<void>((resolve) => { setTimeout(() => resolve(), 1000) })
|
||
try {
|
||
// avoid accidentally reading battery characteristic
|
||
const low = ('' + charUuid).toLowerCase()
|
||
if (low.indexOf('2a19') != -1) {
|
||
console.log('[ProtocolHandler] skipping battery char 2A19')
|
||
return ''
|
||
}
|
||
const buf = await bsvc!.readCharacteristic(dev, serviceUuid, charUuid)
|
||
// Prefer strong typing: treat buf as ArrayBuffer/Uint8Array primarily.
|
||
try { console.log('[ProtocolHandler] raw readCharacteristic buf typeof=', typeof buf) } catch (e) { }
|
||
let arr : Uint8Array = new Uint8Array(0)
|
||
try {
|
||
// Primary, strongly-typed paths
|
||
if (typeof ArrayBuffer !== 'undefined' && buf instanceof ArrayBuffer) {
|
||
arr = new Uint8Array(buf as ArrayBuffer)
|
||
} else if (Array.isArray(buf)) {
|
||
arr = new Uint8Array(buf as number[])
|
||
} else {
|
||
// leave arr empty for downstream diagnostics
|
||
console.log('ak:',arr)
|
||
arr = new Uint8Array(0)
|
||
}
|
||
} catch (e) { arr = new Uint8Array(0) }
|
||
try { console.log('[ProtocolHandler] normalized read buffer len=', arr.length, 'svc=', serviceUuid, 'char=', charUuid) } catch (e) { }
|
||
// If nothing was read, dump wrapper diagnostics to help identify platform shapes
|
||
if (arr.length == 0) {
|
||
console.log(arr)
|
||
}
|
||
|
||
try {
|
||
const hex = Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join(' ')
|
||
console.log('[ProtocolHandler] read buffer raw hex:', hex, 'len=', arr.length, 'svc=', serviceUuid, 'char=', charUuid)
|
||
} catch (e) { console.warn('[ProtocolHandler] failed to stringify buffer', e) }
|
||
if (isPrintableVersion(arr)) {
|
||
return new TextDecoder().decode(arr)
|
||
}
|
||
// Fallback: sometimes version strings are short (e.g. "1.0" or "v2.1")
|
||
// and may fail the printable-ratio heuristic. Try decoding and match
|
||
// a version-like regexp before giving up.
|
||
try {
|
||
const dec = new TextDecoder().decode(arr).trim()
|
||
if (dec.length >= 2 && /^[vV]?\d[\d\.\- ]+$/.test(dec)) {
|
||
console.log('[ProtocolHandler] tryReadChar regex-fallback decoded:', dec, 'svc=', serviceUuid, 'char=', charUuid)
|
||
return dec
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
return ''
|
||
} catch (e) {
|
||
console.warn('[ProtocolHandler] read failed for', serviceUuid, charUuid, e)
|
||
return ''
|
||
}
|
||
}
|
||
|
||
// First attempt: read the initially selected target characteristic (only if service UUID known)
|
||
let result = ''
|
||
if (target != null && found2Uuid != null) {
|
||
result = await tryReadChar(found2Uuid, target.uuid)
|
||
if (result != null && result.length > 0) return result
|
||
}
|
||
// If the direct target failed or none selected, attempt a conservative read of
|
||
// the first few characteristics in the Device Information service (Android
|
||
// devices sometimes put version strings in non-standard characteristics).
|
||
if ((target == null || (result == null || result.length == 0)) && found2Uuid != null && chars.length > 0) {
|
||
try {
|
||
const tryCount = Math.min(4, chars.length)
|
||
for (let i = 0; i < tryCount; i++) {
|
||
const c = chars[i]
|
||
if (c == null || c.uuid == null) continue
|
||
try {
|
||
console.log('[ProtocolHandler] device-info conservative read attempt for char', c.uuid)
|
||
const attempt = await tryReadChar(found2Uuid!, c.uuid)
|
||
if (attempt != null && attempt.length > 0) return attempt
|
||
} catch (e) { console.warn('[ProtocolHandler] conservative read failed for', c.uuid, e) }
|
||
}
|
||
} catch (e) { console.warn('[ProtocolHandler] conservative device-info read loop failed', e) }
|
||
}
|
||
|
||
// Second attempt: search other readable characteristics in the same Device Information service
|
||
try {
|
||
for (let i = 0; i < chars.length; i++) {
|
||
const c = chars[i]
|
||
if (c == null) continue
|
||
const id = ('' + c.uuid).toLowerCase()
|
||
if (target != null && id == ('' + target.uuid).toLowerCase()) continue
|
||
// prefer readable properties when available
|
||
if (c.properties != null && (c.properties.read == true || c.properties.canRead == true)) {
|
||
const attempt = await tryReadChar(found2Uuid!, c.uuid)
|
||
if (attempt != null && attempt.length > 0) return attempt
|
||
|
||
}
|
||
}
|
||
} catch (e) { console.warn('[ProtocolHandler] fallback scan in service failed', e) }
|
||
|
||
// Final fallback: scan all services for likely version characteristics (conservative limit)
|
||
try {
|
||
const svcList = await bsvc!.getServices(dev)
|
||
let attempts = 0
|
||
for (let si = 0; si < svcList.length; si++) {
|
||
if (attempts >= 6) break // limit to avoid long blocking
|
||
const s = svcList[si]
|
||
if (s == null) continue
|
||
const sUuid = ('' + s.uuid)
|
||
const charsAll = await bsvc!.getCharacteristics(dev, sUuid)
|
||
for (let ci = 0; ci < charsAll.length; ci++) {
|
||
const c = charsAll[ci]
|
||
if (c == null) continue
|
||
const id = ('' + c.uuid).toLowerCase()
|
||
if (id.indexOf('2a26') != -1 || id.indexOf('2a27') != -1 || id.indexOf('2a28') != -1 || ('' + c.uuid).toLowerCase().includes('firmware') || ('' + c.uuid).toLowerCase().includes('software') || ('' + c.uuid).toLowerCase().includes('hardware')) {
|
||
const attempt = await tryReadChar(sUuid, c.uuid)
|
||
if (attempt != null && attempt.length > 0) return attempt
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { console.warn('[ProtocolHandler] global fallback scan failed', e) }
|
||
|
||
// Final final fallback: aggressively (but with a strict cap) try any readable characteristic
|
||
try {
|
||
const svcList2 = await bsvc!.getServices(dev)
|
||
let attempts2 = 0
|
||
for (let si = 0; si < svcList2.length; si++) {
|
||
if (attempts2 >= 8) break
|
||
const s = svcList2[si]
|
||
if (s == null) continue
|
||
const sUuid = ('' + s.uuid)
|
||
const charsAll = await bsvc!.getCharacteristics(dev, sUuid)
|
||
for (let ci = 0; ci < charsAll.length; ci++) {
|
||
if (attempts2 >= 8) break
|
||
const c = charsAll[ci]
|
||
if (c == null) continue
|
||
const id = ('' + c.uuid).toLowerCase()
|
||
if (id.indexOf('2a19') != -1) continue // skip battery
|
||
// prefer readable properties if available, otherwise try cautiously
|
||
if (c.properties != null && (c.properties.read == true || c.properties.canRead == true)) {
|
||
attempts2++
|
||
const attempt = await tryReadChar(sUuid, c.uuid)
|
||
if (attempt != null && attempt.length > 0) return attempt
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { console.warn('[ProtocolHandler] aggressive fallback failed', e) }
|
||
|
||
// if all attempts failed, return empty string
|
||
// Vendor-specific fallback: use custom UART-like service to request device info
|
||
try {
|
||
// Known custom primary service and chars
|
||
const UART_SERVICE = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
|
||
const UART_TX = '6e400002-b5a3-f393-e0a9-e50e24dcca9e' // write without response
|
||
const UART_RX = '6e400003-b5a3-f393-e0a9-e50e24dcca9e' // notify/read
|
||
const UART_CTRL = '6e400004-b5a3-f393-e0a9-e50e24dcca9e' // control write (with response)
|
||
|
||
// find the UART service
|
||
const all = await bsvc!.getServices(deviceId)
|
||
let uartSvc : BleService | null = null
|
||
for (let i = 0; i < all.length; i++) {
|
||
const s = all[i]
|
||
if (s != null && (('' + s.uuid).toLowerCase().indexOf('6e400001') != -1 || ('' + s.uuid).toLowerCase() == UART_SERVICE)) { uartSvc = s; break }
|
||
}
|
||
const nonNullUartSvc = uartSvc
|
||
if (nonNullUartSvc != null) {
|
||
const sUuid = '' + nonNullUartSvc.uuid
|
||
// identify write and notify chars (prefer CTRL then TX for write)
|
||
let writeChar = UART_CTRL
|
||
let notifyChar = UART_RX
|
||
// verify existence
|
||
const chars = await bsvc!.getCharacteristics(deviceId, sUuid)
|
||
const charSet = new Set(chars.map(c => ('' + c.uuid).toLowerCase()))
|
||
if (!charSet.has(UART_CTRL) && !charSet.has(UART_TX)) {
|
||
// no usable write char
|
||
console.log('[ProtocolHandler] UART service present but no write char found')
|
||
} else {
|
||
if (!charSet.has(UART_CTRL)) writeChar = UART_TX
|
||
if (!charSet.has(UART_RX)) notifyChar = Array.from(charSet)[0] as string // pick first as fallback
|
||
const self = this
|
||
|
||
// subscribe to notify and wait for response
|
||
let resolved = false
|
||
const notifyPromise = new Promise<Uint8Array | null>((resolve : (value : Uint8Array | null) => void, reject : (reason ?: any) => void) => {
|
||
const timeout = setTimeout(() => {
|
||
if (!resolved) { resolved = true; resolve(null) }
|
||
}, 3000)
|
||
const callback = (data : Uint8Array) => {
|
||
if (resolved) return
|
||
resolved = true
|
||
clearTimeout(timeout)
|
||
resolve(data)
|
||
}
|
||
bsvc!.subscribeCharacteristic(deviceId, sUuid, notifyChar, callback).then(() => {
|
||
// subscription succeeded
|
||
}).catch((e) => {
|
||
clearTimeout(timeout)
|
||
if (!resolved) { resolved = true; resolve(null) }
|
||
})
|
||
})
|
||
|
||
// write the device info request
|
||
const pkt = self.buildVendorPacket(0x01, 0x01, [0x00]) // CMD_DEVICE_INFO
|
||
try {
|
||
await bsvc!.writeCharacteristic(deviceId, sUuid, writeChar, pkt, { waitForResponse: true })
|
||
} catch (e) { console.warn('[ProtocolHandler] UART write failed', e) }
|
||
|
||
const notifyData = await notifyPromise
|
||
try {
|
||
await bsvc!.unsubscribeCharacteristic(deviceId, sUuid, notifyChar)
|
||
} catch (e) { }
|
||
if (notifyData != null) {
|
||
// parse response: expect packet starting with 0xAA
|
||
if (notifyData.length >= 4 && notifyData[0] == 0xAA) {
|
||
// strip STX,LEN,CMD,SEQ and CRC
|
||
const len = notifyData[1]
|
||
const cmd = notifyData[2]
|
||
const seq = notifyData[3]
|
||
const dataBytes = notifyData.slice(4, notifyData.length - 1)
|
||
// try decode data to string
|
||
try {
|
||
// Use TextDecoder which handles Uint8Array directly and is
|
||
// safer than spreading into String.fromCharCode for large arrays.
|
||
const decoded = new TextDecoder().decode(dataBytes)
|
||
if (decoded != null && decoded.length > 0 && decoded.trim().length > 0) {
|
||
console.log('[ProtocolHandler] UART notify decoded:', decoded)
|
||
return decoded
|
||
}
|
||
} catch (e) { console.warn('[ProtocolHandler] failed to decode UART notify', e) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) { console.warn('[ProtocolHandler] UART fallback failed', e) }
|
||
return ''
|
||
|
||
}
|
||
} |