Files
akmon/uni_modules/ak-sbsrv/utssdk/protocol_handler.uts
2026-01-20 08:04:15 +08:00

977 lines
40 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 ''
}
}