Files
uni-pda/pages/testpages/ble.vue
T
2026-06-02 15:28:25 +08:00

1091 lines
24 KiB
Vue
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.
<template>
<view class="container">
<!-- 状态显示 -->
<view class="status-bar">
<view class="status-item">
<text class="status-label">蓝牙适配器</text>
<text class="status-value" :class="adapterState.discovering ? 'active' : ''">
{{ adapterState.discovering ? '搜索中' : adapterState.available ? '就绪' : '不可用' }}
</text>
</view>
<view class="status-item">
<text class="status-label">当前连接</text>
<text class="status-value" :class="connected ? 'connected' : ''">
{{ connected ? connectedDeviceName || '已连接' : '未连接' }}
</text>
</view>
</view>
<!-- 操作按钮区域 -->
<view class="action-section">
<button type="primary" @click="initBluetooth" :disabled="isInitializing">
{{ isInitializing ? '初始化中...' : '初始化蓝牙' }}
</button>
<button type="warn" v-if="adapterState.available" @click="closeBluetooth">
关闭蓝牙
</button>
</view>
<!-- 搜索控制 -->
<view class="action-section" v-if="adapterState.available">
<button v-if="!adapterState.discovering" type="primary" @click="startDiscovery">
开始搜索
</button>
<button v-else type="default" @click="stopDiscovery">
停止搜索
</button>
</view>
<!-- 设备列表 -->
<view class="section">
<view class="section-header">
<text class="section-title">发现设备 ({{ deviceList.length }})</text>
<text class="section-subtitle">点击设备连接</text>
</view>
<view class="device-list" v-if="deviceList.length > 0">
<view
class="device-item"
v-for="device in deviceList"
:key="device.deviceId"
:class="{ connecting: connectingDeviceId === device.deviceId }"
@click="connectDevice(device)"
>
<view class="device-info">
<text class="device-name">{{ device.name || '未知设备' }}</text>
<text class="device-id">{{ device.deviceId }}</text>
</view>
<view class="device-signal">
<text class="signal-rssi" v-if="device.RSSI">RSSI: {{ device.RSSI }}</text>
<text class="signal-status" v-if="connectingDeviceId === device.deviceId">连接中...</text>
<text class="signal-status connected" v-else-if="connectedDeviceId === device.deviceId">已连接</text>
</view>
</view>
</view>
<view class="empty-tip" v-else>
<text>暂无发现设备请确保目标设备正在广播</text>
</view>
</view>
<!-- 已连接设备信息 -->
<view class="section" v-if="connected">
<view class="section-header">
<text class="section-title">已连接设备</text>
<button class="disconnect-btn" size="mini" type="warn" @click="disconnectDevice">
断开连接
</button>
</view>
<!-- 服务列表 -->
<view class="service-list" v-if="serviceList.length > 0">
<view
class="service-item"
v-for="service in serviceList"
:key="service.uuid"
:class="{ active: selectedService && selectedService.uuid === service.uuid }"
@click="selectService(service)"
>
<text class="service-name">{{ service.isPrimary ? '[主服务]' : '[服务]' }} {{ service.uuid }}</text>
</view>
</view>
<!-- 特征值列表 -->
<view class="characteristic-list" v-if="characteristicList.length > 0">
<view
class="characteristic-item"
v-for="char in characteristicList"
:key="char.uuid"
>
<view class="char-info">
<text class="char-uuid">{{ char.uuid }}</text>
<view class="char-properties">
<text v-if="char.properties.read" class="prop-tag">可读</text>
<text v-if="char.properties.write" class="prop-tag write">可写</text>
<text v-if="char.properties.notify" class="prop-tag notify">通知</text>
<text v-if="char.properties.indicate" class="prop-tag indicate">指示</text>
</view>
</view>
<view class="char-actions">
<button
v-if="char.properties.read"
size="mini"
type="primary"
@click="readCharacteristic(char)"
>
读取
</button>
<button
v-if="char.properties.notify || char.properties.indicate"
size="mini"
type="default"
:class="{ active: notifiedCharacteristics.includes(char.uuid) }"
@click="toggleNotify(char)"
>
{{ notifiedCharacteristics.includes(char.uuid) ? '已订阅' : '订阅' }}
</button>
</view>
</view>
</view>
<!-- 写入数据 -->
<view class="write-section" v-if="characteristicList.length > 0 && writeCharacteristicId">
<view class="write-header">
<text class="write-title">发送数据</text>
<text class="write-target">目标: {{ writeCharacteristicId }}</text>
</view>
<!-- 模式切换 -->
<view class="mode-switch">
<view
class="mode-btn"
:class="{ active: writeMode === 'text' }"
@click="writeMode = 'text'"
>
文本
</view>
<view
class="mode-btn"
:class="{ active: writeMode === 'hex' }"
@click="writeMode = 'hex'"
>
16进制
</view>
</view>
<!-- 文本输入 -->
<input
v-if="writeMode === 'text'"
class="write-input"
v-model="writeTextValue"
placeholder="输入要发送的文本数据"
/>
<!-- 16进制输入 -->
<input
v-else
class="write-input"
v-model="writeHexValue"
placeholder="输入16进制数据(如: 0102FF"
/>
<button size="default" type="primary" @click="writeCharacteristic">
发送
</button>
</view>
</view>
<!-- 日志区域 -->
<view class="section log-section">
<view class="section-header">
<text class="section-title">操作日志</text>
<button class="clear-btn" size="mini" @click="clearLog">清空</button>
</view>
<scroll-view class="log-list" scroll-y>
<text class="log-item" v-for="(log, index) in logList" :key="index" :class="log.type">
[{{ log.time }}] {{ log.message }}
</text>
</scroll-view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
// 蓝牙适配器状态
adapterState: {
available: false,
discovering: false
},
isInitializing: false,
// 设备列表
deviceList: [],
connectingDeviceId: '',
connectedDeviceId: '',
connectedDeviceName: '',
connected: false,
// 服务和特征值
serviceList: [],
characteristicList: [],
selectedService: null,
writeCharacteristicId: '',
writeServiceId: '',
// 订阅的特征值
notifiedCharacteristics: [],
// 写入数据
writeMode: 'text', // 'text' | 'hex'
writeTextValue: '',
writeHexValue: '',
// 日志
logList: []
}
},
onLoad() {
this.initBluetooth()
this.setupListeners()
},
onUnload() {
this.cleanup()
},
methods: {
// 添加日志
addLog(message, type = 'info') {
const now = new Date()
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
this.logList.unshift({ time, message, type })
// 限制日志数量
if (this.logList.length > 100) {
this.logList.pop()
}
console.log(`[BLE] ${message}`)
},
// 清空日志
clearLog() {
this.logList = []
},
// 设置监听器
setupListeners() {
// 监听适配器状态变化
uni.onBluetoothAdapterStateChange((res) => {
this.addLog(`适配器状态变化: available=${res.available}, discovering=${res.discovering}`)
this.adapterState.available = res.available
this.adapterState.discovering = res.discovering
if (!res.available) {
this.addLog('蓝牙适配器不可用', 'error')
this.stopDiscovery()
}
})
// 监听发现设备
uni.onBluetoothDeviceFound((res) => {
const devices = res.devices || []
devices.forEach(device => {
// 过滤有效的 BLE 设备
if (!device.name && !device.localName) {
return
}
// 去重
const index = this.deviceList.findIndex(d => d.deviceId === device.deviceId)
if (index === -1) {
this.deviceList.push(device)
this.addLog(`发现设备: ${device.name || device.localName} (RSSI: ${device.RSSI || 'N/A'})`)
} else {
// 更新信号强度
this.deviceList[index].RSSI = device.RSSI
}
})
})
// 监听连接状态变化
uni.onBLEConnectionStateChange((res) => {
this.addLog(`连接状态变化: ${res.deviceId}, connected=${res.connected}`)
if (!res.connected) {
// 设备断开连接
if (this.connectedDeviceId === res.deviceId) {
this.connected = false
this.connectedDeviceId = ''
this.connectedDeviceName = ''
this.serviceList = []
this.characteristicList = []
this.notifiedCharacteristics = []
this.addLog('设备已断开连接', 'warn')
}
}
})
// 监听特征值变化
uni.onBLECharacteristicValueChange((res) => {
const hex = this.ab2hex(res.value)
this.addLog(`收到数据: ${hex}`, 'success')
// 可以在此处理收到的数据
uni.showToast({
title: `收到: ${hex.substring(0, 10)}...`,
icon: 'none',
duration: 1500
})
})
},
// 初始化蓝牙适配器
initBluetooth() {
if (this.isInitializing) return
this.isInitializing = true
this.addLog('正在初始化蓝牙适配器...')
uni.openBluetoothAdapter({
success: (res) => {
this.addLog('蓝牙适配器初始化成功')
this.adapterState.available = true
this.isInitializing = false
// 获取适配器状态
this.getAdapterState()
// 获取已发现的设备(可能是之前连接过的)
this.getKnownDevices()
},
fail: (err) => {
this.isInitializing = false
this.adapterState.available = false
this.addLog(`初始化失败: ${err.errMsg}`, 'error')
uni.showModal({
title: '蓝牙初始化失败',
content: err.errMsg || '请检查蓝牙是否开启',
showCancel: false
})
}
})
},
// 获取适配器状态
getAdapterState() {
uni.getBluetoothAdapterState({
success: (res) => {
this.adapterState.available = res.available
this.adapterState.discovering = res.discovering
this.addLog(`适配器状态: available=${res.available}, discovering=${res.discovering}`)
},
fail: (err) => {
this.addLog(`获取状态失败: ${err.errMsg}`, 'error')
}
})
},
// 获取已发现的设备
getKnownDevices() {
uni.getBluetoothDevices({
success: (res) => {
if (res.devices && res.devices.length > 0) {
this.deviceList = res.devices.filter(d => d.name || d.localName)
this.addLog(`获取到 ${this.deviceList.length} 个已发现设备`)
}
},
fail: (err) => {
this.addLog(`获取设备列表失败: ${err.errMsg}`, 'error')
}
})
},
// 关闭蓝牙
closeBluetooth() {
this.cleanup()
uni.closeBluetoothAdapter({
success: () => {
this.addLog('蓝牙适配器已关闭')
this.adapterState.available = false
this.adapterState.discovering = false
}
})
},
// 开始搜索
startDiscovery() {
if (this.adapterState.discovering) {
this.addLog('已经在搜索中...')
return
}
this.deviceList = [] // 清空设备列表
this.addLog('开始搜索 BLE 设备...')
uni.startBluetoothDevicesDiscovery({
allowDuplicatesKey: true, // 允许重复发现
success: (res) => {
this.addLog('开始搜索成功')
this.adapterState.discovering = true
// 设置超时自动停止
setTimeout(() => {
if (this.adapterState.discovering) {
this.stopDiscovery()
}
}, 30000) // 30秒后自动停止
},
fail: (err) => {
this.addLog(`搜索失败: ${err.errMsg}`, 'error')
// 处理特定错误
if (err.errMsg && err.errMsg.includes('Location')) {
uni.showModal({
title: '需要位置权限',
content: 'BLE 蓝牙扫描需要位置权限,请在系统设置中开启位置服务',
showCancel: false
})
}
}
})
},
// 停止搜索
stopDiscovery() {
uni.stopBluetoothDevicesDiscovery({
success: () => {
this.addLog('已停止搜索')
this.adapterState.discovering = false
},
fail: (err) => {
this.addLog(`停止搜索失败: ${err.errMsg}`, 'error')
}
})
},
// 连接设备
connectDevice(device) {
if (this.connected && this.connectedDeviceId === device.deviceId) {
this.addLog('已经连接此设备')
return
}
// 先断开之前的连接
if (this.connected) {
this.disconnectDevice()
}
this.connectingDeviceId = device.deviceId
this.addLog(`正在连接: ${device.name || device.localName || device.deviceId}...`)
uni.createBLEConnection({
deviceId: device.deviceId,
timeout: 10000,
success: (res) => {
this.addLog('连接成功')
this.connected = true
this.connectedDeviceId = device.deviceId
this.connectedDeviceName = device.name || device.localName
this.connectingDeviceId = ''
// 获取设备服务
this.getDeviceServices()
// 设置 MTU
this.setMTU()
},
fail: (err) => {
this.connectingDeviceId = ''
this.addLog(`连接失败: ${err.errMsg}`, 'error')
uni.showToast({
title: '连接失败',
icon: 'none'
})
}
})
},
// 设置 MTU
setMTU() {
uni.setBLEMTU({
deviceId: this.connectedDeviceId,
mtu: 512,
success: (res) => {
this.addLog(`MTU 设置成功: ${res.mtu}`)
},
fail: (err) => {
this.addLog(`MTU 设置失败: ${err.errMsg}`, 'warn')
}
})
},
// 断开连接
disconnectDevice() {
if (!this.connectedDeviceId) return
this.addLog('正在断开连接...')
// 先停止所有通知
this.notifiedCharacteristics = []
uni.closeBLEConnection({
deviceId: this.connectedDeviceId,
success: () => {
this.addLog('已断开连接')
this.connected = false
this.connectedDeviceId = ''
this.connectedDeviceName = ''
this.serviceList = []
this.characteristicList = []
},
fail: (err) => {
this.addLog(`断开连接失败: ${err.errMsg}`, 'error')
}
})
},
// 获取设备服务
getDeviceServices() {
this.addLog('获取设备服务...')
uni.getBLEDeviceServices({
deviceId: this.connectedDeviceId,
success: (res) => {
console.log('res.services', res)
this.serviceList = res.services
this.addLog(`获取到 ${res.services.length} 个服务`)
// 默认选择第一个主服务
const primaryService = res.services.find(s => s.isPrimary)
if (primaryService) {
this.selectService(primaryService)
}
},
fail: (err) => {
this.addLog(`获取服务失败: ${err.errMsg}`, 'error')
}
})
},
// 选择服务
selectService(service) {
this.selectedService = service
this.characteristicList = []
this.writeCharacteristicId = ''
this.addLog(`选择服务: ${service.uuid}`)
uni.getBLEDeviceCharacteristics({
deviceId: this.connectedDeviceId,
serviceId: service.uuid,
success: (res) => {
this.characteristicList = res.characteristics
this.addLog(`获取到 ${res.characteristics.length} 个特征值`)
// 默认选择第一个可写的特征值
const writableChar = res.characteristics.find(c => c.properties.write)
if (writableChar) {
this.writeCharacteristicId = writableChar.uuid
this.writeServiceId = service.uuid
}
},
fail: (err) => {
this.addLog(`获取特征值失败: ${err.errMsg}`, 'error')
}
})
},
// 读取特征值
readCharacteristic(char) {
this.addLog(`读取特征值: ${char.uuid}`)
uni.readBLECharacteristicValue({
deviceId: this.connectedDeviceId,
serviceId: this.selectedService.uuid,
characteristicId: char.uuid,
success: () => {
this.addLog('读取请求已发送,等待数据...')
},
fail: (err) => {
this.addLog(`读取失败: ${err.errMsg}`, 'error')
}
})
},
// 切换通知订阅
toggleNotify(char) {
const isNotified = this.notifiedCharacteristics.includes(char.uuid)
if (isNotified) {
// 取消订阅
this.stopNotify(char)
} else {
// 订阅
this.startNotify(char)
}
},
// 开始订阅通知
startNotify(char) {
this.addLog(`订阅通知: ${char.uuid}`)
uni.notifyBLECharacteristicValueChange({
deviceId: this.connectedDeviceId,
serviceId: this.selectedService.uuid,
characteristicId: char.uuid,
state: true,
success: () => {
this.notifiedCharacteristics.push(char.uuid)
this.addLog('订阅成功,等待数据推送...')
},
fail: (err) => {
this.addLog(`订阅失败: ${err.errMsg}`, 'error')
}
})
},
// 停止订阅通知
stopNotify(char) {
this.addLog(`取消订阅: ${char.uuid}`)
uni.notifyBLECharacteristicValueChange({
deviceId: this.connectedDeviceId,
serviceId: this.selectedService.uuid,
characteristicId: char.uuid,
state: false,
success: () => {
const index = this.notifiedCharacteristics.indexOf(char.uuid)
if (index > -1) {
this.notifiedCharacteristics.splice(index, 1)
}
this.addLog('已取消订阅')
},
fail: (err) => {
this.addLog(`取消订阅失败: ${err.errMsg}`, 'error')
}
})
},
// 写入特征值
writeCharacteristic() {
if (!this.writeCharacteristicId) {
uni.showToast({
title: '请先选择可写特征值',
icon: 'none'
})
return
}
let buffer
let displayData = ''
if (this.writeMode === 'text') {
// 文本模式
if (!this.writeTextValue) {
uni.showToast({ title: '请输入文本数据', icon: 'none' })
return
}
buffer = this.stringToBuffer(this.writeTextValue)
displayData = this.writeTextValue
this.addLog(`发送文本: "${displayData}"`)
} else {
// 16进制模式
if (!this.writeHexValue) {
uni.showToast({ title: '请输入16进制数据', icon: 'none' })
return
}
const hexStr = this.writeHexValue.replace(/\s/g, '')
if (!/^[0-9A-Fa-f]+$/.test(hexStr) || hexStr.length % 2 !== 0) {
uni.showToast({ title: '请输入有效的16进制数据', icon: 'none' })
return
}
buffer = this.hexToBuffer(hexStr)
displayData = hexStr
this.addLog(`发送16进制: ${displayData}`)
}
uni.writeBLECharacteristicValue({
deviceId: this.connectedDeviceId,
serviceId: this.writeServiceId,
characteristicId: this.writeCharacteristicId,
value: buffer,
writeType: 'writeNoResponse',
success: () => {
this.addLog('发送成功', 'success')
uni.showToast({ title: '发送成功', icon: 'success' })
},
fail: (err) => {
this.addLog(`发送失败: ${err.errMsg}`, 'error')
uni.showToast({ title: '发送失败', icon: 'none' })
}
})
},
// 清理
cleanup() {
if (this.connected) {
this.disconnectDevice()
}
if (this.adapterState.discovering) {
this.stopDiscovery()
}
this.deviceList = []
},
// ArrayBuffer 转 16进制字符串
ab2hex(buffer) {
const hexArr = Array.prototype.map.call(
new Uint8Array(buffer),
bit => ('00' + bit.toString(16)).slice(-2)
)
return hexArr.join(' ')
},
// 16进制字符串转 ArrayBuffer
hexToBuffer(hex) {
const buffer = new ArrayBuffer(hex.length / 2)
const dataView = new DataView(buffer)
for (let i = 0; i < hex.length; i += 2) {
dataView.setUint8(i / 2, parseInt(hex.substr(i, 2), 16))
}
return buffer
},
// 字符串转 ArrayBuffer
stringToBuffer(str) {
const buffer = new ArrayBuffer(str.length)
const dataView = new DataView(buffer)
for (let i = 0; i < str.length; i++) {
dataView.setUint8(i, str.charCodeAt(i))
}
return buffer
}
}
}
</script>
<style scoped>
.container {
padding: 20rpx;
background-color: #f5f5f5;
min-height: 100vh;
}
.status-bar {
display: flex;
background: #fff;
padding: 20rpx;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
.status-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.status-label {
font-size: 24rpx;
color: #999;
margin-bottom: 8rpx;
}
.status-value {
font-size: 28rpx;
font-weight: bold;
color: #666;
}
.status-value.active {
color: #007aff;
}
.status-value.connected {
color: #34c759;
}
.action-section {
display: flex;
gap: 20rpx;
margin-bottom: 20rpx;
}
.action-section button {
flex: 1;
}
.section {
background: #fff;
border-radius: 12rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.section-subtitle {
font-size: 24rpx;
color: #999;
}
.device-list {
max-height: 400rpx;
overflow-y: auto;
}
.device-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx;
border-bottom: 1rpx solid #eee;
}
.device-item:last-child {
border-bottom: none;
}
.device-item:active {
background-color: #f5f5f5;
}
.device-item.connecting {
background-color: #e8f4ff;
}
.device-info {
flex: 1;
}
.device-name {
font-size: 28rpx;
font-weight: bold;
color: #333;
display: block;
}
.device-id {
font-size: 22rpx;
color: #999;
word-break: break-all;
}
.device-signal {
text-align: right;
}
.signal-rssi {
font-size: 24rpx;
color: #666;
display: block;
}
.signal-status {
font-size: 24rpx;
color: #007aff;
}
.signal-status.connected {
color: #34c759;
}
.empty-tip {
text-align: center;
padding: 60rpx 0;
color: #999;
font-size: 28rpx;
}
.disconnect-btn {
margin: 0;
}
.service-list {
max-height: 300rpx;
margin-bottom: 20rpx;
}
.service-item {
padding: 24rpx;
background: #f8f8f8;
border-radius: 8rpx;
margin-bottom: 8rpx;
}
.service-item:active {
background: #eee;
}
.service-item.active {
background: #e8f4ff;
border: 1rpx solid #007aff;
margin-left: -1rpx;
margin-right: -1rpx;
}
.service-name {
font-size: 26rpx;
color: #333;
word-break: break-all;
}
.service-list {
max-height: 250rpx;
overflow-y: auto;
margin-bottom: 24rpx;
}
.characteristic-list {
max-height: 400rpx;
margin-top: 8rpx;
}
.characteristic-item {
padding: 20rpx;
background: #f8f8f8;
border-radius: 8rpx;
margin-bottom: 12rpx;
}
.char-info {
margin-bottom: 12rpx;
}
.char-uuid {
font-size: 24rpx;
color: #333;
display: block;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.char-properties {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
}
.prop-tag {
font-size: 20rpx;
padding: 4rpx 12rpx;
background: #007aff;
color: #fff;
border-radius: 4rpx;
}
.prop-tag.write {
background: #5856d6;
}
.prop-tag.notify {
background: #34c759;
}
.prop-tag.indicate {
background: #ff9500;
}
.char-actions {
display: flex;
gap: 12rpx;
}
.char-actions button {
flex: 1;
}
.char-actions button.active {
background: #34c759;
color: #fff;
}
.write-section {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #eee;
}
.write-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.write-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.write-target {
font-size: 22rpx;
color: #666;
word-break: break-all;
}
.mode-switch {
display: flex;
margin-bottom: 12rpx;
border-radius: 8rpx;
overflow: hidden;
border: 1rpx solid #007aff;
}
.mode-btn {
flex: 1;
padding: 16rpx;
text-align: center;
font-size: 26rpx;
background: #fff;
color: #007aff;
}
.mode-btn.active {
background: #007aff;
color: #fff;
}
.write-input {
border: 1rpx solid #ddd;
border-radius: 8rpx;
padding: 16rpx;
font-size: 28rpx;
margin-bottom: 12rpx;
}
.write-section button {
margin-top: 12rpx;
}
.log-section {
min-height: 300rpx;
}
.clear-btn {
margin: 0;
}
.log-list {
height: 400rpx;
background: #1c1c1c;
border-radius: 8rpx;
padding: 16rpx;
}
.log-item {
display: block;
font-size: 22rpx;
font-family: 'Courier New', monospace;
color: #fff;
margin-bottom: 8rpx;
line-height: 1.5;
}
.log-item.success {
color: #34c759;
}
.log-item.warn {
color: #ff9500;
}
.log-item.error {
color: #ff3b30;
}
</style>