Initial commit
This commit is contained in:
728
pages/akbletest.uvue
Normal file
728
pages/akbletest.uvue
Normal file
@@ -0,0 +1,728 @@
|
||||
<template>
|
||||
<scroll-view direction="vertical" class="container">
|
||||
<view class="section">
|
||||
<button @click="scanDevices" :disabled="scanning">{{ scanning ? '正在扫描...' : '扫描设备' }}</button>
|
||||
<!-- <view style="display:flex; flex-direction:row; margin-left:12px; align-items:center">
|
||||
<text style="margin-right:8px">预设:</text>
|
||||
<radio-group :modelValue="presetSelected" @change="onPresetChange">
|
||||
<view v-for="(opt, index) in presetOptions" :key="index"
|
||||
style="margin-right:8px; display:flex; align-items:center">
|
||||
<radio :value="opt['value'] as string" />
|
||||
<text style="margin-left:4px">{{ opt['label'] as string }}</text>
|
||||
</view>
|
||||
</radio-group>
|
||||
</view> -->
|
||||
<input v-model="optionalServicesInput" placeholder="可选服务 UUID, 逗号分隔" style="margin-left:12px; width: 40%" />
|
||||
<button @click="autoConnect" :disabled="connecting || devices.length == 0"
|
||||
style="margin-left:12px;">{{ connecting ? '正在自动连接...' : '自动连接' }}</button>
|
||||
<!-- Debug: show devices count and raw devices for troubleshooting -->
|
||||
<view>
|
||||
<text>设备计数: {{ devices.length }}</text>
|
||||
<text style="font-size:12px; color:gray">{{ _fmt(devices) }}</text>
|
||||
</view>
|
||||
<view v-if="devices.length">
|
||||
<text>已发现设备:</text>
|
||||
<view v-for="item in devices" :key="item.deviceId" class="device-item">
|
||||
<text>{{ item.name!='' ? item.name : '未知设备' }} ({{ item.deviceId }})</text>
|
||||
<button @click="connect(item.deviceId)">连接</button>
|
||||
|
||||
<button v-if="connectedIds.includes(item.deviceId)" @click="disconnect(item.deviceId)"
|
||||
:disabled="disconnecting">断开</button>
|
||||
<button v-if="connectedIds.includes(item.deviceId)"
|
||||
@click="showServices(item.deviceId)">查看服务</button>
|
||||
<button v-if="connectedIds.includes(item.deviceId)"
|
||||
@click="autoDiscoverInterfaces(item.deviceId)">自动发现接口</button>
|
||||
<button v-if="connectedIds.includes(item.deviceId)"
|
||||
@click="getDeviceInfo(item.deviceId)">设备信息</button>
|
||||
|
||||
<!-- DFU 按钮,仅在 APP-ANDROID 可见 -->
|
||||
<!-- #ifdef APP-ANDROID -->
|
||||
<button v-if="connectedIds.includes(item.deviceId)" @click="startDfuFlow(item.deviceId)">DFU
|
||||
升级</button>
|
||||
<button v-if="connectedIds.includes(item.deviceId)"
|
||||
@click="startDfuFlow(item.deviceId, '/static/OmFw2509140009.zip')">使用内置固件 DFU</button>
|
||||
<!-- #endif -->
|
||||
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="section">
|
||||
<text>日志:</text>
|
||||
<scroll-view direction="vertical" style="height:240px;">
|
||||
<text v-for="(log, idx) in logs" :key="idx" style="font-size:12px;">{{ log }}</text>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<view v-if="showingServicesFor">
|
||||
<view class="section">
|
||||
<text>设备 {{ showingServicesFor }} 的服务:</text>
|
||||
<view v-if="services.length">
|
||||
<view v-for="srv in services" :key="srv.uuid" class="service-item">
|
||||
<text>{{ srv.uuid }}</text>
|
||||
<button @click="showCharacteristics(showingServicesFor, srv.uuid)">查看特征</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else><text>无服务</text></view>
|
||||
<button @click="closeServices">关闭</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="showingCharacteristicsFor">
|
||||
<view class="section">
|
||||
<text>服务 的特征:</text>
|
||||
<view v-if="characteristics.length">
|
||||
<view v-for="char in characteristics" :key="char.uuid" class="char-item">
|
||||
<text>{{ char.uuid }} [{{ charProps(char) }}]</text>
|
||||
<view style="display:flex; flex-direction:row; margin-top:6px">
|
||||
<button v-if="char.properties?.read"
|
||||
@click="readCharacteristic(showingCharacteristicsFor.deviceId, showingCharacteristicsFor.serviceId, char.uuid)">读取</button>
|
||||
<button v-if="char.properties?.write"
|
||||
@click="writeCharacteristic(showingCharacteristicsFor.deviceId, showingCharacteristicsFor.serviceId, char.uuid)">写入(测试)</button>
|
||||
<button v-if="char.properties?.notify"
|
||||
@click="toggleNotify(showingCharacteristicsFor.deviceId, showingCharacteristicsFor.serviceId, char.uuid)">{{ isNotifying(char.uuid) ? '取消订阅' : '订阅' }}</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else><text>无特征</text></view>
|
||||
<button @click="closeCharacteristics">关闭</button>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import { BluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/interface.uts'
|
||||
// Platform-specific entrypoint: import the platform index per build target to avoid bundler including Android-only code in web builds
|
||||
// #ifdef APP-ANDROID
|
||||
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/app-android/index.uts'
|
||||
// #endif
|
||||
// #ifdef WEB
|
||||
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/web/index.uts'
|
||||
// #endif
|
||||
import type { BleDevice, BleService, BleCharacteristic } from '@/uni_modules/ak-sbsrv/utssdk/interface.uts'
|
||||
import { ProtocolHandler } from '@/uni_modules/ak-sbsrv/utssdk/protocol_handler.uts'
|
||||
|
||||
// #ifdef APP-ANDROID
|
||||
import { dfuManager } from '@/uni_modules/ak-sbsrv/utssdk/app-android/dfu_manager.uts'
|
||||
// #endif
|
||||
import { PermissionManager } from '@/ak/PermissionManager.uts'
|
||||
type ShowingCharacteristicsFor = {
|
||||
deviceId : string,
|
||||
serviceId : string
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
scanning: false,
|
||||
connecting: false,
|
||||
disconnecting: false,
|
||||
devices: [] as BleDevice[],
|
||||
connectedIds: [] as string[],
|
||||
logs: [] as string[],
|
||||
showingServicesFor: '',
|
||||
services: [] as BleService[],
|
||||
showingCharacteristicsFor: { deviceId: '', serviceId: '' } as ShowingCharacteristicsFor,
|
||||
characteristics: [] as BleCharacteristic[],
|
||||
// 新增协议相关参数
|
||||
protocolDeviceId: '',
|
||||
protocolServiceId: '',
|
||||
protocolWriteCharId: '',
|
||||
protocolNotifyCharId: '',
|
||||
// protocol handler instances/cache
|
||||
protocolHandlerMap: new Map<string, ProtocolHandler>(),
|
||||
protocolHandler: null as ProtocolHandler | null,
|
||||
// optional services input (comma-separated UUIDs)
|
||||
optionalServicesInput: '',
|
||||
// presets for common BLE services (label -> UUID). 'custom' allows free-form input.
|
||||
presetOptions: [
|
||||
{ label: '无', value: '' },
|
||||
{ label: 'Battery Service (180F)', value: '0000180f-0000-1000-8000-00805f9b34fb' },
|
||||
{ label: 'Device Information (180A)', value: '0000180a-0000-1000-8000-00805f9b34fb' },
|
||||
{ label: 'Generic Attribute (1801)', value: '00001801-0000-1000-8000-00805f9b34fb' },
|
||||
{ label: 'Nordic DFU', value: '00001530-1212-efde-1523-785feabcd123' },
|
||||
{ label: 'Nordic UART (NUS)', value: '6e400001-b5a3-f393-e0a9-e50e24dcca9e' },
|
||||
{ label: '自定义', value: 'custom' }
|
||||
],
|
||||
presetSelected: '',
|
||||
// map of characteristicId -> boolean (is currently subscribed)
|
||||
notifyingMap: new Map<string, boolean>(),
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
PermissionManager.requestBluetoothPermissions((granted : boolean) => {
|
||||
if (!granted) {
|
||||
uni.showToast({ title: '请授权蓝牙和定位权限', icon: 'none' });
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
this.log('页面 mounted: 初始化事件监听和蓝牙权限请求完成')
|
||||
// deviceFound - only accept devices whose name starts with 'CF' or 'BCL'
|
||||
bluetoothService.on('deviceFound', (payload) => {
|
||||
try {
|
||||
// this.log('[event] deviceFound -> ' + this._fmt(payload))
|
||||
// console.log('[event] deviceFound -> ' + this._fmt(payload))
|
||||
// payload can be UTSJSONObject-like or plain object. Normalize.
|
||||
let rawDevice = payload?.device;
|
||||
if (rawDevice == null) {
|
||||
this.log('[event] deviceFound - payload.device is null, ignoring');
|
||||
return;
|
||||
}
|
||||
|
||||
// extract name
|
||||
let name : string | null = rawDevice.name;
|
||||
|
||||
if (name == null) {
|
||||
this.log('[event] deviceFound - 无名称,忽略: ' + this._fmt(rawDevice as any))
|
||||
return;
|
||||
}
|
||||
|
||||
const n = name as string;
|
||||
if (!(n.startsWith('CF') || n.startsWith('BCL'))) {
|
||||
this.log('[event] deviceFound - 名称不匹配前缀,忽略: ' + n)
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = this.devices.some(d => d != null && d.name == n);
|
||||
|
||||
if (!exists) {
|
||||
// rawDevice is non-null here per earlier guard
|
||||
this.devices.push(rawDevice as BleDevice);
|
||||
const deviceIdStr = (rawDevice.deviceId != null) ? rawDevice.deviceId : '';
|
||||
this.log('发现设备: ' + n + ' (' + deviceIdStr + ')');
|
||||
} else {
|
||||
const deviceIdStr = (rawDevice.deviceId != null) ? rawDevice.deviceId : '';
|
||||
this.log('发现重复设备: ' + n + ' (' + deviceIdStr + ')')
|
||||
}
|
||||
} catch (err) {
|
||||
this.log('[error] deviceFound handler error: ' + getErrorMessage(err))
|
||||
console.log(err)
|
||||
}
|
||||
})
|
||||
|
||||
// scanFinished
|
||||
bluetoothService.on('scanFinished', (payload) => {
|
||||
try {
|
||||
this.scanning = false
|
||||
this.log('[event] scanFinished -> ' + this._fmt(payload))
|
||||
} catch (err) {
|
||||
this.log('[error] scanFinished handler error: ' + getErrorMessage(err))
|
||||
}
|
||||
})
|
||||
|
||||
// connectionStateChanged
|
||||
bluetoothService.on('connectionStateChanged', (payload) => {
|
||||
try {
|
||||
this.log('[event] connectionStateChanged -> ' + this._fmt(payload))
|
||||
if (payload != null) {
|
||||
const device = payload.device
|
||||
const state = payload.state
|
||||
this.log(`设备 ${device?.deviceId} 连接状态变为: ${state}`)
|
||||
// maintain connectedIds
|
||||
if (state == 2) {
|
||||
if (device != null && device.deviceId != null && !this.connectedIds.includes(device.deviceId)) {
|
||||
this.connectedIds.push(device.deviceId)
|
||||
this.log(`已记录已连接设备: ${device.deviceId}`)
|
||||
}
|
||||
} else if (state == 0) {
|
||||
if (device != null && device.deviceId != null) {
|
||||
this.connectedIds = this.connectedIds.filter(id => id !== device.deviceId)
|
||||
this.log(`已移除已断开设备: ${device.deviceId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.log('[error] connectionStateChanged handler error: ' + getErrorMessage(err))
|
||||
}
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
async startDfuFlow(deviceId : string, staticFilePath : string = '') {
|
||||
if (staticFilePath != null && staticFilePath !== '') {
|
||||
this.log('DFU 开始: 使用内置固件文件 ' + staticFilePath)
|
||||
} else {
|
||||
this.log('DFU 开始: 请选择固件文件')
|
||||
}
|
||||
try {
|
||||
let chosenPath : string | null = null
|
||||
let fileName : string | null = null
|
||||
if (staticFilePath != null && staticFilePath !== '') {
|
||||
// Use the app's bundled static file path
|
||||
chosenPath = staticFilePath.replace(/^\/+/, '')
|
||||
const tmpName = staticFilePath.split(/[\/]/).pop()
|
||||
fileName = (tmpName != null && tmpName !== '') ? tmpName : staticFilePath
|
||||
} else {
|
||||
const res = await new Promise<any>((resolve, reject) => {
|
||||
uni.chooseFile({ count: 1, success: (r) => resolve(r), fail: (e) => reject(e) })
|
||||
})
|
||||
console.log(res)
|
||||
// Generator-friendly: avoid property iteration or bracket indexing.
|
||||
// Serialize and regex-match common file fields (path/uri/tempFilePath/name).
|
||||
try {
|
||||
const s = (() => { try { return JSON.stringify(res); } catch (e) { return ''; } })()
|
||||
const m = s.match(/"(?:path|uri|tempFilePath|temp_file_path|tempFilePath|name)"\s*:\s*"([^"]+)"/i)
|
||||
if (m != null && m.length >= 2) {
|
||||
const capturedCandidate : string | null = (m[1] != null ? m[1] : null)
|
||||
const captured : string = capturedCandidate != null ? capturedCandidate : ''
|
||||
if (captured !== '') {
|
||||
chosenPath = captured
|
||||
const toTest : string = captured
|
||||
if (!(/^[a-zA-Z]:\\|^\\\//.test(toTest) || /:\/\//.test(toTest))) {
|
||||
const m2 = s.match(/"(?:path|uri|tempFilePath|temp_file_path|tempFilePath)"\s*:\s*"([^"]+)"/i)
|
||||
if (m2 != null && m2.length >= 2 && m2[1] != null) {
|
||||
const pathCandidate : string = m2[1] != null ? ('' + m2[1]) : ''
|
||||
if (pathCandidate !== '') chosenPath = pathCandidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const nameMatch = s.match(/"name"\s*:\s*"([^"]+)"/i)
|
||||
if (nameMatch != null && nameMatch.length >= 2 && nameMatch[1] != null) {
|
||||
const nm : string = nameMatch[1] != null ? ('' + nameMatch[1]) : ''
|
||||
if (nm !== '') fileName = nm
|
||||
}
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
if (chosenPath == null || chosenPath == '') {
|
||||
this.log('未选择文件')
|
||||
return
|
||||
}
|
||||
// filePath is non-null and non-empty here
|
||||
const fpStr : string = chosenPath as string
|
||||
const lastSeg = fpStr.split(/[\/]/).pop();
|
||||
const displayName = (fileName != null && fileName !== '') ? fileName : (lastSeg != null && lastSeg !== '' ? lastSeg : fpStr)
|
||||
this.log('已选文件: ' + displayName + ' 路径: ' + fpStr)
|
||||
const bytes = await this._readFileAsUint8Array(fpStr)
|
||||
this.log('固件读取完成, 大小: ' + bytes.length)
|
||||
try {
|
||||
await dfuManager.startDfu(deviceId, bytes, {
|
||||
useNordic: false,
|
||||
onProgress: (p : number) => this.log('DFU 进度: ' + p + '%'),
|
||||
onLog: (s : string) => this.log('DFU: ' + s),
|
||||
controlTimeout: 30000
|
||||
})
|
||||
this.log('DFU 完成')
|
||||
} catch (e) {
|
||||
this.log('DFU 失败: ' + getErrorMessage(e))
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('选择或读取固件失败: ' + e)
|
||||
}
|
||||
},
|
||||
|
||||
_readFileAsUint8Array(path : string) : Promise<Uint8Array> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
console.log('should readfile')
|
||||
const fsm = uni.getFileSystemManager()
|
||||
console.log(fsm)
|
||||
// Read file as ArrayBuffer directly to avoid base64 encoding issues
|
||||
fsm.readFile({
|
||||
filePath: path, success: (res) => {
|
||||
try {
|
||||
const data = res.data as ArrayBuffer
|
||||
const arr = new Uint8Array(data)
|
||||
resolve(arr)
|
||||
} catch (e) { reject(e) }
|
||||
}, fail: (err) => { reject(err) }
|
||||
})
|
||||
} catch (e) { reject(e) }
|
||||
})
|
||||
},
|
||||
|
||||
log(msg : string) {
|
||||
const ts = new Date().toISOString();
|
||||
this.logs.unshift(`[${ts}] ${msg}`)
|
||||
if (this.logs.length > 100) this.logs.length = 100
|
||||
},
|
||||
_fmt(obj : any) : string {
|
||||
try {
|
||||
if (obj == null) return 'null'
|
||||
if (typeof obj == 'string') return obj
|
||||
return JSON.stringify(obj)
|
||||
} catch (e) {
|
||||
return '' + obj
|
||||
}
|
||||
},
|
||||
onPresetChange(e : any) {
|
||||
try {
|
||||
// Some platforms emit { detail: { value: 'x' } }, others emit { value: 'x' } or just 'x'.
|
||||
// Serialize and regex-extract to avoid direct property access that the UTS->Kotlin generator may emit incorrectly.
|
||||
const s = (() => { try { return JSON.stringify(e); } catch (err) { return ''; } })()
|
||||
let val : string = this.presetSelected
|
||||
// try detail.value first
|
||||
const m = s.match(/"detail"\s*:\s*\{[^}]*"value"\s*:\s*"([^\"]+)"/i)
|
||||
if (m != null && m.length >= 2 && m[1] != null) {
|
||||
val = '' + m[1]
|
||||
} else {
|
||||
const m2 = s.match(/"value"\s*:\s*"([^\"]+)"/i)
|
||||
if (m2 != null && m2.length >= 2 && m2[1] != null) {
|
||||
val = '' + m2[1]
|
||||
}
|
||||
}
|
||||
this.presetSelected = val
|
||||
if (val == 'custom' || val == '') {
|
||||
this.log('已选择预设: ' + (val == 'custom' ? '自定义' : '无'))
|
||||
return;
|
||||
}
|
||||
this.optionalServicesInput = val;
|
||||
this.log('已选择预设服务 UUID: ' + val)
|
||||
} catch (err) {
|
||||
this.log('[error] onPresetChange: ' + getErrorMessage(err))
|
||||
}
|
||||
},
|
||||
scanDevices() {
|
||||
try {
|
||||
this.scanning = true
|
||||
this.devices = []
|
||||
// prepare optional services: prefer free-form input, otherwise use selected preset (unless preset is 'custom' or empty)
|
||||
let raw = (this.optionalServicesInput != null ? this.optionalServicesInput : '').trim();
|
||||
if (raw.length == 0 && this.presetSelected != null && this.presetSelected !== '' && this.presetSelected !== 'custom') {
|
||||
raw = this.presetSelected;
|
||||
}
|
||||
// normalize helper: expand 16-bit UUIDs like '180F' to full 128-bit UUIDs
|
||||
const normalize = (s : string) => {
|
||||
if (s == null || s.length == 0) return '';
|
||||
const u = s.toLowerCase().replace(/^0x/, '').trim();
|
||||
const hex = u.replace(/[^0-9a-f]/g, '');
|
||||
if (/^[0-9a-f]{4}$/.test(hex)) return `0000${hex}-0000-1000-8000-00805f9b34fb`;
|
||||
return s;
|
||||
};
|
||||
const optionalServices = raw.length > 0 ? raw.split(',').map(s => normalize(s.trim())).filter(s => s.length > 0) : []
|
||||
this.log('开始扫描... optionalServices=' + JSON.stringify(optionalServices))
|
||||
bluetoothService.scanDevices({ "protocols": ['BLE'], "optionalServices": optionalServices })
|
||||
.then(() => {
|
||||
this.log('scanDevices resolved')
|
||||
})
|
||||
.catch((e) => {
|
||||
this.log('[error] scanDevices failed: ' + getErrorMessage(e))
|
||||
this.scanning = false
|
||||
})
|
||||
} catch (err) {
|
||||
this.log('[error] scanDevices thrown: ' + getErrorMessage(err))
|
||||
this.scanning = false
|
||||
}
|
||||
},
|
||||
connect(deviceId : string) {
|
||||
this.connecting = true
|
||||
this.log(`connect start -> ${deviceId}`)
|
||||
try {
|
||||
bluetoothService.connectDevice(deviceId, 'BLE', { timeout: 10000 }).then(() => {
|
||||
if (!this.connectedIds.includes(deviceId)) this.connectedIds.push(deviceId)
|
||||
this.log('连接成功: ' + deviceId)
|
||||
}).catch((e) => {
|
||||
this.log('连接失败: ' + getErrorMessage(e!));
|
||||
}).finally(() => {
|
||||
this.connecting = false
|
||||
this.log(`connect finished -> ${deviceId}`)
|
||||
})
|
||||
} catch (err) {
|
||||
this.log('[error] connect thrown: ' + getErrorMessage(err))
|
||||
this.connecting = false
|
||||
}
|
||||
},
|
||||
disconnect(deviceId : string) {
|
||||
if (!this.connectedIds.includes(deviceId)) return
|
||||
this.disconnecting = true
|
||||
this.log(`disconnect start -> ${deviceId}`)
|
||||
bluetoothService.disconnectDevice(deviceId, 'BLE').then(() => {
|
||||
this.log('已断开: ' + deviceId)
|
||||
this.connectedIds = this.connectedIds.filter(id => id !== deviceId)
|
||||
// 清理协议处理器缓存
|
||||
this.protocolHandlerMap.delete(deviceId)
|
||||
}).catch((e) => {
|
||||
this.log('断开失败: ' + getErrorMessage(e!));
|
||||
}).finally(() => {
|
||||
this.disconnecting = false
|
||||
this.log(`disconnect finished -> ${deviceId}`)
|
||||
})
|
||||
},
|
||||
showServices(deviceId : string) {
|
||||
this.showingServicesFor = deviceId
|
||||
this.services = []
|
||||
this.log(`showServices start -> ${deviceId}`)
|
||||
bluetoothService.getServices(deviceId).then((list) => {
|
||||
this.log('showServices result -> ' + this._fmt(list))
|
||||
this.services = list as BleService[]
|
||||
this.log('服务数: ' + (list != null ? list.length : 0) + ' [' + deviceId + ']');
|
||||
}).catch((e) => {
|
||||
this.log('获取服务失败: ' + getErrorMessage(e!));
|
||||
}).finally(() => {
|
||||
this.log(`showServices finished -> ${deviceId}`)
|
||||
});
|
||||
},
|
||||
closeServices() {
|
||||
this.showingServicesFor = ''
|
||||
this.services = []
|
||||
},
|
||||
showCharacteristics(deviceId : string, serviceId : string) {
|
||||
this.showingCharacteristicsFor = { deviceId, serviceId }
|
||||
this.characteristics = []
|
||||
bluetoothService.getCharacteristics(deviceId, serviceId).then((list) => {
|
||||
this.characteristics = list as BleCharacteristic[]
|
||||
console.log('特征数: ' + (list != null ? list.length : 0) + ' [' + deviceId + ']');
|
||||
// 自动查找可用的写入和通知特征
|
||||
const writeChar = this.characteristics.find(c => c.properties.write)
|
||||
const notifyChar = this.characteristics.find(c => c.properties.notify)
|
||||
if (writeChar != null && notifyChar != null) {
|
||||
this.protocolDeviceId = deviceId
|
||||
this.protocolServiceId = serviceId
|
||||
this.protocolWriteCharId = writeChar.uuid
|
||||
this.protocolNotifyCharId = notifyChar.uuid
|
||||
let abs = bluetoothService as BluetoothService
|
||||
this.protocolHandler = new ProtocolHandler(abs)
|
||||
let handler = this.protocolHandler
|
||||
handler?.setConnectionParameters(deviceId, serviceId, writeChar.uuid, notifyChar.uuid)
|
||||
handler?.initialize()?.then(() => {
|
||||
console.log("协议处理器已初始化,可进行协议测试")
|
||||
})?.catch(e => {
|
||||
console.log("协议处理器初始化失败: " + getErrorMessage(e!))
|
||||
})
|
||||
}
|
||||
}).catch((e) => {
|
||||
console.log('获取特征失败: ' + getErrorMessage(e!));
|
||||
});
|
||||
// tracking notifying state
|
||||
// this.$set(this, 'notifyingMap', this.notifyingMap || {});
|
||||
},
|
||||
closeCharacteristics() {
|
||||
this.showingCharacteristicsFor = { deviceId: '', serviceId: '' }
|
||||
this.characteristics = []
|
||||
},
|
||||
charProps(char : BleCharacteristic) : string {
|
||||
const p = char.properties
|
||||
const parts = [] as string[]
|
||||
if (p.read) parts.push('R')
|
||||
if (p.write) parts.push('W')
|
||||
if (p.notify) parts.push('N')
|
||||
if (p.indicate) parts.push('I')
|
||||
return parts.join('/')
|
||||
// return [p.read ? 'R' : '', p.write ? 'W' : '', p.notify ? 'N' : '', p.indicate ? 'I' : ''].filter(Boolean).join('/')
|
||||
},
|
||||
isNotifying(uuid : string) {
|
||||
return this.notifyingMap.has(uuid) && this.notifyingMap.get(uuid) == true
|
||||
},
|
||||
async readCharacteristic(deviceId : string, serviceId : string, charId : string) {
|
||||
try {
|
||||
this.log(`readCharacteristic ${charId} ...`)
|
||||
const buf = await bluetoothService.readCharacteristic(deviceId, serviceId, charId)
|
||||
let text = ''
|
||||
try { text = new TextDecoder().decode(new Uint8Array(buf)) } catch (e) { text = '' }
|
||||
const hex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(' ')
|
||||
console.log(`读取 ${charId}: text='${text}' hex='${hex}'`)
|
||||
this.log(`读取 ${charId}: text='${text}' hex='${hex}'`)
|
||||
|
||||
} catch (e) {
|
||||
this.log('读取特征失败: ' + getErrorMessage(e))
|
||||
}
|
||||
},
|
||||
async writeCharacteristic(deviceId : string, serviceId : string, charId : string) {
|
||||
try {
|
||||
const payload = new Uint8Array([0x01])
|
||||
const ok = await bluetoothService.writeCharacteristic(deviceId, serviceId, charId, payload, null)
|
||||
if (ok) this.log(`写入 ${charId} 成功`);
|
||||
else this.log(`写入 ${charId} 失败`);
|
||||
} catch (e) {
|
||||
this.log('写入特征失败: ' + getErrorMessage(e))
|
||||
}
|
||||
},
|
||||
async toggleNotify(deviceId : string, serviceId : string, charId : string) {
|
||||
try {
|
||||
const map = this.notifyingMap
|
||||
const cur = map.get(charId) == true
|
||||
if (cur) {
|
||||
// unsubscribe
|
||||
await bluetoothService.unsubscribeCharacteristic(deviceId, serviceId, charId)
|
||||
map.set(charId, false)
|
||||
this.log(`取消订阅 ${charId}`)
|
||||
} else {
|
||||
// subscribe with callback
|
||||
await bluetoothService.subscribeCharacteristic(deviceId, serviceId, charId, (payload : any) => {
|
||||
let data : ArrayBuffer | null = null
|
||||
try {
|
||||
if (payload instanceof ArrayBuffer) {
|
||||
data = payload
|
||||
} else if (payload != null && typeof payload == 'string') {
|
||||
// some runtimes deliver base64 strings
|
||||
try {
|
||||
const s = atob(payload)
|
||||
const tmp = new Uint8Array(s.length)
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s.charCodeAt(i)
|
||||
tmp[i] = (ch == null) ? 0 : (ch & 0xff)
|
||||
}
|
||||
data = tmp.buffer
|
||||
} catch (e) { data = null }
|
||||
} else if (payload != null && (payload as UTSJSONObject).get('data') instanceof ArrayBuffer) {
|
||||
data = (payload as UTSJSONObject).get('data') as ArrayBuffer
|
||||
}
|
||||
const arr = data != null ? new Uint8Array(data) : new Uint8Array([])
|
||||
const hex = Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join(' ')
|
||||
this.log(`notify ${charId}: ${hex}`)
|
||||
} catch (e) { this.log('notify callback error: ' + getErrorMessage(e)) }
|
||||
})
|
||||
map.set(charId, true)
|
||||
this.log(`订阅 ${charId}`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.log('订阅/取消订阅失败: ' + getErrorMessage(e))
|
||||
}
|
||||
},
|
||||
autoConnect() {
|
||||
if (this.connecting) return;
|
||||
this.connecting = true;
|
||||
const toConnect = this.devices.filter(d => !this.connectedIds.includes(d.deviceId));
|
||||
if (toConnect.length == 0) {
|
||||
this.log('没有可自动连接的设备');
|
||||
this.connecting = false;
|
||||
return;
|
||||
}
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
let finished = 0;
|
||||
toConnect.forEach(device => {
|
||||
bluetoothService.connectDevice(device.deviceId, 'BLE', { timeout: 10000 }).then(() => {
|
||||
if (!this.connectedIds.includes(device.deviceId)) this.connectedIds.push(device.deviceId);
|
||||
this.log('自动连接成功: ' + device.deviceId);
|
||||
successCount++;
|
||||
// this.getOrInitProtocolHandler(device.deviceId);
|
||||
}).catch((e) => {
|
||||
this.log('自动连接失败: ' + device.deviceId + ' ' + getErrorMessage(e!));
|
||||
failCount++;
|
||||
}).finally(() => {
|
||||
finished++;
|
||||
if (finished == toConnect.length) {
|
||||
this.connecting = false;
|
||||
this.log(`自动连接完成,成功${successCount},失败${failCount}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
autoDiscoverInterfaces(deviceId : string) {
|
||||
this.log('自动发现接口中...')
|
||||
bluetoothService.getAutoBleInterfaces(deviceId)
|
||||
.then((res) => {
|
||||
console.log(res)
|
||||
this.log('自动发现接口成功: ' + JSON.stringify(res))
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
this.log('自动发现接口失败: ' + getErrorMessage(e!))
|
||||
})
|
||||
},
|
||||
// 新增:测试电量功能
|
||||
async getOrInitProtocolHandler(deviceId : string) : Promise<ProtocolHandler> {
|
||||
let handler = this.protocolHandlerMap.get(deviceId);
|
||||
if (handler == null) {
|
||||
// 自动发现接口
|
||||
const res = await bluetoothService.getAutoBleInterfaces(deviceId);
|
||||
handler = new ProtocolHandler(bluetoothService as BluetoothService);
|
||||
handler.setConnectionParameters(deviceId, res.serviceId, res.writeCharId, res.notifyCharId);
|
||||
await handler.initialize();
|
||||
this.protocolHandlerMap.set(deviceId, handler);
|
||||
this.log(`协议处理器已初始化: ${deviceId}`);
|
||||
}
|
||||
return handler!;
|
||||
},
|
||||
|
||||
async getDeviceInfo(deviceId : string) {
|
||||
this.log('获取设备信息中...');
|
||||
try {
|
||||
// First try protocol handler (if device exposes custom protocol)
|
||||
try {
|
||||
const handler = await this.getOrInitProtocolHandler(deviceId);
|
||||
// 获取电量
|
||||
const battery = await handler.testBatteryLevel();
|
||||
this.log('协议: 电量: ' + battery);
|
||||
// 获取软件/硬件版本
|
||||
const swVersion = await handler.testVersionInfo(false);
|
||||
this.log('协议: 软件版本: ' + swVersion);
|
||||
const hwVersion = await handler.testVersionInfo(true);
|
||||
this.log('协议: 硬件版本: ' + hwVersion);
|
||||
} catch (protoErr) {
|
||||
this.log('协议处理器不可用或初始化失败,继续使用通用 GATT 查询: ' + ((protoErr != null && protoErr instanceof Error) ? protoErr.message : this._fmt(protoErr)));
|
||||
}
|
||||
|
||||
// Additionally, attempt to read standard services: Generic Access (0x1800), Generic Attribute (0x1801), Battery (0x180F)
|
||||
const stdServices = ['1800', '1801', '180f'].map(s => {
|
||||
const hex = s.toLowerCase().replace(/^0x/, '');
|
||||
return /^[0-9a-f]{4}$/.test(hex) ? `0000${hex}-0000-1000-8000-00805f9b34fb` : s;
|
||||
});
|
||||
// fetch services once to avoid repeated GATT server queries
|
||||
const services = await bluetoothService.getServices(deviceId);
|
||||
for (const svc of stdServices) {
|
||||
try {
|
||||
this.log('读取服务: ' + svc);
|
||||
// find matching service
|
||||
const found = services.find((x : any) => {
|
||||
const uuid = (x as UTSJSONObject).get('uuid')
|
||||
return uuid != null && uuid.toString().toLowerCase() == svc.toLowerCase()
|
||||
});
|
||||
if (found == null) {
|
||||
this.log('未发现服务 ' + svc + '(需重新扫描并包含 optionalServices)');
|
||||
continue;
|
||||
}
|
||||
const chars = await bluetoothService.getCharacteristics(deviceId, found?.uuid as string);
|
||||
console.log(`服务 ${svc} 包含 ${chars.length} 个特征`, chars);
|
||||
for (const c of chars) {
|
||||
try {
|
||||
if (c.properties?.read == true) {
|
||||
const buf = await bluetoothService.readCharacteristic(deviceId, found?.uuid as string, c.uuid);
|
||||
// try to decode as utf8 then hex
|
||||
let text = '';
|
||||
try { text = new TextDecoder().decode(new Uint8Array(buf)); } catch (e) { text = ''; }
|
||||
const hex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||
console.log(`特征 ${c.uuid} 读取: text='${text}' hex='${hex}'`);
|
||||
} else {
|
||||
console.log(`特征 ${c.uuid} 不可读`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`读取特征 ${c.uuid} 失败: ${getErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('查询服务 ' + svc + ' 失败: ' + getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.log('获取设备信息失败: ' + getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function getErrorMessage(e : Error | string | null) : string {
|
||||
if (e == null) return '';
|
||||
if (typeof e == 'string') return e;
|
||||
try {
|
||||
return JSON.stringify(e);
|
||||
} catch (err) {
|
||||
return '' + e;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.device-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.service-item,
|
||||
.char-item {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
218
pages/alldevices.uvue
Normal file
218
pages/alldevices.uvue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<scroll-view
|
||||
direction="vertical"
|
||||
class="manager-container"
|
||||
style="height: 100vh; flex: 1; min-height: 0;"
|
||||
>
|
||||
<view class="toolbar">
|
||||
<button @click="scanDevices" :disabled="scanning">{{ scanning ? '正在扫描...' : '扫描设备' }}</button>
|
||||
<button @click="measureAllBatteries">全部测电量</button>
|
||||
</view>
|
||||
<view class="card-list">
|
||||
<RingCard
|
||||
v-for="(dev, idx) in devices"
|
||||
:key="dev.deviceId"
|
||||
:name="dev.name"
|
||||
:deviceId="dev.deviceId"
|
||||
:isFullscreen="fullscreenDeviceId === dev.deviceId"
|
||||
:id="'ring_' + idx"
|
||||
@request-fullscreen="handleRequestFullscreen"
|
||||
@exit-fullscreen="handleExitFullscreen"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="log-section">
|
||||
<text>日志:</text>
|
||||
<scroll-view scroll-y style="height:120px;">
|
||||
<text v-for="(log, idx) in logs" :key="idx" style="font-size:12px;">{{ log }}</text>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
|
||||
import RingCard from '@/components/ringcard.uvue'
|
||||
// #ifdef APP-ANDROID
|
||||
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/app-android/index.uts'
|
||||
// #endif
|
||||
|
||||
// #ifdef WEB
|
||||
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/web/index.uts'
|
||||
// #endif
|
||||
import { PermissionManager } from '@/ak/PermissionManager.uts'
|
||||
import type { BleDevice } from '@/uni_modules/ak-sbsrv/utssdk/interface.uts'
|
||||
//import {sqliteContext} from '@/ak/sqlite.uts'
|
||||
|
||||
export default {
|
||||
components: { RingCard },
|
||||
data() {
|
||||
return {
|
||||
scanning: false,
|
||||
devices: [] as BleDevice[],
|
||||
logs: [] as string[],
|
||||
fullscreenDeviceId: '', // 当前全屏的设备id
|
||||
fullscreenElement: null as UniElement | null,
|
||||
isFullscreen: false,
|
||||
orientation: "landscape",
|
||||
navigationUI:"hide",
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
PermissionManager.requestBluetoothPermissions(() => {});
|
||||
bluetoothService.on('deviceFound', (payload) => {
|
||||
try {
|
||||
console.log('ak deviceFound')
|
||||
const device = payload?.device;
|
||||
if (device == null || device.deviceId == null) return;
|
||||
const name = (device.name != null) ? device.name : '';
|
||||
if (name.indexOf('CF') !== 0) return;
|
||||
if (this.devices.find(d => d.deviceId === device.deviceId) == null) {
|
||||
this.devices.push(device);
|
||||
this.log('发现设备: ' + (name !== '' ? name : '未知设备') + ' (' + device.deviceId + ')');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('deviceFound handler error', err);
|
||||
}
|
||||
});
|
||||
bluetoothService.on('scanFinished', () => {
|
||||
this.scanning = false;
|
||||
this.log('扫描完成');
|
||||
});
|
||||
bluetoothService.on('connectionStateChanged', (payload) => {
|
||||
console.log('[AKBLE][LOG] 页面收到 connectionStateChanged', payload)
|
||||
const { device, state } = payload;
|
||||
this.log(`设备 ${device?.deviceId} 连接状态变为: ${state}`);
|
||||
// 通知对应的 RingCard 组件
|
||||
if (device?.deviceId != null && device.deviceId !== '') {
|
||||
const idx = this.devices.findIndex(d => d.deviceId === device!.deviceId);
|
||||
if (idx >= 0) {
|
||||
const refName = 'ring_' + idx;
|
||||
const ringCards = this.$refs[refName] as ComponentPublicInstance[] | ComponentPublicInstance;
|
||||
const arr = Array.isArray(ringCards) ? ringCards : [ringCards];
|
||||
if (arr.length > 0) {
|
||||
const ringCard = arr[0];
|
||||
ringCard.$callMethod('onConnectionStateChanged', state);
|
||||
}
|
||||
}
|
||||
// sqliteContext.executeSql({
|
||||
// sql: `
|
||||
// INSERT INTO ble_event_log (device_id, event_type, timestamp)
|
||||
// VALUES ('${device.deviceId}', '${state}', ${Date.now()})
|
||||
// `,
|
||||
// success: (res) => {
|
||||
// console.log('保存连接日志成功', res);
|
||||
// },
|
||||
// fail: (err) => {
|
||||
// console.error('保存连接日志失败', err);
|
||||
// }
|
||||
// });
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
log(msg: string) {
|
||||
this.logs.unshift(`[${new Date().toTimeString().slice(0,8)}] ${msg}`);
|
||||
if (this.logs.length > 100) this.logs.length = 100;
|
||||
},
|
||||
getCurrentPage() : UniPage {
|
||||
const pages = getCurrentPages()
|
||||
return pages[pages.length - 1]
|
||||
},
|
||||
scanDevices() {
|
||||
this.scanning = true;
|
||||
this.devices = [];
|
||||
bluetoothService.scanDevices({ protocols: ['BLE'] });
|
||||
this.log('开始扫描...');
|
||||
},
|
||||
async measureAllBatteries() {
|
||||
for (let i = 0; i < this.devices.length; i++) {
|
||||
const refName = 'ring_' + i;
|
||||
const ringCards = this.$refs[refName] as ComponentPublicInstance[] | ComponentPublicInstance;
|
||||
const arr = Array.isArray(ringCards) ? ringCards : [ringCards];
|
||||
if (arr.length > 0) {
|
||||
const ringCard = arr[0];
|
||||
try {
|
||||
const battery = await ringCard.$callMethod('measureBattery');
|
||||
this.log(`设备 ${this.devices[i].deviceId} 电量: ${battery}`);
|
||||
} catch (err) {
|
||||
this.log('测量电量失败: ' + (err && (err as any).message ? (err as any).message : String(err)));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onMeasure(deviceId: string) {
|
||||
// 记录日志或其它操作
|
||||
},
|
||||
async handleRequestFullscreen(deviceId: string) {
|
||||
this.fullscreenDeviceId = deviceId;
|
||||
// 让对应的卡片进入全屏
|
||||
const idx = this.devices.findIndex(d => d.deviceId === deviceId);
|
||||
if (idx >= 0) {
|
||||
const refName = 'ring_' + idx;
|
||||
// const ringCards = this.$refs[refName] as UTSArray<ComponentPublicInstance>;
|
||||
// if (ringCards.length > 0) {
|
||||
// // @ts-ignore
|
||||
// ringCards[0].$ref.fullscreenCard?.requestFullscreen?.({
|
||||
// navigationUI: "hide",
|
||||
// orientation: "auto"
|
||||
// });
|
||||
// }
|
||||
console.log(refName)
|
||||
this.fullscreenElement = uni.getElementById(refName) as UniElement;
|
||||
|
||||
this.fullscreenElement?.requestFullscreen({
|
||||
navigationUI: this.navigationUI,
|
||||
orientation: this.orientation,
|
||||
success: () => {
|
||||
this.fullscreenDeviceId = deviceId;
|
||||
console.log( "全屏")
|
||||
},
|
||||
fail: (err) => {
|
||||
console.log('fail', err)
|
||||
},
|
||||
complete: () => {
|
||||
console.log('complete')
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
async handleExitFullscreen(deviceId: string) {
|
||||
this.fullscreenDeviceId = '';
|
||||
// 退出全屏
|
||||
const page = this.getCurrentPage();
|
||||
page.exitFullscreen(
|
||||
{
|
||||
success: () => {
|
||||
console.log( "退出全屏")
|
||||
},
|
||||
fail: (err) => {
|
||||
console.log('fail', err)
|
||||
},
|
||||
complete: () => {
|
||||
console.log('complete')
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.manager-container {
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
height: 100vh;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.toolbar { margin-bottom: 18px; }
|
||||
.card-list {
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.log-section { margin-top: 18px; }
|
||||
</style>
|
||||
963
pages/control.uvue
Normal file
963
pages/control.uvue
Normal file
@@ -0,0 +1,963 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<button @click="scanDevices" :disabled="scanning">扫描蓝牙设备</button>
|
||||
<view v-if="scanning">正在扫描...</view>
|
||||
|
||||
<view class="section-title">设备列表</view>
|
||||
<view v-if="devices.length">
|
||||
<view v-for="item in devices" :key="item.deviceId" class="device-item">
|
||||
<view class="device-info">
|
||||
<text class="device-name">{{ item.name || '未知设备' }}</text>
|
||||
<text class="device-id">({{ item.deviceId }})</text>
|
||||
<text :class="['status', item.connected ? 'connected' : '']">{{ item.connected ? '已连接' : '未连接' }}</text>
|
||||
</view>
|
||||
<view class="device-actions">
|
||||
<button v-if="!item.connected" @click="connectDevice(item.deviceId)" class="btn-connect">连接</button>
|
||||
<button v-else @click="disconnectDevice(item.deviceId)" class="btn-disconnect">断开</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-list">暂无设备,请点击扫描</view>
|
||||
|
||||
<view class="section-title">已连接设备({{ connectedDevices.length }})</view>
|
||||
<view v-if="connectedDevices.length">
|
||||
<view v-for="item in connectedDevices" :key="item.deviceId" class="device-item connected-item">
|
||||
<view class="device-info">
|
||||
<text class="device-name">{{ item.name || '未知设备' }}</text>
|
||||
<text class="device-id">({{ item.deviceId }})</text>
|
||||
|
||||
<!-- 显示电池状态 -->
|
||||
<view v-if="batteryStatus[item.deviceId]" class="battery-status">
|
||||
<view class="battery-container">
|
||||
<view class="battery-level"
|
||||
:style="{width: batteryStatus[item.deviceId].level >= 0 ? batteryStatus[item.deviceId].level + '%' : '0%'}">
|
||||
</view>
|
||||
<text v-if="batteryStatus[item.deviceId].isCharging" class="charging-icon">⚡</text>
|
||||
</view>
|
||||
<text class="battery-text">
|
||||
{{ batteryStatus[item.deviceId].level >= 0 ? batteryStatus[item.deviceId].level + '%' : '未知' }}
|
||||
{{ batteryStatus[item.deviceId].isCharging ? ' (充电中)' : '' }}
|
||||
<span v-if="batteryStatus[item.deviceId].chargingStatus"> | 充电状态: {{ batteryStatus[item.deviceId].chargingStatus }}</span>
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="device-actions">
|
||||
<button @click="disconnectDevice(item.deviceId)" class="btn-disconnect">断开</button>
|
||||
<button @click="manualDiscoverServices(item.deviceId)" class="btn-discover">发现服务</button>
|
||||
<button @click="checkBatteryStatus(item.deviceId)"
|
||||
class="btn-battery"
|
||||
:disabled="checkingBatteryStatus[item.deviceId]">
|
||||
{{ checkingBatteryStatus[item.deviceId] ? '检查中...' : '检查电量' }}
|
||||
</button>
|
||||
<button @click="checkChargingStatus(item.deviceId)" class="btn-battery">测试充电状态</button>
|
||||
<button @click="checkStepCount(item.deviceId)" class="btn-battery">检查步数</button>
|
||||
<button @click="checkHwVersion(item.deviceId)" class="btn-battery">查询硬件版本</button>
|
||||
<button @click="checkSwVersion(item.deviceId)" class="btn-battery">查询软件版本</button>
|
||||
<button @click="getDeviceTime(item.deviceId)" class="btn-battery">获取设备时间</button>
|
||||
<button @click="setDeviceTime(item.deviceId)" class="btn-battery">设置设备时间</button>
|
||||
<button @click="checkHeartRate(item.deviceId)" class="btn-battery">测试心率</button>
|
||||
<button @click="checkOximetry(item.deviceId)" class="btn-battery">测试血氧</button>
|
||||
<button @click="checkOximetryWithEvents(item.deviceId)" class="btn-battery">测试血氧(事件流)</button>
|
||||
</view>
|
||||
<view v-if="stepCount[item.deviceId] !== undefined" style="margin-top:4px;color:#2196F3;display:flex;align-items:center;gap:8px;">
|
||||
步数: {{ stepCount[item.deviceId] }}
|
||||
<button @click="clearStepCount(item.deviceId)" class="btn-battery">步数清零</button>
|
||||
|
||||
</view>
|
||||
<view v-if="deviceTime[item.deviceId]" style="margin-top:2px;color:#009688;">
|
||||
设备时间: {{ deviceTime[item.deviceId] }}
|
||||
</view>
|
||||
<view v-if="hwVersion[item.deviceId]" style="margin-top:2px;color:#795548;">硬件版本: {{ hwVersion[item.deviceId] }}</view>
|
||||
<view v-if="swVersion[item.deviceId]" style="margin-top:2px;color:#607D8B;">软件版本: {{ swVersion[item.deviceId] }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-list">暂无已连接设备</view>
|
||||
|
||||
<!-- 服务和特征值管理 -->
|
||||
<view v-if="selectedDevice" class="device-detail">
|
||||
<view class="section-title">设备详情: {{ selectedDevice.name }}</view>
|
||||
|
||||
<view class="services-container">
|
||||
<view v-for="service in services" :key="service.uuid"
|
||||
:class="['service-item', service.uuid.startsWith('bae') ? 'bae-service' : '']">
|
||||
<view class="service-header" @click="toggleService(service.uuid)">
|
||||
<text class="service-uuid">{{ service.uuid }}</text>
|
||||
<text class="service-tag" v-if="service.uuid.startsWith('bae')">BAE服务</text>
|
||||
<text class="expand-icon">{{ expandedServices.includes(service.uuid) ? '▼' : '▶' }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="expandedServices.includes(service.uuid)" class="characteristics-container">
|
||||
<view v-if="characteristics[service.uuid] && characteristics[service.uuid].length" class="characteristics-list">
|
||||
<view v-for="char in characteristics[service.uuid]" :key="char.uuid" class="characteristic-item">
|
||||
<view class="char-header">
|
||||
<text class="char-uuid">{{ char.uuid }}</text>
|
||||
<view class="char-properties">
|
||||
<text v-if="char.properties.read" class="prop-tag read">R</text>
|
||||
<text v-if="char.properties.write" class="prop-tag write">W</text>
|
||||
<text v-if="char.properties.notify" class="prop-tag notify">N</text>
|
||||
<text v-if="char.properties.indicate" class="prop-tag indicate">I</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 可写特征值显示输入框 -->
|
||||
<view v-if="char.properties.write" class="char-write">
|
||||
<input
|
||||
type="text"
|
||||
v-model="writeValues[char.uuid]"
|
||||
placeholder="输入要写入的数据"
|
||||
class="write-input"
|
||||
/>
|
||||
<button
|
||||
@click="writeCharacteristic(selectedDevice.deviceId, service.uuid, char.uuid, writeValues[char.uuid])"
|
||||
class="btn-write"
|
||||
>
|
||||
写入
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 可订阅特征值显示订阅按钮和数据 -->
|
||||
<view v-if="char.properties.notify || char.properties.indicate" class="char-notify">
|
||||
<button
|
||||
v-if="!subscribedCharacteristics[char.uuid]"
|
||||
@click="subscribeCharacteristic(selectedDevice.deviceId, service.uuid, char.uuid)"
|
||||
class="btn-subscribe"
|
||||
>
|
||||
订阅
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn-subscribed"
|
||||
disabled
|
||||
>
|
||||
已订阅
|
||||
</button>
|
||||
|
||||
<view v-if="notifyData[char.uuid]" class="notify-data">
|
||||
<text class="data-label">收到数据:</text>
|
||||
<text class="data-value">{{ notifyData[char.uuid] }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-list">正在加载特征值...</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script lang="uts">
|
||||
import { bluetoothService } from '@/uni_modules/ak-sbsrv/utssdk/web/index.uts'
|
||||
import { BleDevice, BleService, BleCharacteristic, BatteryStatus } from '@/uni_modules/ak-sbsrv/interface.uts'
|
||||
import { ProtocolHandler } from '@/uni_modules/ak-sbsrv/utssdk/protocol-handler.uts'
|
||||
|
||||
// 类型声明集中管理
|
||||
interface ControlData {
|
||||
devices: BleDevice[];
|
||||
connectedDevices: BleDevice[];
|
||||
scanning: boolean;
|
||||
selectedDevice: BleDevice | null;
|
||||
services: BleService[];
|
||||
characteristics: Record<string, BleCharacteristic[]>;
|
||||
expandedServices: string[];
|
||||
writeValues: Record<string, string>;
|
||||
subscribedCharacteristics: Record<string, boolean>;
|
||||
notifyData: Record<string, string>;
|
||||
batteryStatus: Record<string, BatteryStatus>;
|
||||
checkingBatteryStatus: Record<string, boolean>;
|
||||
stepCount: Record<string, number>;
|
||||
hwVersion: Record<string, string>;
|
||||
swVersion: Record<string, string>;
|
||||
deviceTime: Record<string, string>; // 新增:存储设备时间
|
||||
}
|
||||
|
||||
export default {
|
||||
data(): ControlData {
|
||||
return {
|
||||
devices: [],
|
||||
connectedDevices: [],
|
||||
scanning: false,
|
||||
selectedDevice: null,
|
||||
services: [],
|
||||
characteristics: {},
|
||||
expandedServices: [],
|
||||
writeValues: {},
|
||||
subscribedCharacteristics: {},
|
||||
notifyData: {},
|
||||
batteryStatus: {},
|
||||
checkingBatteryStatus: {}
|
||||
,
|
||||
stepCount: {},
|
||||
hwVersion: {},
|
||||
swVersion: {},
|
||||
deviceTime: {} // 新增
|
||||
}
|
||||
},
|
||||
onLoad() {
|
||||
this.refreshConnectedDevices();
|
||||
if (typeof bluetoothService.onConnectionStateChange === 'function') {
|
||||
bluetoothService.onConnectionStateChange((deviceId, state) => {
|
||||
this.refreshConnectedDevices();
|
||||
if (typeof this.$forceUpdate === 'function') {
|
||||
this.$forceUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 统一错误提示
|
||||
showError(msg: string, e?: any) {
|
||||
uni.showToast({ title: msg + (e?.message ? ': ' + e.message : ''), icon: 'none' });
|
||||
if (e) console.error(msg, e);
|
||||
},
|
||||
// 刷新已连接设备
|
||||
refreshConnectedDevices() {
|
||||
this.connectedDevices = bluetoothService.getConnectedDevices();
|
||||
},
|
||||
// 扫描设备
|
||||
async scanDevices() {
|
||||
this.scanning = true;
|
||||
try {
|
||||
const result = await bluetoothService!.scanDevices();
|
||||
console.log(result)
|
||||
if (result!=null && result.length > 0) {
|
||||
const device = result[0];
|
||||
console.log(device)
|
||||
const existingDeviceIndex = this.devices.findIndex(d => d.deviceId === device.deviceId);
|
||||
if (existingDeviceIndex >= 0) {
|
||||
this.devices[existingDeviceIndex] = device;
|
||||
} else {
|
||||
this.devices.push(device);
|
||||
}
|
||||
}
|
||||
this.refreshConnectedDevices();
|
||||
} catch (e: any) {
|
||||
this.showError('扫描失败,请确保浏览器支持Web蓝牙API', e);
|
||||
} finally {
|
||||
this.scanning = false;
|
||||
}
|
||||
},
|
||||
// 连接设备
|
||||
async connectDevice(deviceId: string) {
|
||||
try {
|
||||
await bluetoothService.connectDevice(deviceId);
|
||||
this.updateDeviceConnection(deviceId, true);
|
||||
this.refreshConnectedDevices();
|
||||
const device = this.devices.find(d => d.deviceId === deviceId);
|
||||
if (device) this.selectDevice(device);
|
||||
uni.showToast({ title: '连接成功' });
|
||||
} catch (e) {
|
||||
this.showError('连接设备失败', e);
|
||||
}
|
||||
},
|
||||
// 断开设备连接
|
||||
async disconnectDevice(deviceId: string) {
|
||||
try {
|
||||
await bluetoothService.disconnectDevice(deviceId);
|
||||
this.updateDeviceConnection(deviceId, false);
|
||||
if (this.selectedDevice && this.selectedDevice.deviceId === deviceId) {
|
||||
this.selectedDevice = null;
|
||||
this.services = [];
|
||||
this.characteristics = {};
|
||||
this.expandedServices = [];
|
||||
}
|
||||
this.refreshConnectedDevices();
|
||||
uni.showToast({ title: '已断开连接' });
|
||||
} catch (e) {
|
||||
this.showError('断开连接失败', e);
|
||||
}
|
||||
},
|
||||
// 更新设备连接状态
|
||||
updateDeviceConnection(deviceId: string, connected: boolean) {
|
||||
const index = this.devices.findIndex(d => d.deviceId === deviceId);
|
||||
if (index >= 0) this.devices[index].connected = connected;
|
||||
},
|
||||
// 选择设备并自动发现服务
|
||||
selectDevice(device: BleDevice) {
|
||||
// 响应式赋值,确保 UI 刷新
|
||||
this.selectedDevice = { ...device };
|
||||
this.discoverServices(device.deviceId);
|
||||
if (typeof this.$forceUpdate === 'function') {
|
||||
this.$forceUpdate();
|
||||
}
|
||||
},
|
||||
// 手动发现服务
|
||||
async manualDiscoverServices(deviceId: string) {
|
||||
const device = this.devices.find(d => d.deviceId === deviceId) ||
|
||||
this.connectedDevices.find(d => d.deviceId === deviceId);
|
||||
if (device) this.selectDevice(device);
|
||||
},
|
||||
// 发现服务
|
||||
async discoverServices(deviceId: string) {
|
||||
try {
|
||||
this.services = [];
|
||||
this.characteristics = {};
|
||||
this.expandedServices = [];
|
||||
const services = await bluetoothService.discoverServices(deviceId);
|
||||
if (!services || !Array.isArray(services)) {
|
||||
throw new Error('服务发现返回值无效');
|
||||
}
|
||||
this.services = services;
|
||||
for (const service of services) {
|
||||
if (service && service.uuid && service.uuid.startsWith('bae')) {
|
||||
this.expandedServices.push(service.uuid);
|
||||
await this.getCharacteristics(deviceId, service.uuid);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.showError('发现服务失败', e);
|
||||
}
|
||||
},
|
||||
// 展开/折叠服务
|
||||
async toggleService(serviceId: string) {
|
||||
const index = this.expandedServices.indexOf(serviceId);
|
||||
if (index >= 0) {
|
||||
this.expandedServices.splice(index, 1);
|
||||
} else {
|
||||
this.expandedServices.push(serviceId);
|
||||
if (!this.characteristics[serviceId] || this.characteristics[serviceId].length === 0) {
|
||||
await this.getCharacteristics(this.selectedDevice!.deviceId, serviceId);
|
||||
}
|
||||
}
|
||||
},
|
||||
// 获取服务的特征值
|
||||
async getCharacteristics(deviceId: string, serviceId: string) {
|
||||
try {
|
||||
const chars = await bluetoothService.getCharacteristics(deviceId, serviceId);
|
||||
this.$set(this.characteristics, serviceId, chars);
|
||||
for (const char of chars) {
|
||||
if (char.properties.write) {
|
||||
this.$set(this.writeValues, char.uuid, '');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.showError('获取特征值失败', e);
|
||||
}
|
||||
},
|
||||
// 写入特征值
|
||||
async writeCharacteristic(deviceId: string, serviceId: string, characteristicId: string, value: string) {
|
||||
if (!value) {
|
||||
uni.showToast({ title: '请输入要写入的数据', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ok = await bluetoothService.writeCharacteristic(deviceId, serviceId, characteristicId, value);
|
||||
if (ok) {
|
||||
uni.showToast({ title: '写入成功' });
|
||||
this.$set(this.writeValues, characteristicId, '');
|
||||
} else {
|
||||
uni.showToast({ title: '写入失败', icon: 'none' });
|
||||
}
|
||||
} catch (e) {
|
||||
this.showError('写入特征值失败', e);
|
||||
}
|
||||
},
|
||||
// 订阅特征值变化
|
||||
async subscribeCharacteristic(deviceId: string, serviceId: string, characteristicId: string) {
|
||||
try {
|
||||
await bluetoothService.subscribeCharacteristic(
|
||||
deviceId,
|
||||
serviceId,
|
||||
characteristicId,
|
||||
(data) => {
|
||||
let displayData;
|
||||
if (data.data instanceof Uint8Array) {
|
||||
displayData = Array.from(data.data as Uint8Array)
|
||||
.map(byte => byte.toString(16).padStart(2, '0'))
|
||||
.join(' ');
|
||||
} else {
|
||||
displayData = data.data.toString();
|
||||
}
|
||||
this.$set(this.notifyData, characteristicId, displayData);
|
||||
}
|
||||
);
|
||||
this.$set(this.subscribedCharacteristics, characteristicId, true);
|
||||
uni.showToast({ title: '订阅成功' });
|
||||
} catch (e) {
|
||||
this.showError('订阅特征值失败', e);
|
||||
}
|
||||
},
|
||||
// 检查设备电池状态
|
||||
async checkBatteryStatus(deviceId: string) {
|
||||
try {
|
||||
this.$set(this.checkingBatteryStatus, deviceId, true);
|
||||
// 查找私有服务和特征
|
||||
if (!this.services || this.services.length === 0) {
|
||||
await this.discoverServices(deviceId);
|
||||
}
|
||||
// 这里假设私有服务UUID和特征有特定前缀(如'bae'),请根据实际协议调整
|
||||
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
|
||||
console.log(this.services)
|
||||
|
||||
if (!privateService) throw new Error('未找到私有协议服务');
|
||||
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
|
||||
await this.getCharacteristics(deviceId, privateService.uuid);
|
||||
}
|
||||
// 查找可写和可通知特征
|
||||
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
|
||||
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
|
||||
console.log(this.characteristics[privateService.uuid])
|
||||
console.log(writeChar,' aa ',notifyChar)
|
||||
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
|
||||
// 初始化协议处理器
|
||||
const handler = new ProtocolHandler(bluetoothService);
|
||||
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
|
||||
await handler.initialize();
|
||||
// 调用私有协议获取电量
|
||||
const batteryLevel = await handler.testBatteryLevel();
|
||||
this.$set(this.batteryStatus, deviceId, {
|
||||
level: batteryLevel,
|
||||
isCharging: false // 如需充电状态可扩展testChargingStatus
|
||||
});
|
||||
uni.showToast({ title: '获取电池信息成功' });
|
||||
} catch (e: any) {
|
||||
this.showError('获取电池信息失败', e);
|
||||
this.$set(this.batteryStatus, deviceId, {
|
||||
level: -1,
|
||||
isCharging: false
|
||||
});
|
||||
} finally {
|
||||
this.$set(this.checkingBatteryStatus, deviceId, false);
|
||||
}
|
||||
},
|
||||
// 检查步数
|
||||
async checkStepCount(deviceId: string) {
|
||||
try {
|
||||
// 确保服务和特征已发现
|
||||
if (!this.services || this.services.length === 0) {
|
||||
await this.discoverServices(deviceId);
|
||||
}
|
||||
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
|
||||
if (!privateService) throw new Error('未找到私有协议服务');
|
||||
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
|
||||
await this.getCharacteristics(deviceId, privateService.uuid);
|
||||
}
|
||||
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
|
||||
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
|
||||
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
|
||||
const handler = new ProtocolHandler(bluetoothService);
|
||||
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
|
||||
await handler.initialize();
|
||||
const stepCount = await handler.testStepCount();
|
||||
this.$set(this.stepCount, deviceId, stepCount);
|
||||
uni.showToast({ title: '步数获取成功' });
|
||||
} catch (e: any) {
|
||||
this.showError('获取步数失败', e);
|
||||
this.$set(this.stepCount, deviceId, -1);
|
||||
}
|
||||
},
|
||||
// 步数清零
|
||||
async clearStepCount(deviceId: string) {
|
||||
try {
|
||||
if (!this.services || this.services.length === 0) {
|
||||
await this.discoverServices(deviceId);
|
||||
}
|
||||
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
|
||||
if (!privateService) throw new Error('未找到私有协议服务');
|
||||
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
|
||||
await this.getCharacteristics(deviceId, privateService.uuid);
|
||||
}
|
||||
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
|
||||
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
|
||||
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
|
||||
const handler = new ProtocolHandler(bluetoothService);
|
||||
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
|
||||
await handler.initialize();
|
||||
await handler.testClearStepCount();
|
||||
uni.showToast({ title: '步数已清零' });
|
||||
// 清零后自动刷新步数
|
||||
await this.checkStepCount(deviceId);
|
||||
} catch (e: any) {
|
||||
this.showError('步数清零失败', e);
|
||||
}
|
||||
},
|
||||
// 获取设备时间
|
||||
async getDeviceTime(deviceId: string) {
|
||||
try {
|
||||
if (!this.services || this.services.length === 0) {
|
||||
await this.discoverServices(deviceId);
|
||||
}
|
||||
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
|
||||
if (!privateService) throw new Error('未找到私有协议服务');
|
||||
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
|
||||
await this.getCharacteristics(deviceId, privateService.uuid);
|
||||
}
|
||||
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
|
||||
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
|
||||
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
|
||||
const handler = new ProtocolHandler(bluetoothService);
|
||||
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
|
||||
await handler.initialize();
|
||||
const timeStr = await handler.testGetTime();
|
||||
this.$set(this.deviceTime, deviceId, timeStr);
|
||||
uni.showToast({ title: '获取设备时间成功' });
|
||||
} catch (e: any) {
|
||||
this.showError('获取设备时间失败', e);
|
||||
this.$set(this.deviceTime, deviceId, '获取失败');
|
||||
}
|
||||
},
|
||||
// 获取设备时间
|
||||
async setDeviceTime(deviceId: string) {
|
||||
try {
|
||||
if (!this.services || this.services.length === 0) {
|
||||
await this.discoverServices(deviceId);
|
||||
}
|
||||
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
|
||||
if (!privateService) throw new Error('未找到私有协议服务');
|
||||
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
|
||||
await this.getCharacteristics(deviceId, privateService.uuid);
|
||||
}
|
||||
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
|
||||
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
|
||||
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
|
||||
const handler = new ProtocolHandler(bluetoothService);
|
||||
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
|
||||
await handler.initialize();
|
||||
const timeStr = await handler.testTimeSync();
|
||||
this.$set(this.deviceTime, deviceId, timeStr);
|
||||
uni.showToast({ title: '设置设备时间成功' });
|
||||
} catch (e: any) {
|
||||
this.showError('设置设备时间失败', e);
|
||||
this.$set(this.deviceTime, deviceId, '获取失败');
|
||||
}
|
||||
},
|
||||
// 查询硬件版本
|
||||
async checkHwVersion(deviceId: string) {
|
||||
try {
|
||||
if (!this.services || this.services.length === 0) {
|
||||
await this.discoverServices(deviceId);
|
||||
}
|
||||
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
|
||||
if (!privateService) throw new Error('未找到私有协议服务');
|
||||
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
|
||||
await this.getCharacteristics(deviceId, privateService.uuid);
|
||||
}
|
||||
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
|
||||
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
|
||||
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
|
||||
const handler = new ProtocolHandler(bluetoothService);
|
||||
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
|
||||
await handler.initialize();
|
||||
const version = await handler.testVersionInfo(true); // true=硬件版本
|
||||
this.$set(this.hwVersion, deviceId, version);
|
||||
uni.showToast({ title: '硬件版本获取成功' });
|
||||
} catch (e: any) {
|
||||
this.showError('获取硬件版本失败', e);
|
||||
this.$set(this.hwVersion, deviceId, '获取失败');
|
||||
}
|
||||
},
|
||||
// 查询软件版本
|
||||
async checkSwVersion(deviceId: string) {
|
||||
try {
|
||||
if (!this.services || this.services.length === 0) {
|
||||
await this.discoverServices(deviceId);
|
||||
}
|
||||
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
|
||||
if (!privateService) throw new Error('未找到私有协议服务');
|
||||
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
|
||||
await this.getCharacteristics(deviceId, privateService.uuid);
|
||||
}
|
||||
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
|
||||
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
|
||||
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
|
||||
const handler = new ProtocolHandler(bluetoothService);
|
||||
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
|
||||
await handler.initialize();
|
||||
const version = await handler.testVersionInfo(false); // false=软件版本
|
||||
this.$set(this.swVersion, deviceId, version);
|
||||
uni.showToast({ title: '软件版本获取成功' });
|
||||
} catch (e: any) {
|
||||
this.showError('获取软件版本失败', e);
|
||||
this.$set(this.swVersion, deviceId, '获取失败');
|
||||
}
|
||||
},
|
||||
// 测试充电状态
|
||||
async checkChargingStatus(deviceId: string) {
|
||||
try {
|
||||
if (!this.services || this.services.length === 0) {
|
||||
await this.discoverServices(deviceId);
|
||||
}
|
||||
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
|
||||
if (!privateService) throw new Error('未找到私有协议服务');
|
||||
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
|
||||
await this.getCharacteristics(deviceId, privateService.uuid);
|
||||
}
|
||||
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
|
||||
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
|
||||
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
|
||||
const handler = new ProtocolHandler(bluetoothService);
|
||||
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
|
||||
await handler.initialize();
|
||||
const status = await handler.testChargingStatus();
|
||||
let statusText = '未知';
|
||||
if (status === 0) statusText = '未充电';
|
||||
else if (status === 1) statusText = '充电中';
|
||||
else if (status === 2) statusText = '已充满';
|
||||
if (!this.batteryStatus[deviceId]) {
|
||||
this.$set(this.batteryStatus, deviceId, { level: -1, isCharging: false, chargingStatus: statusText });
|
||||
} else {
|
||||
this.$set(this.batteryStatus[deviceId], 'chargingStatus', statusText);
|
||||
}
|
||||
uni.showToast({ title: '充电状态: ' + statusText });
|
||||
} catch (e: any) {
|
||||
this.showError('获取充电状态失败', e);
|
||||
if (!this.batteryStatus[deviceId]) {
|
||||
this.$set(this.batteryStatus, deviceId, { level: -1, isCharging: false, chargingStatus: '未知' });
|
||||
} else {
|
||||
this.$set(this.batteryStatus[deviceId], 'chargingStatus', '未知');
|
||||
}
|
||||
}
|
||||
},
|
||||
// 测试心率(事件流式)
|
||||
async checkHeartRate(deviceId: string) {
|
||||
try {
|
||||
if (!this.services || this.services.length === 0) {
|
||||
await this.discoverServices(deviceId);
|
||||
}
|
||||
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
|
||||
if (!privateService) throw new Error('未找到私有协议服务');
|
||||
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
|
||||
await this.getCharacteristics(deviceId, privateService.uuid);
|
||||
}
|
||||
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
|
||||
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
|
||||
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
|
||||
const handler = new ProtocolHandler(bluetoothService);
|
||||
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
|
||||
await handler.initialize();
|
||||
// 使用事件流方式
|
||||
const { stop, onData } = handler.heartRateWithEvents();
|
||||
let lastHeartRate = null;
|
||||
let timer = null;
|
||||
onData((data: Uint8Array) => {
|
||||
// 假设心率值在data[5],可根据协议调整
|
||||
if (data.length >= 6 && data[2] === 0x31 && data[3] === 0x00) {
|
||||
const heartRate = data[5];
|
||||
lastHeartRate = heartRate;
|
||||
uni.showToast({ title: '心率: ' + heartRate, icon: 'success' });
|
||||
// 3秒后自动停止
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
stop();
|
||||
uni.showToast({ title: '心率测量已结束', icon: 'none' });
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
this.showError('获取心率失败', e);
|
||||
}
|
||||
},
|
||||
// 测试血氧(事件流式)
|
||||
async checkOximetryWithEvents(deviceId: string) {
|
||||
try {
|
||||
if (!this.services || this.services.length === 0) {
|
||||
await this.discoverServices(deviceId);
|
||||
}
|
||||
const privateService = this.services.find(service => service.uuid.startsWith('bae'));
|
||||
if (!privateService) throw new Error('未找到私有协议服务');
|
||||
if (!this.characteristics[privateService.uuid] || this.characteristics[privateService.uuid].length === 0) {
|
||||
await this.getCharacteristics(deviceId, privateService.uuid);
|
||||
}
|
||||
const writeChar = this.characteristics[privateService.uuid].find(char => char.properties.write);
|
||||
const notifyChar = this.characteristics[privateService.uuid].find(char => char.properties.notify);
|
||||
if (!writeChar || !notifyChar) throw new Error('未找到私有协议特征');
|
||||
const handler = new ProtocolHandler(bluetoothService);
|
||||
handler.setConnectionParameters(deviceId, privateService.uuid, writeChar.uuid, notifyChar.uuid);
|
||||
await handler.initialize();
|
||||
// 使用事件流方式
|
||||
const { stop, onData } = handler.oximetryWithEvents();
|
||||
let timer = null;
|
||||
onData((data: Uint8Array) => {
|
||||
// 协议:data[4]=佩戴状态, data[5]=心率, data[6]=血氧, data[7:8]=温度(int16,0.01℃,小端)
|
||||
if (data.length >= 9 && data[2] === 0x32 && data[3] === 0x00) {
|
||||
const wearStatus = data[4];
|
||||
const heartRate = data[5];
|
||||
const spo2 = data[6];
|
||||
// 温度为有符号短整型,小端序
|
||||
let tempRaw = (data[8] << 8) | data[7];
|
||||
if (tempRaw & 0x8000) tempRaw = tempRaw - 0x10000;
|
||||
const temp = tempRaw / 100;
|
||||
console.log({ title: `血氧: ${spo2}% 心率: ${heartRate} 温度: ${temp}℃` });
|
||||
// 3秒后自动停止
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
stop();
|
||||
uni.showToast({ title: '血氧测量已结束', icon: 'none' });
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
this.showError('获取血氧失败', e);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin: 20px 0 10px;
|
||||
padding-bottom: 5px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.device-item {
|
||||
margin: 10px 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.connected-item {
|
||||
background-color: #f0f9ff;
|
||||
border-color: #b3e0ff;
|
||||
}
|
||||
.device-info {
|
||||
flex: 1;
|
||||
}
|
||||
.device-name {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.device-id {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.status.connected {
|
||||
color: #4CAF50;
|
||||
}
|
||||
.device-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.btn-connect {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-disconnect {
|
||||
background-color: #F44336;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-discover {
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.empty-list {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 设备详情样式 */
|
||||
.device-detail {
|
||||
margin-top: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
/* 服务列表样式 */
|
||||
.services-container {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.service-item {
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bae-service {
|
||||
border-color: #4CAF50;
|
||||
}
|
||||
.service-header {
|
||||
padding: 10px;
|
||||
background-color: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.bae-service .service-header {
|
||||
background-color: #e8f5e9;
|
||||
}
|
||||
.service-uuid {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
}
|
||||
.service-tag {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.expand-icon {
|
||||
margin-left: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 特征值列表样式 */
|
||||
.characteristics-container {
|
||||
padding: 10px;
|
||||
background-color: white;
|
||||
}
|
||||
.characteristic-item {
|
||||
padding: 10px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.char-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.char-uuid {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
.char-properties {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.prop-tag {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.prop-tag.read {
|
||||
background-color: #2196F3;
|
||||
}
|
||||
.prop-tag.write {
|
||||
background-color: #FF9800;
|
||||
}
|
||||
.prop-tag.notify {
|
||||
background-color: #9C27B0;
|
||||
}
|
||||
.prop-tag.indicate {
|
||||
background-color: #795548;
|
||||
}
|
||||
|
||||
/* 写入区域样式 */
|
||||
.char-write {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.write-input {
|
||||
flex: 1;
|
||||
padding: 6px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.btn-write {
|
||||
background-color: #FF9800;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
/* 通知区域样式 */
|
||||
.char-notify {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-subscribe {
|
||||
background-color: #9C27B0;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.btn-subscribed {
|
||||
background-color: #7B1FA2;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.notify-data {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.data-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.data-value {
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 电池状态样式 */
|
||||
.battery-status {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.battery-container {
|
||||
width: 40px;
|
||||
height: 16px;
|
||||
border: 1px solid #999;
|
||||
border-radius: 2px;
|
||||
padding: 1px;
|
||||
position: relative;
|
||||
margin-right: 8px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
.battery-level {
|
||||
height: 100%;
|
||||
background-color: #4CAF50;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.battery-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.charging-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #FF9800;
|
||||
font-size: 10px;
|
||||
}
|
||||
.btn-battery {
|
||||
background-color: #607D8B;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user