379 lines
8.5 KiB
JavaScript
379 lines
8.5 KiB
JavaScript
import {
|
|
BLUETOOTH_ERROR_MESSAGES,
|
|
DEFAULT_BLUETOOTH_ERROR_MESSAGE,
|
|
DISCOVERY_FILTER_SERVICE_UUIDS,
|
|
TARGET_SERVICE_UUID_FULL,
|
|
TARGET_SERVICE_UUID_ALT_FULL,
|
|
TARGET_SHORT_UUID
|
|
} from '@/constants/bluetooth'
|
|
import { DEVICE_PROFILE, createUnifiedBleController } from '@/utils/ble'
|
|
import { showToast } from '@/utils/toast'
|
|
|
|
const FILTER_SCAN_TIMEOUT_MS = 4000
|
|
|
|
/**
|
|
* 蓝牙设备搜索逻辑封装。
|
|
* 基于 utils/ble 统一蓝牙控制器实现。
|
|
*/
|
|
export function createBluetoothDiscovery(options = {}) {
|
|
const {
|
|
onSearchingChange = () => {},
|
|
onDeviceMapChange = () => {},
|
|
onDeviceListChange = () => {}
|
|
} = options
|
|
|
|
const controller = createUnifiedBleController({
|
|
profileId: DEVICE_PROFILE.UNKNOWN
|
|
})
|
|
|
|
let isSearching = false
|
|
let deviceMap = {}
|
|
|
|
let initialized = false
|
|
let filterFallbackTimer = null
|
|
|
|
let offDeviceFound = null
|
|
let offAdapterState = null
|
|
|
|
let searchSessionId = 0
|
|
let acceptingDeviceEvents = false
|
|
|
|
function setSearching(value) {
|
|
isSearching = Boolean(value)
|
|
onSearchingChange(isSearching)
|
|
}
|
|
|
|
function emitDeviceList() {
|
|
const list = Object.values(deviceMap).sort((a, b) => {
|
|
if (a.hasTargetService !== b.hasTargetService) {
|
|
return a.hasTargetService ? -1 : 1
|
|
}
|
|
|
|
const aSignal = typeof a.RSSI === 'number' ? a.RSSI : -999
|
|
const bSignal = typeof b.RSSI === 'number' ? b.RSSI : -999
|
|
return bSignal - aSignal
|
|
})
|
|
|
|
onDeviceListChange(list)
|
|
}
|
|
|
|
function setDeviceMap(nextMap) {
|
|
deviceMap = nextMap
|
|
onDeviceMapChange({ ...deviceMap })
|
|
emitDeviceList()
|
|
}
|
|
|
|
function resetDeviceMap() {
|
|
setDeviceMap({})
|
|
}
|
|
|
|
function normalizeUuid(uuid) {
|
|
return String(uuid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
|
|
}
|
|
|
|
function hasTargetService(device) {
|
|
if (!Array.isArray(device.advertisServiceUUIDs)) {
|
|
return false
|
|
}
|
|
|
|
const serviceSet = new Set([
|
|
normalizeUuid(TARGET_SERVICE_UUID_FULL),
|
|
normalizeUuid(TARGET_SERVICE_UUID_ALT_FULL)
|
|
])
|
|
|
|
return device.advertisServiceUUIDs.some((uuid) => {
|
|
const value = normalizeUuid(uuid)
|
|
|
|
if (value.endsWith(TARGET_SHORT_UUID)) {
|
|
return true
|
|
}
|
|
|
|
return serviceSet.has(value)
|
|
})
|
|
}
|
|
|
|
function hasTargetDeviceInMap() {
|
|
return Object.values(deviceMap).some((item) => item.hasTargetService)
|
|
}
|
|
|
|
function getDeviceStableSignature(device = {}) {
|
|
return [
|
|
device.deviceId || '',
|
|
device.RSSI ?? '',
|
|
device.name || '',
|
|
device.localName || '',
|
|
Array.isArray(device.advertisServiceUUIDs) ? device.advertisServiceUUIDs.map((item) => normalizeUuid(item)).sort().join('|') : '',
|
|
device.advertisData ? JSON.stringify(Array.from(new Uint8Array(device.advertisData))) : ''
|
|
].join('#')
|
|
}
|
|
|
|
function clearFallbackTimer() {
|
|
if (!filterFallbackTimer) {
|
|
return
|
|
}
|
|
|
|
clearTimeout(filterFallbackTimer)
|
|
filterFallbackTimer = null
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms)
|
|
})
|
|
}
|
|
|
|
async function ensureAndroidLocationPermission() {
|
|
let systemInfo = {}
|
|
|
|
try {
|
|
systemInfo = uni.getSystemInfoSync() || {}
|
|
} catch (error) {
|
|
return true
|
|
}
|
|
|
|
const platform = String(systemInfo.platform || '').toLowerCase()
|
|
if (platform !== 'android') {
|
|
return true
|
|
}
|
|
|
|
try {
|
|
const settings = await new Promise((resolve, reject) => {
|
|
uni.getSetting({
|
|
success: resolve,
|
|
fail: reject
|
|
})
|
|
})
|
|
|
|
const authSetting = settings.authSetting || {}
|
|
const locationSetting = authSetting['scope.userLocation']
|
|
|
|
if (locationSetting === true) {
|
|
return true
|
|
}
|
|
|
|
await new Promise((resolve, reject) => {
|
|
uni.authorize({
|
|
scope: 'scope.userLocation',
|
|
success: resolve,
|
|
fail: reject
|
|
})
|
|
})
|
|
|
|
return true
|
|
} catch (error) {
|
|
showToast('Android建议开启定位权限/定位开关(已继续尝试搜索)')
|
|
return false
|
|
}
|
|
}
|
|
|
|
function upsertDevice(rawDevice) {
|
|
if (!rawDevice || !rawDevice.deviceId) {
|
|
return
|
|
}
|
|
|
|
const targetMatched = hasTargetService(rawDevice)
|
|
if (!targetMatched) {
|
|
return
|
|
}
|
|
|
|
const existing = deviceMap[rawDevice.deviceId] || {}
|
|
const merged = {
|
|
...existing,
|
|
...rawDevice,
|
|
showName: rawDevice.name || rawDevice.localName || existing.showName || '未命名设备',
|
|
hasTargetService: true
|
|
}
|
|
|
|
const prevSignature = getDeviceStableSignature(existing)
|
|
const nextSignature = getDeviceStableSignature(merged)
|
|
|
|
if (prevSignature === nextSignature) {
|
|
return
|
|
}
|
|
|
|
setDeviceMap({
|
|
...deviceMap,
|
|
[rawDevice.deviceId]: merged
|
|
})
|
|
}
|
|
|
|
async function ensureInitialized() {
|
|
if (initialized) {
|
|
return
|
|
}
|
|
|
|
await controller.init()
|
|
controller.ble.watchDeviceFound()
|
|
|
|
offDeviceFound = controller.ble.on('device:found', (devices) => {
|
|
if (!acceptingDeviceEvents) {
|
|
return
|
|
}
|
|
|
|
const currentSession = searchSessionId
|
|
devices.forEach((device) => {
|
|
if (!acceptingDeviceEvents || currentSession !== searchSessionId) {
|
|
return
|
|
}
|
|
|
|
upsertDevice(device)
|
|
})
|
|
})
|
|
|
|
offAdapterState = controller.ble.on('adapter:state', (state) => {
|
|
if (!state || state.available !== false) {
|
|
return
|
|
}
|
|
|
|
acceptingDeviceEvents = false
|
|
setSearching(false)
|
|
showToast('蓝牙已关闭,请先开启手机蓝牙')
|
|
})
|
|
|
|
initialized = true
|
|
}
|
|
|
|
async function startDiscoveryWithServices(services = []) {
|
|
await controller.ble.startDiscovery({
|
|
services,
|
|
allowDuplicatesKey: true,
|
|
interval: 0
|
|
})
|
|
}
|
|
|
|
async function stopDiscoverySilently() {
|
|
try {
|
|
await controller.ble.stopDiscovery()
|
|
} catch (error) {
|
|
}
|
|
}
|
|
|
|
async function fallbackToUnfilteredDiscoveryIfNeeded(sessionId) {
|
|
clearFallbackTimer()
|
|
|
|
filterFallbackTimer = setTimeout(async () => {
|
|
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId || hasTargetDeviceInMap()) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await stopDiscoverySilently()
|
|
await delay(120)
|
|
|
|
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {
|
|
return
|
|
}
|
|
|
|
await startDiscoveryWithServices([])
|
|
} catch (error) {
|
|
}
|
|
}, FILTER_SCAN_TIMEOUT_MS)
|
|
}
|
|
|
|
function getBluetoothErrorText(error) {
|
|
const errCode = (error && (error.errCode ?? error.code))
|
|
const text = BLUETOOTH_ERROR_MESSAGES[errCode] || DEFAULT_BLUETOOTH_ERROR_MESSAGE
|
|
|
|
if (errCode === undefined || errCode === null) {
|
|
return text
|
|
}
|
|
|
|
return `${text}(${errCode})`
|
|
}
|
|
|
|
async function startSearch() {
|
|
if (isSearching) {
|
|
return
|
|
}
|
|
|
|
clearFallbackTimer()
|
|
searchSessionId += 1
|
|
const sessionId = searchSessionId
|
|
|
|
resetDeviceMap()
|
|
|
|
try {
|
|
await ensureAndroidLocationPermission()
|
|
await ensureInitialized()
|
|
|
|
const adapterState = await controller.ble.getAdapterState()
|
|
if (adapterState && adapterState.available === false) {
|
|
throw { errCode: 10001 }
|
|
}
|
|
|
|
await stopDiscoverySilently()
|
|
|
|
acceptingDeviceEvents = true
|
|
|
|
try {
|
|
await startDiscoveryWithServices(DISCOVERY_FILTER_SERVICE_UUIDS)
|
|
} catch (error) {
|
|
await startDiscoveryWithServices([])
|
|
}
|
|
|
|
if (sessionId !== searchSessionId) {
|
|
return
|
|
}
|
|
|
|
setSearching(true)
|
|
await fallbackToUnfilteredDiscoveryIfNeeded(sessionId)
|
|
} catch (error) {
|
|
acceptingDeviceEvents = false
|
|
setSearching(false)
|
|
showToast(getBluetoothErrorText(error))
|
|
throw error
|
|
}
|
|
}
|
|
|
|
function stopSearch(showStopToast = true) {
|
|
return new Promise((resolve) => {
|
|
clearFallbackTimer()
|
|
|
|
searchSessionId += 1
|
|
acceptingDeviceEvents = false
|
|
|
|
controller.ble.stopDiscovery()
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
setSearching(false)
|
|
if (showStopToast) {
|
|
showToast('已停止搜索')
|
|
}
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
function cleanupDiscovery() {
|
|
clearFallbackTimer()
|
|
|
|
searchSessionId += 1
|
|
acceptingDeviceEvents = false
|
|
setSearching(false)
|
|
|
|
if (offDeviceFound) {
|
|
offDeviceFound()
|
|
offDeviceFound = null
|
|
}
|
|
|
|
if (offAdapterState) {
|
|
offAdapterState()
|
|
offAdapterState = null
|
|
}
|
|
|
|
if (!initialized) {
|
|
return
|
|
}
|
|
|
|
initialized = false
|
|
|
|
controller.cleanup().catch(() => {})
|
|
}
|
|
|
|
return {
|
|
startSearch,
|
|
stopSearch,
|
|
cleanupDiscovery
|
|
}
|
|
}
|