SEC_Warehouse/hooks/useBluetoothDiscovery.js

857 lines
21 KiB
JavaScript
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.

import {
BLUETOOTH_ERROR_MESSAGES,
DEFAULT_BLUETOOTH_ERROR_MESSAGE,
DISCOVERY_FILTER_SERVICE_UUIDS,
TARGET_SERVICE_UUID_FULL,
TARGET_SERVICE_UUID_ALT_FULL,
TARGET_SERVICE_UUID_COMPAT_FULL,
TARGET_SHORT_UUID_CANDIDATES
} from '@/constants/bluetooth'
import { DEVICE_PROFILE, createUnifiedBleController, shortUuid, uuidInList } from '@/utils/ble'
import { showToast } from '@/utils/toast'
const FILTER_SCAN_TIMEOUT_MS = 4000
const RESTART_SCAN_DELAY_MS = 120
const DISCOVERY_POLL_INTERVAL_MS = 1200
const TARGET_NAME_KEYWORDS = Object.freeze(['ED713', '713WQ', 'ED719', 'EP832'])
function promisifyUniApi(apiName, params = {}) {
return new Promise((resolve, reject) => {
uni[apiName]({
...params,
success: (res) => resolve(res),
fail: (err) => reject(err)
})
})
}
/**
* 统一蓝牙接入层ED713/ED719
* 1) 搜索(过滤、去重、可停止)
* 2) 连接 + 服务/特征发现 + 订阅
* 3) 鉴权、读取设备信息、WiFi 配置、雷达控制
*/
export function createBluetoothDiscovery(options = {}) {
const {
onSearchingChange = () => {},
onDeviceMapChange = () => {},
onDeviceListChange = () => {},
onConnectedChange = () => {},
onProfileChange = () => {},
onStageChange = () => {},
onProtocolState = () => {},
onProtocolMsg = () => {},
onRadarData = () => {},
onDeviceInfoChange = () => {},
onAuthChange = () => {},
onError = () => {}
} = options
const controller = createUnifiedBleController({
profileId: DEVICE_PROFILE.UNKNOWN
})
let initialized = false
let isSearching = false
let isConnected = false
let deviceMap = {}
let currentDeviceId = ''
let currentProfileId = DEVICE_PROFILE.UNKNOWN
let lastDeviceInfo = null
let lastAuthResult = null
let searchSessionId = 0
let acceptingDeviceEvents = false
let filterFallbackTimer = null
let discoveryPollTimer = null
let offDeviceFound = null
let offAdapterState = null
let offConnectionChange = null
let offStateChange = null
let offProtocolState = null
let offProtocolMsg = null
let offProtocolRadar = null
let offError = null
function setSearching(value) {
isSearching = Boolean(value)
onSearchingChange(isSearching)
}
function setConnected(value) {
isConnected = Boolean(value)
onConnectedChange(isConnected)
}
function getSortedDeviceList(mapValue = deviceMap) {
return Object.values(mapValue).sort((left, right) => {
if (left.hasTargetService !== right.hasTargetService) {
return left.hasTargetService ? -1 : 1
}
const leftSignal = typeof left.RSSI === 'number' ? left.RSSI : -999
const rightSignal = typeof right.RSSI === 'number' ? right.RSSI : -999
return rightSignal - leftSignal
})
}
function emitDeviceList() {
onDeviceListChange(getSortedDeviceList())
}
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 inferProfileByName(device) {
const text = `${device?.name || ''} ${device?.localName || ''}`.toUpperCase()
if (text.includes('ED719')) {
return DEVICE_PROFILE.ED719
}
if (text.includes('ED713') || text.includes('713WQ')) {
return DEVICE_PROFILE.ED713
}
return DEVICE_PROFILE.UNKNOWN
}
/**
* 从广播包中解析 16-bit service UUIDAD Type 0x02/0x03
*/
function extractShortUuidsFromAdvertisData(advertisData) {
if (!advertisData) {
return []
}
const bytes = advertisData instanceof Uint8Array ? advertisData : new Uint8Array(advertisData)
const shortUuids = []
let offset = 0
while (offset < bytes.length) {
const len = bytes[offset]
if (!len) {
break
}
const typeIndex = offset + 1
const dataStart = offset + 2
const dataEnd = offset + 1 + len
if (dataEnd > bytes.length) {
break
}
const type = bytes[typeIndex]
if (type === 0x02 || type === 0x03) {
for (let index = dataStart; index + 1 < dataEnd; index += 2) {
const value = ((bytes[index + 1] << 8) | bytes[index]).toString(16).toUpperCase().padStart(4, '0')
shortUuids.push(value)
}
}
offset += len + 1
}
return shortUuids
}
function hasTargetService(device) {
const serviceList = Array.isArray(device?.advertisServiceUUIDs) ? device.advertisServiceUUIDs : []
const adShortUuids = extractShortUuidsFromAdvertisData(device?.advertisData)
if (!serviceList.length && !adShortUuids.length) {
return false
}
const serviceShortList = serviceList.map((item) => shortUuid(item))
return uuidInList(TARGET_SERVICE_UUID_COMPAT_FULL, serviceList)
|| uuidInList(TARGET_SERVICE_UUID_FULL, serviceList)
|| uuidInList(TARGET_SERVICE_UUID_ALT_FULL, serviceList)
|| TARGET_SHORT_UUID_CANDIDATES.some((uuid) => serviceShortList.includes(uuid))
|| TARGET_SHORT_UUID_CANDIDATES.some((uuid) => adShortUuids.includes(uuid))
}
function bufferToPrintableText(buffer) {
if (!buffer) {
return ''
}
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
return Array.from(bytes)
.map((value) => (value >= 32 && value <= 126 ? String.fromCharCode(value) : ' '))
.join(' ')
.toUpperCase()
}
function hasTargetKeywordText(text) {
return TARGET_NAME_KEYWORDS.some((keyword) => text.includes(keyword))
}
function hasTargetName(device) {
const text = `${String(device?.name || '')} ${String(device?.localName || '')}`.toUpperCase()
return hasTargetKeywordText(text)
}
function hasTargetKeywordInPayload(device) {
const adText = bufferToPrintableText(device?.advertisData)
const manufacturerText = bufferToPrintableText(device?.manufacturerData)
return hasTargetKeywordText(`${adText} ${manufacturerText}`)
}
function matchTargetDevice(device) {
const byService = hasTargetService(device)
const byName = hasTargetName(device)
const byPayload = hasTargetKeywordInPayload(device)
return {
isTarget: byService || byName || byPayload,
byService,
byName,
byPayload
}
}
function hasTargetDeviceInMap() {
return Object.values(deviceMap).some((item) => item.isTargetDevice)
}
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 clearDiscoveryPollTimer() {
if (!discoveryPollTimer) {
return
}
clearTimeout(discoveryPollTimer)
discoveryPollTimer = 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 locationSetting = settings?.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 matched = matchTargetDevice(rawDevice)
if (!matched.isTarget) {
return
}
const existing = deviceMap[rawDevice.deviceId] || {}
const merged = {
...existing,
...rawDevice,
showName: rawDevice.name || rawDevice.localName || existing.showName || '未命名设备',
isTargetDevice: true,
hasTargetService: matched.byService,
matchedByName: matched.byName,
matchedByPayload: matched.byPayload,
profileHint: inferProfileByName(rawDevice)
}
const prevSignature = getDeviceStableSignature(existing)
const nextSignature = getDeviceStableSignature(merged)
if (prevSignature === nextSignature) {
return
}
setDeviceMap({
...deviceMap,
[rawDevice.deviceId]: merged
})
}
function getBluetoothErrorText(error) {
const errCode = error?.errCode ?? error?.code
const text = BLUETOOTH_ERROR_MESSAGES[errCode] || DEFAULT_BLUETOOTH_ERROR_MESSAGE
if (errCode === undefined || errCode === null) {
return text
}
return `${text}(${errCode})`
}
function bindControllerEvents() {
if (offStateChange) {
return
}
offStateChange = controller.ble.on('state:change', (state) => {
onStageChange(state.stage)
if (state.profileId && state.profileId !== currentProfileId) {
currentProfileId = state.profileId
onProfileChange(currentProfileId)
}
})
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?.available !== false) {
return
}
acceptingDeviceEvents = false
setSearching(false)
setConnected(false)
showToast('蓝牙已关闭,请先开启手机蓝牙')
})
offConnectionChange = controller.ble.on('connection:change', (payload) => {
setConnected(Boolean(payload?.connected))
if (!payload?.connected) {
currentDeviceId = ''
}
})
offProtocolState = controller.ble.on('protocol:state', (payload) => {
try { console.debug('[BLE][STATE]', 'code=', payload?.stateCode, 'char=', payload?.characteristicId) } catch (e) {}
onProtocolState(payload)
})
offProtocolMsg = controller.ble.on('protocol:msg', (payload) => {
try { console.debug('[BLE][MSG]', 'len=', payload?.value?.length || 0) } catch (e) {}
onProtocolMsg(payload)
if (payload?.deviceInfo) {
lastDeviceInfo = payload.deviceInfo
onDeviceInfoChange(lastDeviceInfo)
}
})
offProtocolRadar = controller.ble.on('protocol:radar', (payload) => {
const parsed = controller.radar.parseNotifyPayload(payload.value)
onRadarData({
...payload,
parsed
})
})
offError = controller.ble.on('error', (error) => {
try { console.debug('[BLE][ERR]', error?.message || error?.errMsg || String(error)) } catch (e) {}
onError(error)
})
}
function unbindControllerEvents() {
if (offDeviceFound) {
offDeviceFound()
offDeviceFound = null
}
if (offAdapterState) {
offAdapterState()
offAdapterState = null
}
if (offConnectionChange) {
offConnectionChange()
offConnectionChange = null
}
if (offStateChange) {
offStateChange()
offStateChange = null
}
if (offProtocolState) {
offProtocolState()
offProtocolState = null
}
if (offProtocolMsg) {
offProtocolMsg()
offProtocolMsg = null
}
if (offProtocolRadar) {
offProtocolRadar()
offProtocolRadar = null
}
if (offError) {
offError()
offError = null
}
}
async function ensureInitialized() {
if (initialized) {
return
}
await controller.init()
controller.ble.watchDeviceFound()
bindControllerEvents()
initialized = true
currentProfileId = controller.ble.getProfile().id
onProfileChange(currentProfileId)
}
async function startDiscoveryWithServices(services = []) {
await controller.ble.startDiscovery({
services,
allowDuplicatesKey: true,
interval: 0,
// 兼容低版本客户端:不强制传 powerLevel。
powerLevel: null
})
}
async function stopDiscoverySilently() {
try {
await controller.ble.stopDiscovery()
} catch (error) {
}
}
async function pullDiscoveredDevicesOnce(sessionId) {
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {
return
}
try {
const result = await promisifyUniApi('getBluetoothDevices')
const list = Array.isArray(result?.devices) ? result.devices : []
list.forEach((device) => {
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {
return
}
upsertDevice(device)
})
} catch (error) {
}
}
function startDiscoveryPolling(sessionId) {
clearDiscoveryPollTimer()
const run = async () => {
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {
return
}
await pullDiscoveredDevicesOnce(sessionId)
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {
return
}
discoveryPollTimer = setTimeout(run, DISCOVERY_POLL_INTERVAL_MS)
}
discoveryPollTimer = setTimeout(run, 300)
}
async function fallbackToUnfilteredDiscoveryIfNeeded(sessionId) {
clearFallbackTimer()
filterFallbackTimer = setTimeout(async () => {
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId || hasTargetDeviceInMap()) {
return
}
try {
await stopDiscoverySilently()
await delay(RESTART_SCAN_DELAY_MS)
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {
return
}
await startDiscoveryWithServices([])
} catch (error) {
}
}, FILTER_SCAN_TIMEOUT_MS)
}
/**
* 设备搜索:
* 优先按 0xFFF4/0x00F4/0xF400 过滤;若无结果自动切到兜底扫描并轮询 getBluetoothDevices。
*/
async function startSearch() {
if (isSearching) {
return
}
clearFallbackTimer()
clearDiscoveryPollTimer()
searchSessionId += 1
const sessionId = searchSessionId
resetDeviceMap()
try {
await ensureAndroidLocationPermission()
await ensureInitialized()
const adapterState = await controller.ble.getAdapterState()
if (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)
startDiscoveryPolling(sessionId)
await fallbackToUnfilteredDiscoveryIfNeeded(sessionId)
} catch (error) {
acceptingDeviceEvents = false
setSearching(false)
showToast(getBluetoothErrorText(error))
throw error
}
}
async function stopSearch(showStopToast = true) {
clearFallbackTimer()
clearDiscoveryPollTimer()
searchSessionId += 1
acceptingDeviceEvents = false
await stopDiscoverySilently()
setSearching(false)
if (showStopToast) {
showToast('已停止搜索')
}
}
function pickPreferredDeviceId(deviceId) {
if (deviceId) {
return deviceId
}
const list = getSortedDeviceList()
return list[0]?.deviceId || ''
}
function applyProfileHintByDevice(deviceId) {
if (!deviceId) {
return
}
const hint = deviceMap[deviceId]?.profileHint
if (!hint || hint === DEVICE_PROFILE.UNKNOWN) {
return
}
const currentProfile = controller.ble.getProfile().id
if (currentProfile === DEVICE_PROFILE.UNKNOWN) {
controller.ble.setProfile(hint)
}
}
/**
* 仅连接与发现阶段:连接 -> 发现服务0x00F4 -> 发现特征 -> 订阅 F301/F302。
*/
async function connectDevice(deviceId) {
await ensureInitialized()
await stopSearch(false)
const targetDeviceId = pickPreferredDeviceId(deviceId)
if (!targetDeviceId) {
throw new Error('未找到可连接设备')
}
applyProfileHintByDevice(targetDeviceId)
const characteristics = await controller.connectAndDiscover(targetDeviceId)
currentDeviceId = targetDeviceId
setConnected(true)
return {
deviceId: targetDeviceId,
characteristics,
profile: controller.ble.getProfile(),
state: controller.ble.getState()
}
}
/**
* 鉴权流程:严格遵循协议绑定逻辑(读取 UUID_MSG 的 bindStatus 后写 KEY
*/
async function authorizeDevice(key, options = {}) {
if (!currentDeviceId) {
throw new Error('请先连接设备后再鉴权')
}
const result = await controller.auth.ensureAuthorized(key, options)
lastAuthResult = result
onAuthChange(result)
return result
}
/**
* MAC固定规则鉴权密钥=MAC(去分隔符)+0000。
*/
async function authorizeDeviceByMac(options = {}) {
if (!currentDeviceId) {
throw new Error('请先连接设备后再鉴权')
}
const result = await controller.auth.ensureAuthorizedByMac(options)
lastAuthResult = result
onAuthChange(result)
return result
}
/**
* 标准连接流程:连接 -> 发现 -> 订阅 -> 鉴权。
*/
async function standardConnectFlow(params = {}) {
await ensureInitialized()
await stopSearch(false)
const targetDeviceId = pickPreferredDeviceId(params.deviceId)
if (!targetDeviceId) {
throw new Error('未找到可连接设备')
}
applyProfileHintByDevice(targetDeviceId)
const result = await controller.standardConnectFlow({
...params,
deviceId: targetDeviceId
})
currentDeviceId = result.deviceId
setConnected(true)
lastAuthResult = result.authResult
onAuthChange(result.authResult)
return result
}
/**
* WiFi配置专用流程连接 -> 发现 -> 订阅 -> 按 MAC+0000 完成鉴权。
*/
async function prepareWifiConfig(deviceId, options = {}) {
await connectDevice(deviceId)
return authorizeDeviceByMac(options)
}
async function readDeviceInfo() {
const info = await controller.auth.readDeviceInfo()
lastDeviceInfo = info
onDeviceInfoChange(info)
return info
}
async function configureWifi(ssid, password, options = {}) {
const profileId = controller.ble.getProfile().id
const preDelayMs = options.preDelayMs ?? (profileId === DEVICE_PROFILE.ED713 ? 120 : 0)
return controller.wifi.configureWifi(ssid, password, { ...options, preDelayMs })
}
async function startRadar() {
return controller.radar.startRadarStream()
}
async function stopRadar() {
return controller.radar.stopRadarStream()
}
async function setNarrowMode(enabled) {
return controller.radar.setNarrowMode(Boolean(enabled))
}
async function writeFallParams(rawParams) {
return controller.radar.writeFallParams(rawParams)
}
async function sendRadarCommand(commandByte) {
return controller.radar.sendCommand(commandByte)
}
async function disconnectDevice() {
await controller.ble.disconnect(currentDeviceId)
currentDeviceId = ''
setConnected(false)
}
function getState() {
const bleState = controller.ble.getState()
return {
isSearching,
isConnected,
currentDeviceId,
currentProfileId: controller.ble.getProfile().id,
deviceMap: { ...deviceMap },
deviceList: getSortedDeviceList(),
lastDeviceInfo,
lastAuthResult,
bleState
}
}
function cleanupDiscovery() {
clearFallbackTimer()
clearDiscoveryPollTimer()
searchSessionId += 1
acceptingDeviceEvents = false
setSearching(false)
setConnected(false)
currentDeviceId = ''
lastAuthResult = null
lastDeviceInfo = null
unbindControllerEvents()
if (!initialized) {
return
}
initialized = false
controller.cleanup().catch(() => {})
}
return {
// 搜索能力(兼容原页面入口)。
startSearch,
stopSearch,
cleanupDiscovery,
// 连接与协议流程能力。
connectDevice,
authorizeDevice,
authorizeDeviceByMac,
standardConnectFlow,
prepareWifiConfig,
disconnectDevice,
// 协议业务能力。
readDeviceInfo,
configureWifi,
startRadar,
stopRadar,
setNarrowMode,
writeFallParams,
sendRadarCommand,
// 调试/状态读取。
getState
}
}