// 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 { return await this.ensureControlChannel(force) } async ensureControlChannel(force : boolean = false) : Promise { 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 { 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 { 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((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 { // 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 { return; } async connect(device : BleDevice, options ?: BleConnectOptionsExt) : Promise { return; } async disconnect(device : BleDevice) : Promise { return; } async sendData(device : BleDevice, payload ?: SendDataPayload, options ?: BleOptions) : Promise { return; } async autoConnect(device : BleDevice, options ?: BleConnectOptionsExt) : Promise { 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 { 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 with 'timeSynced' or 'timeFailed' or ''. async synchronizeOnConnect() : Promise { 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 { // 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((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 { await new Promise((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((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 '' } }