diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..542edb6 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/smarthome.iml b/.idea/smarthome.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/smarthome.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/App.vue b/App.vue index 9579b6d..dad0025 100644 --- a/App.vue +++ b/App.vue @@ -1,12 +1,17 @@ diff --git a/api/http/client.js b/api/http/client.js index a76f708..3b0d3f5 100644 --- a/api/http/client.js +++ b/api/http/client.js @@ -4,6 +4,9 @@ import { showToast } from '@/utils/toast' let baseURL = DEFAULT_BASE_URL +/** + * 拼接请求地址:支持绝对地址 / 相对地址 / 仅 baseURL。 + */ function buildUrl(url = '') { if (!url) { return baseURL @@ -43,6 +46,9 @@ function buildHeaders(customHeader = {}, needAuth = true, withJson = true) { return headers } +/** + * 401 统一处理:清理本地登录态并回到登录页。 + */ function handleUnauthorized() { if (!isAuthEnabled()) { return @@ -137,6 +143,7 @@ function request(options = {}) { }) } +// 语义化快捷方法,保持页面侧调用简洁。 request.get = (url, params = {}, config = {}) => { return request({ url, @@ -173,6 +180,9 @@ request.delete = (url, data = {}, config = {}) => { }) } +/** + * 上传文件封装:保持与 request 一致的错误处理与鉴权策略。 + */ request.upload = (url, filePath, name = 'file', formData = {}, config = {}) => { const { header = {}, diff --git a/constants/bluetooth.js b/constants/bluetooth.js index 70a6977..ec528e8 100644 --- a/constants/bluetooth.js +++ b/constants/bluetooth.js @@ -1,16 +1,30 @@ /** * 目标服务 UUID(用于优先识别特定设备)。 + * 协议文档为 0x00F4,部分固件实现会使用 0xF400。 */ -export const TARGET_SERVICE_UUID = '0000F400-0000-1000-8000-00805F9B34FB' +export const TARGET_SERVICE_UUID = '00F4' +export const TARGET_SERVICE_UUID_FULL = '000000F4-0000-1000-8000-00805F9B34FB' +export const TARGET_SERVICE_UUID_ALT = 'F400' +export const TARGET_SERVICE_UUID_ALT_FULL = '0000F400-0000-1000-8000-00805F9B34FB' + export const TARGET_SHORT_UUID = '00F4' +export const DISCOVERY_FILTER_SERVICE_UUIDS = Object.freeze([ + TARGET_SERVICE_UUID_FULL, + TARGET_SERVICE_UUID_ALT_FULL +]) + /** * 蓝牙常见错误提示映射。 */ export const BLUETOOTH_ERROR_MESSAGES = Object.freeze({ 10001: '蓝牙不可用,请先开启手机蓝牙', + 10002: '未找到指定设备,请确认设备在附近且处于广播状态', 10003: '连接失败,请重试', - 10012: '操作超时,请重试' + 10012: '操作超时,请重试', + 10013: '无效参数,请重试', + 10016: '蓝牙适配器初始化失败', + 10017: '启动搜索失败' }) export const DEFAULT_BLUETOOTH_ERROR_MESSAGE = '蓝牙搜索启动失败' diff --git a/docs/unified-ble-flow.md b/docs/unified-ble-flow.md new file mode 100644 index 0000000..461c13e --- /dev/null +++ b/docs/unified-ble-flow.md @@ -0,0 +1,124 @@ +# 统一 BLE 连接与控制流程(ED713 / ED719) + +## 1. 标准连接流程图(文字版) +1. 初始化阶段 +- `openBluetoothAdapter` +- 注册监听:`onBluetoothAdapterStateChange`、`onBLEConnectionStateChange`、`onBLECharacteristicValueChange` + +2. 搜索阶段 +- 优先过滤扫描:`startBluetoothDevicesDiscovery({ services:[0x00F4] })` +- 超时无结果则无过滤兜底扫描 +- 选择目标设备 `deviceId` + +3. 连接与发现阶段 +- `createBLEConnection(deviceId)` +- `getBLEDeviceServices`,定位服务 `0x00F4` +- `getBLEDeviceCharacteristics`,识别 `F401/F402/F403/F501/F301/F302` +- 订阅通知:`notify(F301)=true`、`notify(F302)=true` + +4. 标准业务阶段 +- 读取设备信息:`read(F501)` +- 鉴权:`write(F402, A7分包key)`,监听 `F301` 状态码 +- 鉴权成功后进行功能操作: + - WiFi 配网:`write(F401, A7分包ssid|pass)` + - 控制命令:`write(F403, [cmd])` + - 雷达数据:`cmd=0xA1` 开始、`cmd=0xA2` 停止,解析 `F302` + +5. 收尾阶段 +- 停止扫描 / 断开连接 / 关闭适配器 +- 清理监听器与状态 + +## 2. 目录结构 +```text +utils/ble/ + core/ + bleCore.js # 蓝牙核心能力(连接/发现/读写/订阅/重连) + controller.js # 标准流程编排(scan->connect->discover->auth) + errors.js # 统一异常 + eventBus.js # 回调分发 + packet.js # A7 分包工具 + retry.js # 重试工具 + state.js # 状态模型 + modules/ + auth.js # 鉴权流程(读MSG + 写KEY) + wifi.js # WiFi配置流程 + radar.js # 雷达控制流程 + parsers/ + commonParser.js # MSG/STATE/雷达通用解析 + index.js # 解析聚合出口 + protocols/ + profiles.js # ED713/ED719/UNKNOWN profile 差异配置 + utils/ + bytes.js # 字节/高低位工具 + hex.js # hex/utf8 转换 + uuid.js # UUID 标准化与比较 + index.js # 统一出口 +``` + +## 3. 公共方法列表 +- 蓝牙核心 + - `createBleCore(options)` + - `openAdapter/getAdapterState/closeAdapter` + - `startDiscovery/stopDiscovery` + - `connect/disconnect` + - `discoverServices/discoverCharacteristics` + - `readCharacteristic/writeCharacteristic/notifyCharacteristic` +- 流程编排 + - `createUnifiedBleController(options)` + - `standardConnectFlow({ deviceId, key })` + - `scanAndPickDevice/connectAndDiscover/cleanup` +- 业务模块 + - `createAuthModule(ble).ensureAuthorized(key)` + - `createWifiModule(ble).configureWifi(ssid, password)` + - `createRadarModule(ble).startRadarStream()/stopRadarStream()/writeFallParams()` +- 工具 + - `buildA7Packets(payload, packetConfig)` + - `bytesToHex/hexToBytes/utf8ToBytes/bytesToUtf8` + - `splitToLowHigh/joinLowHigh/readUint16LE/readUint32LE` + - `normalizeUuid/toFullUuid/uuidEquals` + +## 4. 核心流程代码示例 +```js +import { + createUnifiedBleController, + DEVICE_PROFILE +} from '@/utils/ble' + +const bleController = createUnifiedBleController({ + profileId: DEVICE_PROFILE.UNKNOWN, + reconnect: { enabled: true, retries: 2, delay: 1000 } +}) + +async function runStandardFlow({ key, ssid, password }) { + try { + // 1) 连接 + 服务发现 + 自动订阅F301/F302 + 鉴权 + const session = await bleController.standardConnectFlow({ key }) + + // 2) 读取设备信息(如需要可再次读取) + const info = await bleController.auth.readDeviceInfo() + + // 3) WiFi 配置 + const wifiResult = await bleController.wifi.configureWifi(ssid, password) + + // 4) 雷达控制 + await bleController.radar.startRadarStream() + + return { + session, + info, + wifiResult + } + } finally { + // 页面退出或流程结束时清理 + await bleController.cleanup() + } +} +``` + +## 5. ED713/ED719 兼容策略 +- 相同 UUID 与鉴权主流程统一抽象。 +- 差异通过 `profiles.js` 配置: + - `radar.notifySignature`: ED713=`0x7c`,ED719=`0x67` + - `cmd.supportsNarrowMode`: ED713=true + - `cmd.supportsFallParam67`: ED719=true +- 未知设备先用 `UNKNOWN`,收到 `F302` 数据后按签名字节自动识别 profile。 diff --git a/hooks/useBluetoothDiscovery.js b/hooks/useBluetoothDiscovery.js index 3835eed..8d748aa 100644 --- a/hooks/useBluetoothDiscovery.js +++ b/hooks/useBluetoothDiscovery.js @@ -1,14 +1,19 @@ import { BLUETOOTH_ERROR_MESSAGES, DEFAULT_BLUETOOTH_ERROR_MESSAGE, - TARGET_SERVICE_UUID, + 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 { @@ -17,21 +22,27 @@ export function createBluetoothDiscovery(options = {}) { onDeviceListChange = () => {} } = options + const controller = createUnifiedBleController({ + profileId: DEVICE_PROFILE.UNKNOWN + }) + let isSearching = false let deviceMap = {} - let deviceFoundHandler = null + + 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 setDeviceMap(nextMap) { - deviceMap = nextMap - onDeviceMapChange({ ...deviceMap }) - emitDeviceList() - } - function emitDeviceList() { const list = Object.values(deviceMap).sort((a, b) => { if (a.hasTargetService !== b.hasTargetService) { @@ -46,6 +57,16 @@ export function createBluetoothDiscovery(options = {}) { 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() } @@ -55,37 +76,94 @@ export function createBluetoothDiscovery(options = {}) { return false } - const targetUuid = normalizeUuid(TARGET_SERVICE_UUID) + const serviceSet = new Set([ + normalizeUuid(TARGET_SERVICE_UUID_FULL), + normalizeUuid(TARGET_SERVICE_UUID_ALT_FULL) + ]) + return device.advertisServiceUUIDs.some((uuid) => { const value = normalizeUuid(uuid) - return value === targetUuid || value.endsWith(TARGET_SHORT_UUID) + + if (value.endsWith(TARGET_SHORT_UUID)) { + return true + } + + return serviceSet.has(value) }) } - function openBluetoothAdapter() { - return new Promise((resolve, reject) => { - uni.openBluetoothAdapter({ - success: resolve, - fail: reject + 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 + }) }) - }) - } - function startDiscoveryWithServices(services = []) { - return new Promise((resolve, reject) => { - const options = { - allowDuplicatesKey: true, - interval: 0, - success: resolve, - fail: reject + const authSetting = settings.authSetting || {} + const locationSetting = authSetting['scope.userLocation'] + + if (locationSetting === true) { + return true } - if (services.length) { - options.services = services - } + await new Promise((resolve, reject) => { + uni.authorize({ + scope: 'scope.userLocation', + success: resolve, + fail: reject + }) + }) - uni.startBluetoothDevicesDiscovery(options) - }) + return true + } catch (error) { + showToast('Android建议开启定位权限/定位开关(已继续尝试搜索)') + return false + } } function upsertDevice(rawDevice) { @@ -93,12 +171,24 @@ export function createBluetoothDiscovery(options = {}) { 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: hasTargetService(rawDevice) || existing.hasTargetService + hasTargetService: true + } + + const prevSignature = getDeviceStableSignature(existing) + const nextSignature = getDeviceStableSignature(merged) + + if (prevSignature === nextSignature) { + return } setDeviceMap({ @@ -107,75 +197,177 @@ export function createBluetoothDiscovery(options = {}) { }) } - function registerDeviceFoundHandler() { - if (deviceFoundHandler) { + async function ensureInitialized() { + if (initialized) { return } - deviceFoundHandler = (res) => { - const devices = Array.isArray(res.devices) ? res.devices : [res] + 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 } - uni.onBluetoothDeviceFound(deviceFoundHandler) + 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) => { - uni.stopBluetoothDevicesDiscovery({ - complete: () => { + clearFallbackTimer() + + searchSessionId += 1 + acceptingDeviceEvents = false + + controller.ble.stopDiscovery() + .catch(() => {}) + .finally(() => { setSearching(false) if (showStopToast) { showToast('已停止搜索') } resolve() - } - }) + }) }) } function cleanupDiscovery() { - if (isSearching) { - uni.stopBluetoothDevicesDiscovery({ - complete: () => {} - }) - } + clearFallbackTimer() + searchSessionId += 1 + acceptingDeviceEvents = false setSearching(false) - if (deviceFoundHandler && uni.offBluetoothDeviceFound) { - uni.offBluetoothDeviceFound(deviceFoundHandler) + if (offDeviceFound) { + offDeviceFound() + offDeviceFound = null } - deviceFoundHandler = null - } - - function getBluetoothErrorText(error) { - const errCode = error && error.errCode - return BLUETOOTH_ERROR_MESSAGES[errCode] || DEFAULT_BLUETOOTH_ERROR_MESSAGE - } - - async function startSearch() { - setDeviceMap({}) - - try { - await openBluetoothAdapter() - registerDeviceFoundHandler() - - try { - await startDiscoveryWithServices([TARGET_SERVICE_UUID]) - } catch (error) { - await startDiscoveryWithServices([]) - } - - setSearching(true) - } catch (error) { - setSearching(false) - showToast(getBluetoothErrorText(error)) - throw error + if (offAdapterState) { + offAdapterState() + offAdapterState = null } + + if (!initialized) { + return + } + + initialized = false + + controller.cleanup().catch(() => {}) } return { diff --git a/main.js b/main.js index c16539f..5b551d3 100644 --- a/main.js +++ b/main.js @@ -1,6 +1,7 @@ import App from './App' import { setupAuthInterceptors } from './utils/auth' +// 应用启动时注册统一路由鉴权拦截器(内部有幂等保护)。 setupAuthInterceptors() // #ifndef VUE3 @@ -16,6 +17,10 @@ app.$mount() // #ifdef VUE3 import { createSSRApp } from 'vue' + +/** + * uni-app Vue3 入口。 + */ export function createApp() { const app = createSSRApp(App) return { diff --git a/pages/device/add.vue b/pages/device/add.vue index 779595b..9fca61d 100644 --- a/pages/device/add.vue +++ b/pages/device/add.vue @@ -1,3 +1,4 @@ + diff --git a/pages/event/index.vue b/pages/event/index.vue index d66f992..2c7b4cf 100644 --- a/pages/event/index.vue +++ b/pages/event/index.vue @@ -1,3 +1,4 @@ + diff --git a/pages/index/index.vue b/pages/index/index.vue index 2c7cda2..e178e63 100644 --- a/pages/index/index.vue +++ b/pages/index/index.vue @@ -1,3 +1,4 @@ +