2115 lines
58 KiB
Vue
2115 lines
58 KiB
Vue
<template>
|
||
<view class="bluetooth-page">
|
||
<!-- 导航栏 -->
|
||
<uni-navbar title="搅拌器设备连接" :border="false" background-color="rgba(102, 126, 234, 0.9)" color="#fff" />
|
||
|
||
<view class="bluetooth-container">
|
||
|
||
<!-- 搜索状态和设置 -->
|
||
<view class="search-status">
|
||
<view class="status-row">
|
||
<view class="status-item">
|
||
<text class="status-label">蓝牙状态:</text>
|
||
<text :class="['status-value', bluetoothEnabled ? 'status-on' : 'status-off']">
|
||
{{ bluetoothEnabled ? '已开启' : '未开启' }}
|
||
</text>
|
||
</view>
|
||
<view class="status-item">
|
||
<text class="status-label">搜索状态:</text>
|
||
<text :class="['status-value', isSearching ? 'status-on' : 'status-off']">
|
||
{{ isSearching ? '搜索中...' : '未搜索' }}
|
||
</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 操作按钮 -->
|
||
<view class="action-buttons">
|
||
<u-button
|
||
type="primary"
|
||
:loading="isSearching"
|
||
@click="startBluetoothSearch"
|
||
:disabled="!bluetoothEnabled"
|
||
>
|
||
{{ isSearching ? '搜索中...' : '开始搜索' }}
|
||
</u-button>
|
||
<u-button
|
||
type="warning"
|
||
plain
|
||
@click="stopBluetoothSearch"
|
||
:disabled="!isSearching"
|
||
style="margin-left: 20rpx;"
|
||
>
|
||
停止搜索
|
||
</u-button>
|
||
</view>
|
||
|
||
|
||
<!-- 设备列表 -->
|
||
<view class="device-list">
|
||
<view class="list-header">
|
||
<text class="header-title">附近的设备</text>
|
||
<text class="device-count">({{ deviceList.length }})</text>
|
||
</view>
|
||
|
||
<view v-if="deviceList.length === 0" class="empty-state">
|
||
<view class="empty-icon">
|
||
<image
|
||
class="bluetooth-img"
|
||
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2MCIgaGVpZ2h0PSI2MCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSIjZmZmZmZmIj48cGF0aCBkPSJNMTcuNzEgNy43MUwxMiAyaC0xdjcuNTlMNi40MSA1IDUgNi40MSAxMC41OSAxMiA1IDE3LjU5IDYuNDEgMTkgMTEgMTQuNDFWMjJoMWw1LjcxLTUuNzEtNC4zLTQuMjkgNC4zLTQuMjl6TTEzIDUuODNsMS44OCAxLjg4TDEzIDkuNTlWNS44M3ptMS44OCAxMC40NkwxMyAxOC4xN3YtMy43NmwxLjg4IDEuODh6Ii8+PC9zdmc+"
|
||
mode="aspectFit"
|
||
/>
|
||
</view>
|
||
<text class="empty-text">暂无设备,点击上方按钮开始搜索</text>
|
||
</view>
|
||
|
||
<view v-else class="device-items">
|
||
<view
|
||
v-for="(device, index) in deviceList"
|
||
:key="device.deviceId"
|
||
class="device-wrapper"
|
||
>
|
||
<view
|
||
class="device-item"
|
||
@click="connectDevice(device)"
|
||
>
|
||
<view class="device-info">
|
||
<view class="device-name">
|
||
<view class="device-icon">
|
||
<image
|
||
class="bluetooth-img-small"
|
||
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSIjZmZmZmZmIj48cGF0aCBkPSJNMTcuNzEgNy43MUwxMiAyaC0xdjcuNTlMNi40MSA1IDUgNi40MSAxMC41OSAxMiA1IDE3LjU5IDYuNDEgMTkgMTEgMTQuNDFWMjJoMWw1LjcxLTUuNzEtNC4zLTQuMjkgNC4zLTQuMjl6TTEzIDUuODNsMS44OCAxLjg4TDEzIDkuNTlWNS44M3ptMS44OCAxMC40NkwxMyAxOC4xN3YtMy43NmwxLjg4IDEuODh6Ii8+PC9zdmc+"
|
||
mode="aspectFit"
|
||
/>
|
||
</view>
|
||
<text class="name-text">
|
||
{{ device.name || device.localName || `未命名设备 (${device.deviceId.slice(-8)})` }}
|
||
</text>
|
||
</view>
|
||
<view class="device-details">
|
||
<text class="detail-text">设备ID: {{ device.deviceId }}</text>
|
||
<text class="detail-text">信号强度: {{ device.RSSI }} dBm</text>
|
||
</view>
|
||
</view>
|
||
<view class="device-action">
|
||
<view v-if="device.deviceId === connectedDeviceId" class="connected-badge">
|
||
<text class="badge-text">已连接</text>
|
||
</view>
|
||
<view v-else-if="device.deviceId === connectingDeviceId" class="connecting-badge">
|
||
<text class="connecting-text">连接中...</text>
|
||
</view>
|
||
<u-button
|
||
v-else
|
||
type="primary"
|
||
size="small"
|
||
@click.stop="connectDevice(device)"
|
||
>
|
||
连接
|
||
</u-button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 已连接设备的详细信息,显示在对应设备下方 -->
|
||
<view v-if="device.deviceId === connectedDeviceId && connectedDevice" class="device-connected-detail">
|
||
<view class="detail-header">
|
||
<text class="detail-title">连接成功,准备跳转...</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||
|
||
// 状态变量
|
||
const bluetoothEnabled = ref(false)
|
||
const isSearching = ref(false)
|
||
const deviceList = ref([])
|
||
const connectedDevice = ref(null)
|
||
const connectedDeviceId = ref('')
|
||
const connectingDeviceId = ref('')
|
||
// 新增:服务和特征值相关
|
||
const services = ref([]) // 设备服务列表
|
||
const characteristics = ref([]) // 当前服务的特征值列表
|
||
const notifyCharacteristics = ref([]) // 可监听的特征值
|
||
const totalCharacteristicsCount = ref(0)
|
||
|
||
// 监听器标记 - 防止重复注册
|
||
let hasRegisteredCharacteristicListener = false
|
||
// 权限被拒绝提示标记 - 防止重复弹窗
|
||
let hasShownPermissionDenied = false
|
||
// 自动重连标记 - 防止重复尝试
|
||
let hasTriedAutoReconnect = false
|
||
// 本地存储key
|
||
const LAST_DEVICE_KEY = 'last_connected_bluetooth_device'
|
||
// 数据交互相关
|
||
const inputData = ref('') // 输入框数据
|
||
const sendHistory = ref([]) // 发送历史记录
|
||
const receiveHistory = ref([]) // 接收历史记录
|
||
const showHistory = ref(false) // 是否显示历史记录
|
||
const readStats = ref(null) // 读取统计信息 { total, success, fail }
|
||
|
||
// 计算合并的历史记录(用于显示)
|
||
const dataHistory = computed(() => {
|
||
return [...sendHistory.value, ...receiveHistory.value]
|
||
.sort((a, b) => b.timestamp - a.timestamp) // 按时间倒序排列
|
||
})
|
||
|
||
// 计算发送按钮是否禁用
|
||
const isSendDisabled = computed(() => {
|
||
const disabled = !inputData.value || inputData.value.trim() === ''
|
||
console.log('发送按钮状态:', {
|
||
inputData: inputData.value,
|
||
length: inputData.value?.length,
|
||
trimmed: inputData.value?.trim(),
|
||
disabled
|
||
})
|
||
return disabled
|
||
})
|
||
|
||
// 初始化蓝牙适配器
|
||
const initBluetooth = () => {
|
||
// 先检查和请求权限
|
||
checkAndRequestPermissions()
|
||
}
|
||
|
||
// 检查和请求蓝牙相关权限
|
||
const checkAndRequestPermissions = () => {
|
||
// #ifdef APP-PLUS
|
||
const permissions = []
|
||
|
||
// 获取 Android 版本
|
||
const platform = uni.getSystemInfoSync().platform
|
||
const system = uni.getSystemInfoSync().system
|
||
|
||
if (platform === 'android') {
|
||
// 解析 Android 版本号
|
||
const androidVersion = parseInt(system.replace(/[^0-9]/ig, ''))
|
||
|
||
if (androidVersion >= 12) {
|
||
// Android 12+ 需要新的蓝牙权限
|
||
permissions.push('android.permission.BLUETOOTH_SCAN')
|
||
permissions.push('android.permission.BLUETOOTH_CONNECT')
|
||
permissions.push('android.permission.BLUETOOTH_ADVERTISE')
|
||
} else {
|
||
// Android 6-11 需要位置权限
|
||
permissions.push('android.permission.ACCESS_FINE_LOCATION')
|
||
permissions.push('android.permission.ACCESS_COARSE_LOCATION')
|
||
}
|
||
|
||
// 先检查权限状态,避免重复提示
|
||
const checkPermissions = permissions.map(permission => {
|
||
return plus.android.checkPermission(permission)
|
||
})
|
||
|
||
// 如果所有权限都已被永久拒绝(返回-1表示被永久拒绝),跳过请求
|
||
const allDeniedAlways = checkPermissions.every(status => status === -1)
|
||
if (allDeniedAlways) {
|
||
console.log('蓝牙权限已被永久拒绝,跳过请求')
|
||
// 不弹窗,静默失败,用户点击搜索时会提示
|
||
return
|
||
}
|
||
|
||
// 如果所有权限都已授予(返回0表示已授予)
|
||
const allGranted = checkPermissions.every(status => status === 0)
|
||
if (allGranted) {
|
||
console.log('蓝牙权限已授予')
|
||
openBluetoothAdapter()
|
||
return
|
||
}
|
||
|
||
// 请求权限
|
||
plus.android.requestPermissions(permissions,
|
||
(resultObj) => {
|
||
const granted = resultObj.granted
|
||
const deniedAlways = resultObj.deniedAlways
|
||
const deniedPresent = resultObj.deniedPresent
|
||
|
||
if (granted.length > 0) {
|
||
// 权限获取成功,初始化蓝牙
|
||
openBluetoothAdapter()
|
||
} else if (deniedPresent.length > 0) {
|
||
// 本次拒绝(但没有选择不再询问),不弹窗,静默失败
|
||
console.log('用户本次拒绝了权限,可稍后点击"开始搜索"时再次请求')
|
||
} else if (deniedAlways.length > 0) {
|
||
// 永久拒绝(选择了不再询问),也不弹窗
|
||
// 因为用户可能只是暂时不想授权,不应该每次进入都骚扰用户
|
||
console.log('用户永久拒绝了权限,可在需要时引导用户去设置')
|
||
}
|
||
},
|
||
(error) => {
|
||
console.error('权限请求失败', error)
|
||
}
|
||
)
|
||
} else {
|
||
// iOS 直接初始化
|
||
openBluetoothAdapter()
|
||
}
|
||
// #endif
|
||
|
||
// #ifndef APP-PLUS
|
||
openBluetoothAdapter()
|
||
// #endif
|
||
}
|
||
|
||
// 打开蓝牙适配器
|
||
const openBluetoothAdapter = () => {
|
||
uni.openBluetoothAdapter({
|
||
success: (res) => {
|
||
console.log('蓝牙适配器初始化成功', res)
|
||
bluetoothEnabled.value = true
|
||
uni.showToast({
|
||
title: '蓝牙已开启',
|
||
icon: 'success'
|
||
})
|
||
|
||
// 初始化成功后尝试自动重连上次设备
|
||
setTimeout(() => {
|
||
tryAutoReconnectLastDevice()
|
||
}, 500)
|
||
},
|
||
fail: (err) => {
|
||
console.error('蓝牙适配器初始化失败', err)
|
||
bluetoothEnabled.value = false
|
||
|
||
// 只在特定错误码时才提示,避免重复提示权限问题
|
||
if (err.errCode === 10001) {
|
||
// 蓝牙未开启
|
||
uni.showModal({
|
||
title: '提示',
|
||
content: '请在手机设置中开启蓝牙',
|
||
showCancel: false
|
||
})
|
||
} else if (err.errCode === -1) {
|
||
// 初始化失败,但不弹窗(可能是权限问题,已在权限检查时处理)
|
||
console.log('蓝牙初始化失败,可能是权限未授予')
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 检查位置服务(GPS)是否开启
|
||
const checkLocationService = () => {
|
||
return new Promise((resolve, reject) => {
|
||
// #ifdef APP-PLUS
|
||
const platform = uni.getSystemInfoSync().platform
|
||
const system = uni.getSystemInfoSync().system
|
||
|
||
if (platform === 'android') {
|
||
const androidVersion = parseInt(system.replace(/[^0-9]/ig, ''))
|
||
|
||
// Android 12+ 不需要检查GPS
|
||
if (androidVersion >= 12) {
|
||
resolve(true)
|
||
return
|
||
}
|
||
|
||
// Android 10-11 必须开启GPS,否则搜索失败
|
||
// 使用 plus.android 检查位置服务状态
|
||
try {
|
||
const main = plus.android.runtimeMainActivity()
|
||
const Context = plus.android.importClass('android.content.Context')
|
||
const LocationManager = plus.android.importClass('android.location.LocationManager')
|
||
|
||
const locationManager = main.getSystemService(Context.LOCATION_SERVICE)
|
||
const isGpsEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
|
||
const isNetworkEnabled = locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||
|
||
const isLocationEnabled = isGpsEnabled || isNetworkEnabled
|
||
|
||
if (!isLocationEnabled) {
|
||
reject(false)
|
||
} else {
|
||
resolve(true)
|
||
}
|
||
} catch (e) {
|
||
console.error('检查GPS状态失败:', e)
|
||
// 检查失败时假设已开启
|
||
resolve(true)
|
||
}
|
||
} else {
|
||
// iOS 不需要检查
|
||
resolve(true)
|
||
}
|
||
// #endif
|
||
|
||
// #ifndef APP-PLUS
|
||
resolve(true)
|
||
// #endif
|
||
})
|
||
}
|
||
|
||
// 引导用户开启GPS
|
||
const guideToEnableGPS = () => {
|
||
const platform = uni.getSystemInfoSync().platform
|
||
const system = uni.getSystemInfoSync().system
|
||
const androidVersion = parseInt(system.replace(/[^0-9]/ig, ''))
|
||
|
||
let content = ''
|
||
|
||
if (androidVersion >= 12) {
|
||
content = '检测到您使用的是 Android 12+,不需要开启GPS即可使用蓝牙功能。'
|
||
} else if (androidVersion >= 10) {
|
||
content = `检测到您使用的是 Android ${androidVersion},系统要求必须开启位置服务(GPS)才能搜索蓝牙设备。
|
||
|
||
这是 Android 系统的强制要求,即使不会真正定位您的位置。
|
||
|
||
是否前往设置开启?`
|
||
} else {
|
||
content = '建议开启位置服务(GPS)以确保蓝牙搜索功能正常使用。\n\n是否前往设置开启?'
|
||
}
|
||
|
||
uni.showModal({
|
||
title: androidVersion >= 12 ? '提示' : '需要开启GPS',
|
||
content: content,
|
||
showCancel: androidVersion < 12,
|
||
confirmText: androidVersion >= 12 ? '知道了' : '去设置',
|
||
success: (res) => {
|
||
if (res.confirm && androidVersion < 12) {
|
||
// 跳转到位置设置页面
|
||
// #ifdef APP-PLUS
|
||
try {
|
||
const Intent = plus.android.importClass('android.content.Intent')
|
||
const Settings = plus.android.importClass('android.provider.Settings')
|
||
const intent = new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
|
||
const main = plus.android.runtimeMainActivity()
|
||
main.startActivity(intent)
|
||
} catch (e) {
|
||
console.error('跳转设置失败:', e)
|
||
uni.showToast({
|
||
title: '请手动前往设置开启GPS',
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
}
|
||
// #endif
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 保存上次连接成功的设备信息
|
||
const saveLastDevice = (device) => {
|
||
try {
|
||
const deviceInfo = {
|
||
deviceId: device.deviceId,
|
||
name: device.name || device.localName || '未命名设备',
|
||
localName: device.localName,
|
||
RSSI: device.RSSI
|
||
}
|
||
uni.setStorageSync(LAST_DEVICE_KEY, JSON.stringify(deviceInfo))
|
||
console.log('已保存上次连接的设备:', deviceInfo)
|
||
} catch (e) {
|
||
console.error('保存设备信息失败:', e)
|
||
}
|
||
}
|
||
|
||
// 获取上次连接的设备信息
|
||
const getLastDevice = () => {
|
||
try {
|
||
const deviceStr = uni.getStorageSync(LAST_DEVICE_KEY)
|
||
if (deviceStr) {
|
||
const device = JSON.parse(deviceStr)
|
||
console.log('读取到上次连接的设备:', device)
|
||
return device
|
||
}
|
||
} catch (e) {
|
||
console.error('读取设备信息失败:', e)
|
||
}
|
||
return null
|
||
}
|
||
|
||
// 尝试自动重连上次的设备
|
||
const tryAutoReconnectLastDevice = async () => {
|
||
// 防止重复尝试
|
||
if (hasTriedAutoReconnect) {
|
||
console.log('已经尝试过自动重连,跳过')
|
||
startBluetoothSearch()
|
||
return
|
||
}
|
||
|
||
hasTriedAutoReconnect = true
|
||
|
||
// 获取上次连接的设备
|
||
const lastDevice = getLastDevice()
|
||
if (!lastDevice) {
|
||
console.log('没有上次连接的设备记录,开始正常搜索')
|
||
startBluetoothSearch()
|
||
return
|
||
}
|
||
|
||
console.log('尝试自动重连上次的设备:', lastDevice.name)
|
||
|
||
uni.showLoading({
|
||
title: `正在连接 ${lastDevice.name}...`,
|
||
mask: true
|
||
})
|
||
|
||
// 检查GPS是否开启(Android 10-11 必需)
|
||
try {
|
||
await checkLocationService()
|
||
} catch (e) {
|
||
uni.hideLoading()
|
||
console.log('GPS未开启,无法自动重连')
|
||
guideToEnableGPS()
|
||
return
|
||
}
|
||
|
||
// 开始搜索设备
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
uni.startBluetoothDevicesDiscovery({
|
||
allowDuplicatesKey: false,
|
||
success: resolve,
|
||
fail: reject
|
||
})
|
||
})
|
||
|
||
console.log('开始搜索上次连接的设备...')
|
||
|
||
// 等待3秒搜索设备
|
||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||
|
||
// 获取搜索到的设备列表
|
||
const devices = await new Promise((resolve, reject) => {
|
||
uni.getBluetoothDevices({
|
||
success: (res) => resolve(res.devices),
|
||
fail: reject
|
||
})
|
||
})
|
||
|
||
// 查找上次连接的设备
|
||
const targetDevice = devices.find(d => d.deviceId === lastDevice.deviceId)
|
||
|
||
if (targetDevice) {
|
||
console.log('找到上次连接的设备,尝试连接:', targetDevice)
|
||
|
||
// 尝试连接
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
uni.createBLEConnection({
|
||
deviceId: targetDevice.deviceId,
|
||
timeout: 8000,
|
||
success: resolve,
|
||
fail: reject
|
||
})
|
||
})
|
||
|
||
// 连接成功
|
||
console.log('自动重连成功')
|
||
connectingDeviceId.value = ''
|
||
connectedDeviceId.value = targetDevice.deviceId
|
||
connectedDevice.value = targetDevice
|
||
|
||
// 停止搜索
|
||
uni.stopBluetoothDevicesDiscovery()
|
||
|
||
// 获取蓝牙设备服务
|
||
const bleInfo = await getBLEDeviceServices(targetDevice.deviceId)
|
||
|
||
uni.hideLoading()
|
||
|
||
uni.showToast({
|
||
title: '自动连接成功',
|
||
icon: 'success',
|
||
duration: 1500
|
||
})
|
||
|
||
// 自动跳转到控制页面
|
||
setTimeout(() => {
|
||
const url = buildMixerPageUrl(targetDevice, bleInfo)
|
||
console.log('🚀 跳转到搅拌器页面:', url)
|
||
uni.navigateTo({ url })
|
||
}, 1500)
|
||
|
||
} catch (connectErr) {
|
||
console.error('自动重连失败:', connectErr)
|
||
uni.hideLoading()
|
||
|
||
uni.showToast({
|
||
title: '自动连接失败,请手动选择',
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
|
||
// 连接失败,继续正常搜索流程
|
||
setTimeout(() => {
|
||
// 设备列表已经有了,直接显示
|
||
isSearching.value = true
|
||
getBluetoothDevices()
|
||
}, 2000)
|
||
}
|
||
} else {
|
||
console.log('未找到上次连接的设备,继续正常搜索')
|
||
uni.hideLoading()
|
||
|
||
uni.showToast({
|
||
title: '未找到上次设备',
|
||
icon: 'none',
|
||
duration: 1500
|
||
})
|
||
|
||
// 继续正常搜索
|
||
setTimeout(() => {
|
||
isSearching.value = true
|
||
getBluetoothDevices()
|
||
}, 1500)
|
||
}
|
||
|
||
} catch (err) {
|
||
console.error('搜索设备失败:', err)
|
||
uni.hideLoading()
|
||
|
||
uni.showToast({
|
||
title: '搜索失败,请手动操作',
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
|
||
// 搜索失败,开始正常搜索
|
||
setTimeout(() => {
|
||
startBluetoothSearch()
|
||
}, 2000)
|
||
}
|
||
}
|
||
|
||
// 开始搜索蓝牙设备
|
||
const startBluetoothSearch = async () => {
|
||
if (!bluetoothEnabled.value) {
|
||
uni.showToast({
|
||
title: '请先开启蓝牙',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
// 检查GPS是否开启(Android 10-11 必需)
|
||
try {
|
||
await checkLocationService()
|
||
} catch (e) {
|
||
// GPS未开启,引导用户开启
|
||
guideToEnableGPS()
|
||
return
|
||
}
|
||
|
||
// 清空设备列表
|
||
deviceList.value = []
|
||
isSearching.value = true
|
||
|
||
// 开始搜寻附近的蓝牙外围设备
|
||
uni.startBluetoothDevicesDiscovery({
|
||
allowDuplicatesKey: false,
|
||
success: (res) => {
|
||
console.log('开始搜索蓝牙设备', res)
|
||
uni.showToast({
|
||
title: '开始搜索设备',
|
||
icon: 'success'
|
||
})
|
||
// 监听设备发现
|
||
getBluetoothDevices()
|
||
},
|
||
fail: (err) => {
|
||
console.error('搜索蓝牙设备失败', err)
|
||
isSearching.value = false
|
||
|
||
// 根据错误码提供详细提示
|
||
let errorMsg = '搜索失败,请重试'
|
||
|
||
if (err.errCode === 10001) {
|
||
errorMsg = '搜索失败:未开启蓝牙适配器'
|
||
} else if (err.errCode === 10002) {
|
||
errorMsg = '搜索失败:未找到蓝牙设备'
|
||
} else if (err.errCode === 10004) {
|
||
errorMsg = '搜索失败:请检查是否已开启位置服务(GPS)'
|
||
// 可能是GPS未开启导致的
|
||
guideToEnableGPS()
|
||
return
|
||
}
|
||
|
||
uni.showToast({
|
||
title: errorMsg,
|
||
icon: 'none',
|
||
duration: 2500
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// 停止搜索蓝牙设备
|
||
const stopBluetoothSearch = () => {
|
||
uni.stopBluetoothDevicesDiscovery({
|
||
success: (res) => {
|
||
console.log('停止搜索蓝牙设备', res)
|
||
isSearching.value = false
|
||
uni.showToast({
|
||
title: '已停止搜索',
|
||
icon: 'success'
|
||
})
|
||
},
|
||
fail: (err) => {
|
||
console.error('停止搜索失败', err)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 获取已发现的蓝牙设备
|
||
const getBluetoothDevices = () => {
|
||
uni.getBluetoothDevices({
|
||
success: (res) => {
|
||
console.log('获取到的蓝牙设备', res.devices)
|
||
// 显示所有设备,包括未命名设备
|
||
deviceList.value = res.devices
|
||
},
|
||
fail: (err) => {
|
||
console.error('获取设备列表失败', err)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 监听新设备发现
|
||
const onBluetoothDeviceFound = () => {
|
||
uni.onBluetoothDeviceFound((res) => {
|
||
const devices = res.devices
|
||
devices.forEach(device => {
|
||
// 检查设备是否已存在
|
||
const existIndex = deviceList.value.findIndex(d => d.deviceId === device.deviceId)
|
||
if (existIndex === -1) {
|
||
// 新设备,直接添加
|
||
deviceList.value.push(device)
|
||
} else {
|
||
// 更新已存在设备的信息
|
||
deviceList.value[existIndex] = device
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
// 连接蓝牙设备
|
||
const connectDevice = (device) => {
|
||
// 如果正在连接中,不允许再次连接
|
||
if (connectingDeviceId.value) {
|
||
uni.showToast({
|
||
title: '正在连接中,请稍候',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
// 如果已经连接了同一个设备,不需要重复连接
|
||
if (connectedDeviceId.value === device.deviceId) {
|
||
uni.showToast({
|
||
title: '该设备已连接',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
// 如果已经连接了其他设备,先提示用户
|
||
if (connectedDeviceId.value && connectedDeviceId.value !== device.deviceId) {
|
||
uni.showModal({
|
||
title: '提示',
|
||
content: '已连接其他设备,是否断开并连接新设备?',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
// 先断开当前连接
|
||
uni.closeBLEConnection({
|
||
deviceId: connectedDeviceId.value,
|
||
complete: () => {
|
||
// 无论断开成功与否,都清空状态并尝试连接新设备
|
||
connectedDeviceId.value = ''
|
||
connectedDevice.value = null
|
||
// 连接新设备
|
||
doConnect(device)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
})
|
||
return
|
||
}
|
||
|
||
// 直接连接
|
||
doConnect(device)
|
||
}
|
||
|
||
// 执行连接操作
|
||
const doConnect = (device) => {
|
||
connectingDeviceId.value = device.deviceId
|
||
|
||
console.log('开始连接设备:', device)
|
||
|
||
uni.createBLEConnection({
|
||
deviceId: device.deviceId,
|
||
timeout: 10000, // 设置10秒超时
|
||
success: async (res) => {
|
||
console.log('连接成功', res)
|
||
connectingDeviceId.value = ''
|
||
connectedDeviceId.value = device.deviceId
|
||
connectedDevice.value = device
|
||
|
||
uni.showToast({
|
||
title: '连接成功',
|
||
icon: 'success'
|
||
})
|
||
|
||
// 停止搜索
|
||
stopBluetoothSearch()
|
||
|
||
// 保存上次连接的设备信息
|
||
saveLastDevice(device)
|
||
|
||
// 获取蓝牙设备服务
|
||
const bleInfo = await getBLEDeviceServices(device.deviceId)
|
||
|
||
// 连接成功后跳转到控制页面
|
||
setTimeout(() => {
|
||
const url = buildMixerPageUrl(device, bleInfo)
|
||
console.log('🚀 跳转到搅拌器页面:', url)
|
||
uni.navigateTo({ url })
|
||
}, 800)
|
||
},
|
||
fail: (err) => {
|
||
console.error('连接失败', err)
|
||
connectingDeviceId.value = ''
|
||
|
||
// 根据错误码提供更详细的错误信息
|
||
let errorMsg = '无法连接到该设备'
|
||
|
||
if (err.errCode === 10003) {
|
||
errorMsg = '连接失败:设备未找到或已关闭'
|
||
} else if (err.errCode === 10012) {
|
||
errorMsg = '连接失败:连接超时,设备可能距离太远'
|
||
} else if (err.errCode === 10004) {
|
||
errorMsg = '连接失败:设备不支持连接'
|
||
} else if (err.errCode === -1) {
|
||
errorMsg = '连接失败:系统错误,请重启蓝牙后重试'
|
||
} else if (err.errMsg) {
|
||
errorMsg = `连接失败:${err.errMsg}`
|
||
}
|
||
|
||
uni.showModal({
|
||
title: '连接失败',
|
||
content: `${errorMsg}
|
||
|
||
错误码: ${err.errCode || '未知'}
|
||
|
||
提示:
|
||
1. 确保设备在可连接范围内
|
||
2. 确保设备未被其他应用连接
|
||
3. 尝试重启设备蓝牙`,
|
||
showCancel: false,
|
||
confirmText: '知道了'
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// 断开蓝牙连接
|
||
const disconnectDevice = () => {
|
||
if (!connectedDeviceId.value) {
|
||
uni.showToast({
|
||
title: '没有已连接的设备',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
const deviceId = connectedDeviceId.value
|
||
|
||
// 关闭notify监听
|
||
if (notifyCharacteristics.value.length > 0) {
|
||
notifyCharacteristics.value.forEach(char => {
|
||
try {
|
||
uni.notifyBLECharacteristicValueChange({
|
||
deviceId: deviceId,
|
||
serviceId: char.serviceId,
|
||
characteristicId: char.uuid,
|
||
state: false,
|
||
success: () => {
|
||
console.log('关闭notify成功:', char.uuid)
|
||
},
|
||
fail: (err) => {
|
||
console.error('关闭notify失败:', char.uuid, err)
|
||
}
|
||
})
|
||
} catch (err) {
|
||
console.error('关闭notify异常:', err)
|
||
}
|
||
})
|
||
}
|
||
|
||
uni.closeBLEConnection({
|
||
deviceId: deviceId,
|
||
success: (res) => {
|
||
console.log('断开连接成功', res)
|
||
// 清空连接状态和服务信息
|
||
cleanupConnection()
|
||
uni.showToast({
|
||
title: '已断开连接',
|
||
icon: 'success'
|
||
})
|
||
},
|
||
fail: (err) => {
|
||
console.error('断开连接失败', err)
|
||
// 即使断开失败,也清空状态(可能设备已经离线)
|
||
cleanupConnection()
|
||
uni.showToast({
|
||
title: '断开连接',
|
||
icon: 'success'
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// 清理连接状态
|
||
const cleanupConnection = () => {
|
||
connectedDeviceId.value = ''
|
||
connectedDevice.value = null
|
||
services.value = []
|
||
characteristics.value = []
|
||
notifyCharacteristics.value = []
|
||
totalCharacteristicsCount.value = 0
|
||
// 断开连接时不清空历史记录,保留数据
|
||
}
|
||
|
||
// 获取蓝牙设备服务
|
||
const getBLEDeviceServices = async (deviceId) => {
|
||
try {
|
||
const res = await new Promise((resolve, reject) => {
|
||
uni.getBLEDeviceServices({
|
||
deviceId: deviceId,
|
||
success: resolve,
|
||
fail: reject
|
||
})
|
||
})
|
||
|
||
console.log('获取到的服务列表', res.services)
|
||
services.value = res.services
|
||
|
||
// 计算所有服务的特征值总数
|
||
let totalCount = 0
|
||
let writeCharacteristicId = '' // 可写特征值
|
||
let notifyCharacteristicId = '' // 可监听特征值
|
||
let mainServiceId = '' // 主服务ID
|
||
|
||
// 自动获取第一个服务的特征值(用于展示)
|
||
if (res.services.length > 0) {
|
||
mainServiceId = res.services[0].uuid
|
||
const chars = await getBLEDeviceCharacteristicsSync(deviceId, mainServiceId)
|
||
characteristics.value = chars
|
||
|
||
// 遍历所有服务统计特征值总数
|
||
for (const service of res.services) {
|
||
try {
|
||
const serviceChars = await getBLEDeviceCharacteristicsSync(deviceId, service.uuid)
|
||
totalCount += serviceChars.length
|
||
|
||
// 找出可以监听的特征值
|
||
serviceChars.forEach(characteristic => {
|
||
// 查找可写特征值(优先选择 write)
|
||
if (characteristic.properties.write && !writeCharacteristicId) {
|
||
writeCharacteristicId = characteristic.uuid
|
||
if (!mainServiceId) mainServiceId = service.uuid
|
||
console.log('找到可写特征值(write):', writeCharacteristicId)
|
||
} else if (characteristic.properties.writeNoResponse && !writeCharacteristicId) {
|
||
writeCharacteristicId = characteristic.uuid
|
||
if (!mainServiceId) mainServiceId = service.uuid
|
||
console.log('找到可写特征值(writeNoResponse):', writeCharacteristicId)
|
||
}
|
||
|
||
// 查找可监听特征值
|
||
if (characteristic.properties.notify || characteristic.properties.indicate) {
|
||
if (!notifyCharacteristicId) {
|
||
notifyCharacteristicId = characteristic.uuid
|
||
console.log('找到可监听特征值:', notifyCharacteristicId)
|
||
}
|
||
|
||
notifyCharacteristics.value.push({
|
||
...characteristic,
|
||
serviceId: service.uuid
|
||
})
|
||
enableNotify(deviceId, service.uuid, characteristic.uuid)
|
||
}
|
||
})
|
||
} catch (err) {
|
||
console.error(`获取服务 ${service.uuid} 的特征值失败`, err)
|
||
}
|
||
}
|
||
|
||
totalCharacteristicsCount.value = totalCount
|
||
console.log(`总共 ${totalCount} 个特征值`)
|
||
}
|
||
|
||
// 返回蓝牙信息(用于跳转)
|
||
return {
|
||
serviceId: mainServiceId,
|
||
characteristicId: writeCharacteristicId,
|
||
notifyCharacteristicId: notifyCharacteristicId
|
||
}
|
||
} catch (err) {
|
||
console.error('获取服务列表失败', err)
|
||
return {
|
||
serviceId: '',
|
||
characteristicId: '',
|
||
notifyCharacteristicId: ''
|
||
}
|
||
}
|
||
}
|
||
|
||
// 同步获取特征值(不启用notify)
|
||
const getBLEDeviceCharacteristicsSync = (deviceId, serviceId) => {
|
||
return new Promise((resolve, reject) => {
|
||
uni.getBLEDeviceCharacteristics({
|
||
deviceId: deviceId,
|
||
serviceId: serviceId,
|
||
success: (res) => {
|
||
resolve(res.characteristics)
|
||
},
|
||
fail: reject
|
||
})
|
||
})
|
||
}
|
||
|
||
// 预览控制页面(模拟设备数据)
|
||
const previewMixerPage = () => {
|
||
const fakeDevice = {
|
||
deviceId: 'preview-fake-id',
|
||
name: '预览测试设备',
|
||
localName: 'Preview Device',
|
||
RSSI: -50
|
||
}
|
||
|
||
const fakeBleInfo = {
|
||
serviceId: 'fake-service-id',
|
||
characteristicId: 'fake-char-id',
|
||
notifyCharacteristicId: 'fake-notify-id'
|
||
}
|
||
|
||
const url = buildMixerPageUrl(fakeDevice, fakeBleInfo)
|
||
console.log('🎨 预览跳转:', url)
|
||
uni.navigateTo({ url })
|
||
}
|
||
|
||
// 构造跳转到 mixer 页面的 URL(带蓝牙信息)
|
||
const buildMixerPageUrl = (device, bleInfo) => {
|
||
const params = {
|
||
deviceId: device.deviceId,
|
||
deviceName: encodeURIComponent(device.name || device.localName || '未命名设备')
|
||
}
|
||
|
||
// 添加蓝牙服务和特征值信息
|
||
if (bleInfo && bleInfo.serviceId) {
|
||
params.serviceId = bleInfo.serviceId
|
||
}
|
||
if (bleInfo && bleInfo.characteristicId) {
|
||
params.characteristicId = bleInfo.characteristicId
|
||
}
|
||
if (bleInfo && bleInfo.notifyCharacteristicId) {
|
||
params.notifyCharacteristicId = bleInfo.notifyCharacteristicId
|
||
}
|
||
|
||
// 拼接 URL
|
||
const queryString = Object.keys(params)
|
||
.map(key => `${key}=${params[key]}`)
|
||
.join('&')
|
||
|
||
return `/pages/mixer/mixer?${queryString}`
|
||
}
|
||
|
||
// 启用特征值变化监听
|
||
const enableNotify = (deviceId, serviceId, characteristicId) => {
|
||
// 第一次启用notify时注册监听器
|
||
if (!hasRegisteredCharacteristicListener) {
|
||
onBLECharacteristicValueChange()
|
||
hasRegisteredCharacteristicListener = true
|
||
}
|
||
|
||
uni.notifyBLECharacteristicValueChange({
|
||
deviceId: deviceId,
|
||
serviceId: serviceId,
|
||
characteristicId: characteristicId,
|
||
state: true,
|
||
success: (res) => {
|
||
console.log('启用notify成功', characteristicId)
|
||
},
|
||
fail: (err) => {
|
||
console.error('启用notify失败', err)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 监听特征值变化(接收数据)
|
||
const onBLECharacteristicValueChange = () => {
|
||
uni.onBLECharacteristicValueChange((res) => {
|
||
console.log('收到蓝牙数据:', res)
|
||
console.log('特征值UUID:', res.characteristicId)
|
||
|
||
// 将 ArrayBuffer 转换为16进制字符串
|
||
const hexString = ab2hex(res.value)
|
||
console.log('16进制数据:', hexString)
|
||
|
||
// 尝试转换为文本数据
|
||
const textData = ab2str(res.value)
|
||
console.log('文本数据:', textData)
|
||
|
||
// 显示数据的字节长度
|
||
console.log('数据长度:', res.value.byteLength, '字节')
|
||
|
||
// 优先显示可读的文本,如果文本不可读则显示16进制
|
||
const displayData = textData && textData.trim() ? textData : hexString
|
||
|
||
// 添加到历史记录(包含UUID)
|
||
addToHistory('receive', displayData, hexString, res.characteristicId)
|
||
|
||
// 显示接收到的数据
|
||
uni.showToast({
|
||
title: `收到数据: ${displayData.slice(0, 20)}${displayData.length > 20 ? '...' : ''}`,
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
})
|
||
}
|
||
|
||
// 向蓝牙设备写入数据 - 遍历所有服务查找可写特征值
|
||
const writeBLEData = async (data) => {
|
||
if (!connectedDeviceId.value) {
|
||
uni.showToast({
|
||
title: '请先连接设备',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
if (!services.value || services.value.length === 0) {
|
||
uni.showToast({
|
||
title: '设备服务未就绪,请稍后重试',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
uni.showLoading({
|
||
title: '正在发送数据...',
|
||
mask: true
|
||
})
|
||
|
||
try {
|
||
let writeCharacteristic = null
|
||
let writeServiceId = null
|
||
|
||
// 遍历所有服务,查找可写的特征值
|
||
for (const service of services.value) {
|
||
try {
|
||
const chars = await getBLEServiceCharacteristics(connectedDeviceId.value, service.uuid)
|
||
|
||
// 优先查找支持 write 的特征值
|
||
const writableChar = chars.find(c => c.properties.write)
|
||
if (writableChar) {
|
||
writeCharacteristic = writableChar
|
||
writeServiceId = service.uuid
|
||
console.log('找到可写特征值(write):', writableChar.uuid, '在服务:', service.uuid)
|
||
break
|
||
}
|
||
|
||
// 如果没有 write,查找 writeNoResponse
|
||
const writeNoResponseChar = chars.find(c => c.properties.writeNoResponse)
|
||
if (writeNoResponseChar && !writeCharacteristic) {
|
||
writeCharacteristic = writeNoResponseChar
|
||
writeServiceId = service.uuid
|
||
console.log('找到可写特征值(writeNoResponse):', writeNoResponseChar.uuid, '在服务:', service.uuid)
|
||
}
|
||
} catch (err) {
|
||
console.error('获取服务特征值失败:', service.uuid, err)
|
||
}
|
||
}
|
||
|
||
uni.hideLoading()
|
||
|
||
if (!writeCharacteristic) {
|
||
uni.showModal({
|
||
title: '无法发送',
|
||
content: '该设备没有可写入的特征值\n\n可能原因:\n1. 设备不支持写入操作\n2. 设备固件限制\n\n建议:查看设备文档或联系设备厂商',
|
||
showCancel: false
|
||
})
|
||
return
|
||
}
|
||
|
||
console.log('准备写入数据:', data)
|
||
console.log('写入特征值:', writeCharacteristic.uuid)
|
||
console.log('所属服务:', writeServiceId)
|
||
|
||
// 将字符串转换为 ArrayBuffer
|
||
const buffer = str2ab(data)
|
||
console.log('转换后的buffer长度:', buffer.byteLength)
|
||
console.log('Buffer内容(HEX):', ab2hex(buffer))
|
||
|
||
// 检查数据长度(蓝牙一般限制在20字节以内)
|
||
if (buffer.byteLength > 20) {
|
||
uni.showModal({
|
||
title: '数据过长',
|
||
content: `数据长度${buffer.byteLength}字节,超过蓝牙限制(20字节)\n\n是否分包发送?`,
|
||
success: async (res) => {
|
||
if (res.confirm) {
|
||
await sendDataInChunks(data, writeServiceId, writeCharacteristic.uuid)
|
||
}
|
||
}
|
||
})
|
||
return
|
||
}
|
||
|
||
uni.writeBLECharacteristicValue({
|
||
deviceId: connectedDeviceId.value,
|
||
serviceId: writeServiceId,
|
||
characteristicId: writeCharacteristic.uuid,
|
||
value: buffer,
|
||
success: (res) => {
|
||
console.log('写入数据成功', res)
|
||
|
||
// 添加到历史记录
|
||
const hexString = ab2hex(buffer)
|
||
addToHistory('send', data, hexString)
|
||
|
||
uni.showToast({
|
||
title: `发送成功: ${data}`,
|
||
icon: 'success',
|
||
duration: 2000
|
||
})
|
||
},
|
||
fail: (err) => {
|
||
console.error('写入数据失败', err)
|
||
|
||
let errorMsg = '发送失败'
|
||
let errorDetail = ''
|
||
|
||
if (err.errCode === 10008) {
|
||
errorMsg = '发送失败:数据格式错误'
|
||
errorDetail = '请检查输入的数据格式'
|
||
} else if (err.errCode === 10007) {
|
||
errorMsg = '发送失败:特征值不支持写入'
|
||
errorDetail = '该特征值不支持写入操作'
|
||
} else if (err.errCode === 10006) {
|
||
errorMsg = '发送失败:设备连接已断开'
|
||
errorDetail = '请重新连接设备'
|
||
} else if (err.errMsg) {
|
||
errorMsg = '发送失败'
|
||
errorDetail = err.errMsg
|
||
}
|
||
|
||
uni.showModal({
|
||
title: errorMsg,
|
||
content: errorDetail + `\n\n错误码: ${err.errCode || '未知'}`,
|
||
showCancel: false
|
||
})
|
||
}
|
||
})
|
||
} catch (err) {
|
||
uni.hideLoading()
|
||
console.error('发送数据异常', err)
|
||
uni.showToast({
|
||
title: '发送失败',
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
}
|
||
}
|
||
|
||
// 分包发送数据
|
||
const sendDataInChunks = async (data, serviceId, characteristicId) => {
|
||
const buffer = str2ab(data)
|
||
const chunkSize = 20 // 每次发送20字节
|
||
const chunks = []
|
||
|
||
// 分割数据
|
||
for (let i = 0; i < buffer.byteLength; i += chunkSize) {
|
||
const chunk = buffer.slice(i, Math.min(i + chunkSize, buffer.byteLength))
|
||
chunks.push(chunk)
|
||
}
|
||
|
||
console.log(`数据分为 ${chunks.length} 包发送`)
|
||
|
||
uni.showLoading({
|
||
title: `发送中 0/${chunks.length}`,
|
||
mask: true
|
||
})
|
||
|
||
try {
|
||
for (let i = 0; i < chunks.length; i++) {
|
||
await new Promise((resolve, reject) => {
|
||
uni.writeBLECharacteristicValue({
|
||
deviceId: connectedDeviceId.value,
|
||
serviceId: serviceId,
|
||
characteristicId: characteristicId,
|
||
value: chunks[i],
|
||
success: () => {
|
||
console.log(`第 ${i + 1}/${chunks.length} 包发送成功`)
|
||
uni.showLoading({
|
||
title: `发送中 ${i + 1}/${chunks.length}`,
|
||
mask: true
|
||
})
|
||
resolve()
|
||
},
|
||
fail: (err) => {
|
||
reject(err)
|
||
}
|
||
})
|
||
})
|
||
|
||
// 每包之间延迟100ms
|
||
if (i < chunks.length - 1) {
|
||
await new Promise(resolve => setTimeout(resolve, 100))
|
||
}
|
||
}
|
||
|
||
uni.hideLoading()
|
||
|
||
// 添加到历史记录
|
||
const hexString = ab2hex(buffer)
|
||
addToHistory('send', data, hexString)
|
||
|
||
uni.showToast({
|
||
title: `分包发送成功 (${chunks.length}包)`,
|
||
icon: 'success',
|
||
duration: 2000
|
||
})
|
||
} catch (err) {
|
||
uni.hideLoading()
|
||
console.error('分包发送失败', err)
|
||
uni.showToast({
|
||
title: '分包发送失败',
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
}
|
||
}
|
||
|
||
// 读取蓝牙设备数据 - 遍历所有服务和特征值
|
||
const readBLEData = async () => {
|
||
if (!connectedDeviceId.value || services.value.length === 0) {
|
||
uni.showToast({
|
||
title: '请先连接设备',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
// 只清空接收历史,保留发送历史
|
||
receiveHistory.value = []
|
||
showHistory.value = false
|
||
|
||
// 开始读取
|
||
await performRead()
|
||
}
|
||
|
||
// 执行读取操作
|
||
const performRead = async () => {
|
||
uni.showLoading({
|
||
title: '正在读取数据...',
|
||
mask: true
|
||
})
|
||
|
||
let readCount = 0
|
||
let totalReadable = 0
|
||
let successCount = 0 // 成功返回数据的次数
|
||
let failCount = 0 // 读取失败的次数
|
||
|
||
try {
|
||
// 遍历所有服务
|
||
for (let i = 0; i < services.value.length; i++) {
|
||
const service = services.value[i]
|
||
console.log(`正在检查服务 ${i + 1}/${services.value.length}:`, service.uuid)
|
||
|
||
// 获取该服务的所有特征值
|
||
const chars = await getBLEServiceCharacteristics(connectedDeviceId.value, service.uuid)
|
||
|
||
// 找出所有可读的特征值
|
||
const readableChars = chars.filter(c => c.properties.read)
|
||
totalReadable += readableChars.length
|
||
|
||
console.log(`服务 ${service.uuid} 有 ${readableChars.length} 个可读特征值`)
|
||
|
||
// 读取所有可读的特征值
|
||
for (const char of readableChars) {
|
||
readCount++ // 总数统计(不管成功失败)
|
||
try {
|
||
await readCharacteristicValue(connectedDeviceId.value, service.uuid, char.uuid)
|
||
successCount++
|
||
console.log(`成功读取特征值: ${char.uuid}`)
|
||
} catch (err) {
|
||
failCount++
|
||
console.error(`读取特征值失败: ${char.uuid}`, err)
|
||
}
|
||
|
||
// 添加延迟,避免读取过快
|
||
await new Promise(resolve => setTimeout(resolve, 100))
|
||
}
|
||
}
|
||
|
||
uni.hideLoading()
|
||
|
||
if (totalReadable === 0) {
|
||
uni.showToast({
|
||
title: '该设备没有可读特征值\n请等待设备推送数据',
|
||
icon: 'none',
|
||
duration: 2500
|
||
})
|
||
readStats.value = null
|
||
} else {
|
||
// 保存统计信息,不弹窗
|
||
readStats.value = {
|
||
total: readCount,
|
||
success: successCount,
|
||
fail: failCount
|
||
}
|
||
|
||
// 自动显示历史记录
|
||
setTimeout(() => {
|
||
if (dataHistory.value.length > 0) {
|
||
showHistory.value = true
|
||
}
|
||
}, 500)
|
||
}
|
||
} catch (err) {
|
||
uni.hideLoading()
|
||
console.error('读取数据失败', err)
|
||
uni.showToast({
|
||
title: '读取数据失败',
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
}
|
||
}
|
||
|
||
// 获取服务的特征值(Promise封装)
|
||
const getBLEServiceCharacteristics = (deviceId, serviceId) => {
|
||
return new Promise((resolve, reject) => {
|
||
uni.getBLEDeviceCharacteristics({
|
||
deviceId: deviceId,
|
||
serviceId: serviceId,
|
||
success: (res) => {
|
||
resolve(res.characteristics)
|
||
},
|
||
fail: (err) => {
|
||
reject(err)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
// 读取特征值(Promise封装)
|
||
const readCharacteristicValue = (deviceId, serviceId, characteristicId) => {
|
||
return new Promise((resolve, reject) => {
|
||
uni.readBLECharacteristicValue({
|
||
deviceId: deviceId,
|
||
serviceId: serviceId,
|
||
characteristicId: characteristicId,
|
||
success: (res) => {
|
||
resolve(res)
|
||
},
|
||
fail: (err) => {
|
||
reject(err)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
// ArrayBuffer 转 16进制字符串
|
||
const ab2hex = (buffer) => {
|
||
const hexArr = Array.prototype.map.call(
|
||
new Uint8Array(buffer),
|
||
bit => ('00' + bit.toString(16)).slice(-2)
|
||
)
|
||
return hexArr.join('')
|
||
}
|
||
|
||
// ArrayBuffer 转 字符串
|
||
const ab2str = (buffer) => {
|
||
try {
|
||
return String.fromCharCode.apply(null, new Uint8Array(buffer))
|
||
} catch (e) {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
// 字符串 转 ArrayBuffer(支持UTF-8编码)
|
||
const str2ab = (str) => {
|
||
// 使用TextEncoder支持UTF-8编码(支持中文)
|
||
if (typeof TextEncoder !== 'undefined') {
|
||
const encoder = new TextEncoder()
|
||
return encoder.encode(str).buffer
|
||
}
|
||
|
||
// 备用方案:使用charCodeAt(仅支持ASCII)
|
||
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) & 0xFF)
|
||
}
|
||
return buffer
|
||
}
|
||
|
||
// 监听蓝牙连接状态变化
|
||
const onBLEConnectionStateChange = () => {
|
||
uni.onBLEConnectionStateChange((res) => {
|
||
console.log('蓝牙连接状态变化', res)
|
||
if (!res.connected && res.deviceId === connectedDeviceId.value) {
|
||
cleanupConnection()
|
||
uni.showToast({
|
||
title: '设备已断开',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// 发送自定义数据
|
||
const sendCustomData = () => {
|
||
// 支持中文全角空格的trim
|
||
const trimmedData = inputData.value.replace(/^[\s\u3000]+|[\s\u3000]+$/g, '')
|
||
|
||
if (!trimmedData) {
|
||
uni.showToast({
|
||
title: '请输入数据',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
writeBLEData(trimmedData)
|
||
inputData.value = '' // 发送后清空输入框
|
||
}
|
||
|
||
// 添加到历史记录
|
||
const addToHistory = (type, data, hex, characteristicId = '') => {
|
||
const now = new Date()
|
||
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
|
||
|
||
const record = {
|
||
type, // 'send' 或 'receive'
|
||
data,
|
||
hex,
|
||
characteristicId, // 特征值UUID
|
||
time,
|
||
timestamp: now.getTime()
|
||
}
|
||
|
||
// 根据类型添加到不同的历史记录
|
||
if (type === 'send') {
|
||
sendHistory.value.unshift(record)
|
||
// 最多保留100条发送记录
|
||
if (sendHistory.value.length > 100) {
|
||
sendHistory.value = sendHistory.value.slice(0, 100)
|
||
}
|
||
} else if (type === 'receive') {
|
||
receiveHistory.value.unshift(record)
|
||
// 最多保留100条接收记录
|
||
if (receiveHistory.value.length > 100) {
|
||
receiveHistory.value = receiveHistory.value.slice(0, 100)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 切换历史记录显示
|
||
const toggleHistory = () => {
|
||
if (dataHistory.value.length === 0) {
|
||
uni.showToast({
|
||
title: '暂无历史记录',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
showHistory.value = !showHistory.value
|
||
}
|
||
|
||
// 清空历史记录
|
||
const clearHistory = () => {
|
||
uni.showModal({
|
||
title: '确认',
|
||
content: '确定要清空所有历史记录吗?',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
sendHistory.value = []
|
||
receiveHistory.value = []
|
||
showHistory.value = false
|
||
uni.showToast({
|
||
title: '已清空',
|
||
icon: 'success'
|
||
})
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 监听蓝牙适配器状态变化
|
||
const onBluetoothAdapterStateChange = () => {
|
||
uni.onBluetoothAdapterStateChange((res) => {
|
||
console.log('蓝牙适配器状态变化', res)
|
||
bluetoothEnabled.value = res.available
|
||
if (!res.available) {
|
||
isSearching.value = false
|
||
uni.showToast({
|
||
title: '蓝牙已关闭',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// 页面加载时初始化
|
||
onMounted(() => {
|
||
// 判断是否为H5环境
|
||
// #ifdef H5
|
||
uni.showModal({
|
||
title: '提示',
|
||
content: 'H5平台不支持蓝牙功能,请使用App或小程序版本',
|
||
showCancel: false,
|
||
confirmText: '我知道了'
|
||
})
|
||
bluetoothEnabled.value = false
|
||
// #endif
|
||
|
||
// #ifndef H5
|
||
initBluetooth()
|
||
onBluetoothDeviceFound()
|
||
onBLEConnectionStateChange()
|
||
onBluetoothAdapterStateChange()
|
||
// #endif
|
||
})
|
||
|
||
|
||
|
||
// 页面卸载时清理
|
||
const cleanup = () => {
|
||
// 移除监听器
|
||
if (hasRegisteredCharacteristicListener) {
|
||
uni.offBLECharacteristicValueChange()
|
||
hasRegisteredCharacteristicListener = false
|
||
}
|
||
|
||
uni.offBluetoothDeviceFound()
|
||
uni.offBLEConnectionStateChange()
|
||
uni.offBluetoothAdapterStateChange()
|
||
|
||
// 停止搜索
|
||
if (isSearching.value) {
|
||
stopBluetoothSearch()
|
||
}
|
||
|
||
// 断开连接
|
||
if (connectedDeviceId.value) {
|
||
disconnectDevice()
|
||
}
|
||
|
||
// 关闭蓝牙适配器
|
||
uni.closeBluetoothAdapter({
|
||
success: (res) => {
|
||
console.log('关闭蓝牙适配器成功', res)
|
||
},
|
||
fail: (err) => {
|
||
console.error('关闭蓝牙适配器失败', err)
|
||
}
|
||
})
|
||
}
|
||
|
||
onUnmounted(() => {
|
||
// #ifndef H5
|
||
cleanup()
|
||
// #endif
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
page {
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
background-color: #667eea;
|
||
}
|
||
|
||
.bluetooth-page {
|
||
width: 100%;
|
||
height: 100vh;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
}
|
||
|
||
.bluetooth-container {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
overflow-x: hidden;
|
||
padding-bottom: 20rpx;
|
||
}
|
||
|
||
.search-status {
|
||
background: rgba(255, 255, 255, 0.95);
|
||
padding: 30rpx;
|
||
margin: 20rpx;
|
||
border-radius: 20rpx;
|
||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
|
||
|
||
.status-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.status-item {
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
.status-label {
|
||
font-size: 28rpx;
|
||
color: #666;
|
||
}
|
||
|
||
.status-value {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
|
||
&.status-on {
|
||
color: #52c41a;
|
||
}
|
||
|
||
&.status-off {
|
||
color: #999;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.action-buttons {
|
||
display: flex;
|
||
padding: 0 20rpx;
|
||
margin: 20rpx 0;
|
||
|
||
:deep(.u-button) {
|
||
flex: 1;
|
||
height: 88rpx;
|
||
border-radius: 16rpx;
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
.device-list {
|
||
background: rgba(255, 255, 255, 0.95);
|
||
margin: 20rpx;
|
||
border-radius: 20rpx;
|
||
padding: 30rpx;
|
||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
|
||
|
||
.list-header {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 20rpx;
|
||
padding-bottom: 20rpx;
|
||
border-bottom: 2rpx solid rgba(102, 126, 234, 0.1);
|
||
|
||
.header-title {
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
|
||
.device-count {
|
||
font-size: 28rpx;
|
||
color: #999;
|
||
margin-left: 10rpx;
|
||
}
|
||
}
|
||
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 80rpx 0;
|
||
|
||
.empty-icon {
|
||
width: 120rpx;
|
||
height: 120rpx;
|
||
background: linear-gradient(135deg, #2196F3 0%, #1976D2 100%);
|
||
border-radius: 60rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 8rpx 24rpx rgba(33, 150, 243, 0.4);
|
||
margin-bottom: 30rpx;
|
||
|
||
.bluetooth-img {
|
||
width: 60rpx;
|
||
height: 60rpx;
|
||
}
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 28rpx;
|
||
color: #999;
|
||
margin-top: 20rpx;
|
||
}
|
||
}
|
||
|
||
.device-items {
|
||
.device-wrapper {
|
||
margin-bottom: 20rpx;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.device-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 24rpx;
|
||
background: linear-gradient(135deg, #f8f9ff 0%, #ffffff 100%);
|
||
border-radius: 16rpx;
|
||
border: 2rpx solid rgba(102, 126, 234, 0.1);
|
||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
|
||
transition: all 0.3s ease;
|
||
|
||
&:active {
|
||
transform: scale(0.98);
|
||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.device-info {
|
||
flex: 1;
|
||
min-width: 0; // 允许内容缩小
|
||
|
||
.device-name {
|
||
display: flex;
|
||
align-items: flex-start; // 顶部对齐,适配多行文本
|
||
margin-bottom: 12rpx;
|
||
|
||
.device-icon {
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
background: linear-gradient(135deg, #2196F3 0%, #1976D2 100%);
|
||
border-radius: 32rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
box-shadow: 0 4rpx 12rpx rgba(33, 150, 243, 0.3);
|
||
margin-top: 4rpx; // 与文本顶部对齐
|
||
|
||
.bluetooth-img-small {
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
}
|
||
}
|
||
|
||
.name-text {
|
||
flex: 1;
|
||
min-width: 0; // 允许文本缩小
|
||
font-size: 30rpx;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin-left: 12rpx;
|
||
word-wrap: break-word; // 允许单词内换行
|
||
word-break: break-all; // 允许任意位置换行
|
||
line-height: 1.4; // 设置行高
|
||
}
|
||
}
|
||
|
||
.device-details {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding-left: 76rpx; // 与设备名称对齐
|
||
|
||
.detail-text {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
line-height: 36rpx;
|
||
word-wrap: break-word;
|
||
word-break: break-all;
|
||
}
|
||
}
|
||
}
|
||
|
||
.device-action {
|
||
flex-shrink: 0; // 防止按钮区域被压缩
|
||
margin-left: 16rpx; // 与设备信息保持间距
|
||
align-self: flex-start; // 顶部对齐
|
||
.connected-badge {
|
||
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
|
||
padding: 12rpx 24rpx;
|
||
border-radius: 30rpx;
|
||
box-shadow: 0 4rpx 12rpx rgba(82, 196, 26, 0.3);
|
||
|
||
.badge-text {
|
||
font-size: 24rpx;
|
||
color: #fff;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
|
||
.connecting-badge {
|
||
padding: 12rpx 24rpx;
|
||
background: rgba(24, 144, 255, 0.1);
|
||
border-radius: 30rpx;
|
||
|
||
.connecting-text {
|
||
font-size: 24rpx;
|
||
color: #1890ff;
|
||
font-weight: 600;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 已连接设备的详细信息(嵌入式)
|
||
.device-connected-detail {
|
||
margin-top: 16rpx;
|
||
padding: 20rpx;
|
||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||
border-radius: 12rpx;
|
||
border: 2rpx solid rgba(14, 165, 233, 0.2);
|
||
|
||
.detail-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16rpx;
|
||
padding-bottom: 12rpx;
|
||
border-bottom: 1rpx solid rgba(14, 165, 233, 0.15);
|
||
|
||
.detail-title {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: #0284c7;
|
||
}
|
||
}
|
||
|
||
.detail-content {
|
||
.detail-row {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 10rpx;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.detail-label {
|
||
font-size: 26rpx;
|
||
color: #666;
|
||
min-width: 140rpx;
|
||
}
|
||
|
||
.detail-value {
|
||
font-size: 26rpx;
|
||
color: #333;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
}
|
||
|
||
.data-section {
|
||
margin-top: 16rpx;
|
||
padding-top: 16rpx;
|
||
border-top: 1rpx solid rgba(14, 165, 233, 0.15);
|
||
|
||
.section-title {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: #0284c7;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.input-area {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 16rpx;
|
||
|
||
.data-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
height: 80rpx;
|
||
padding: 16rpx 24rpx;
|
||
background: rgba(255, 255, 255, 0.98);
|
||
border-radius: 12rpx;
|
||
border: 3rpx solid rgba(14, 165, 233, 0.5);
|
||
font-size: 30rpx;
|
||
color: #333;
|
||
box-sizing: border-box;
|
||
|
||
&:focus {
|
||
border-color: rgba(14, 165, 233, 1);
|
||
background: rgba(255, 255, 255, 1);
|
||
box-shadow: 0 0 0 4rpx rgba(14, 165, 233, 0.1);
|
||
}
|
||
}
|
||
}
|
||
|
||
.data-actions {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12rpx;
|
||
|
||
:deep(.u-button) {
|
||
flex: 1;
|
||
height: 64rpx;
|
||
font-size: 24rpx;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 内联历史记录
|
||
.history-section-inline {
|
||
margin-top: 16rpx;
|
||
padding-top: 16rpx;
|
||
border-top: 1rpx solid rgba(14, 165, 233, 0.15);
|
||
|
||
.history-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 12rpx;
|
||
|
||
.history-title {
|
||
font-size: 28rpx;
|
||
font-weight: 600;
|
||
color: #0284c7;
|
||
}
|
||
|
||
.read-stats {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
|
||
.stats-text {
|
||
font-size: 22rpx;
|
||
color: #0284c7;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.stats-detail {
|
||
font-size: 20rpx;
|
||
color: #f59e0b;
|
||
margin-top: 2rpx;
|
||
}
|
||
}
|
||
}
|
||
|
||
.history-list {
|
||
max-height: 400rpx;
|
||
|
||
.history-item {
|
||
padding: 16rpx;
|
||
margin-bottom: 12rpx;
|
||
background: rgba(255, 255, 255, 0.9);
|
||
border-radius: 10rpx;
|
||
border: 1rpx solid rgba(14, 165, 233, 0.15);
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.history-info {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 10rpx;
|
||
|
||
.history-type {
|
||
padding: 4rpx 12rpx;
|
||
border-radius: 16rpx;
|
||
font-size: 20rpx;
|
||
font-weight: 600;
|
||
margin-right: 12rpx;
|
||
|
||
&.send {
|
||
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
|
||
color: #fff;
|
||
}
|
||
|
||
&.receive {
|
||
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
.history-time {
|
||
font-size: 20rpx;
|
||
color: #999;
|
||
}
|
||
}
|
||
|
||
.history-uuid {
|
||
display: flex;
|
||
margin-bottom: 8rpx;
|
||
|
||
.uuid-label {
|
||
font-size: 20rpx;
|
||
color: #8b5cf6;
|
||
width: 70rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.uuid-value {
|
||
flex: 1;
|
||
font-size: 20rpx;
|
||
color: #8b5cf6;
|
||
word-break: break-all;
|
||
font-family: monospace;
|
||
}
|
||
}
|
||
|
||
.history-data {
|
||
display: flex;
|
||
margin-bottom: 6rpx;
|
||
|
||
.data-label {
|
||
font-size: 22rpx;
|
||
color: #666;
|
||
width: 70rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.data-value {
|
||
flex: 1;
|
||
font-size: 22rpx;
|
||
color: #333;
|
||
word-break: break-all;
|
||
}
|
||
}
|
||
|
||
.history-hex {
|
||
display: flex;
|
||
|
||
.hex-label {
|
||
font-size: 20rpx;
|
||
color: #999;
|
||
width: 70rpx;
|
||
}
|
||
|
||
.hex-value {
|
||
flex: 1;
|
||
font-size: 20rpx;
|
||
color: #999;
|
||
word-break: break-all;
|
||
font-family: monospace;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
</style>
|