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 @@
+
@@ -8,6 +9,7 @@
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 @@
+
@@ -18,6 +19,7 @@
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 @@
+
@@ -35,9 +36,11 @@ import { navigateTo } from '@/utils/navigation'
export default {
methods: {
+ // 进入设备添加页(后续会承接蓝牙配网流程入口)。
goAdd() {
navigateTo(ROUTES.DEVICE_ADD)
},
+ // 进入设备配置页。
goConfig() {
navigateTo(ROUTES.DEVICE_CONFIG)
}
diff --git a/pages/mine/index.vue b/pages/mine/index.vue
index 4545552..3de7c58 100644
--- a/pages/mine/index.vue
+++ b/pages/mine/index.vue
@@ -1,3 +1,4 @@
+
@@ -68,17 +69,20 @@ export default {
phoneText() {
return this.userInfo?.mobile || '未绑定'
},
+ // 根据登录类型展示不同文案。
loginTip() {
return this.userInfo?.loginType === 'mobile' ? '手机号验证码登录' : '微信登录'
}
},
onShow() {
+ // 每次回到页面时刷新一次本地缓存用户信息。
this.userInfo = getUser()
},
methods: {
goConfig() {
navigateTo(ROUTES.DEVICE_CONFIG)
},
+ // 已登录时不再重复跳转登录页。
goLogin() {
if (isLoggedIn()) {
return
@@ -86,6 +90,7 @@ export default {
navigateTo(LOGIN_PAGE)
},
+ // 退出时清理本地登录态并回到首页。
logout() {
clearAuth()
showToast('已退出登录')
diff --git a/styles/common.css b/styles/common.css
index 6b42b78..d44cf3a 100644
--- a/styles/common.css
+++ b/styles/common.css
@@ -1,8 +1,10 @@
+/* 页面通用背景基线:统一浅灰底色,减少各页面重复定义。 */
.page-base {
min-height: 100vh;
background: #f1f1f4;
}
+/* 顶部导航通用布局:居中标题 + 左右扩展占位。 */
.top-nav-base {
height: 120rpx;
padding: 24rpx 24rpx 0;
@@ -19,6 +21,7 @@
color: #222222;
}
+/* 空状态通用样式,供首页/事件页等无数据场景复用。 */
.empty-state {
display: flex;
flex-direction: column;
@@ -40,6 +43,7 @@
font-weight: 600;
}
+/* 卡片外观基类。 */
.card-base {
background: #ffffff;
border-radius: 24rpx;
diff --git a/uni.promisify.adaptor.js b/uni.promisify.adaptor.js
index 5fec4f3..65b2354 100644
--- a/uni.promisify.adaptor.js
+++ b/uni.promisify.adaptor.js
@@ -1,13 +1,19 @@
+// uni-app Promise 适配:把 [err, data] 风格的返回值统一为 resolve/reject。
uni.addInterceptor({
returnValue (res) {
- if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
- return res;
+ if (!(!!res && (typeof res === 'object' || typeof res === 'function') && typeof res.then === 'function')) {
+ return res
}
+
return new Promise((resolve, reject) => {
- res.then((res) => {
- if (!res) return resolve(res)
- return res[0] ? reject(res[0]) : resolve(res[1])
- });
- });
- },
-});
\ No newline at end of file
+ res.then((value) => {
+ if (!value) {
+ return resolve(value)
+ }
+
+ // uni API Promise 通常返回 [error, result]。
+ return value[0] ? reject(value[0]) : resolve(value[1])
+ })
+ })
+ }
+})
diff --git a/utils/api/user.js b/utils/api/user.js
index 77ed7b4..03bad48 100644
--- a/utils/api/user.js
+++ b/utils/api/user.js
@@ -1,3 +1,4 @@
+// 统一复用 api/user.js 的用户相关接口,供页面层按 utils 路径导入。
export {
getUserProfile,
loginByMobile,
diff --git a/utils/ble/README.md b/utils/ble/README.md
new file mode 100644
index 0000000..b465370
--- /dev/null
+++ b/utils/ble/README.md
@@ -0,0 +1,5 @@
+# BLE 模块说明
+
+- 核心入口: `utils/ble/index.js`
+- 推荐使用: `createUnifiedBleController`
+- 本模块目标: 在不改变业务语义的前提下,抽离连接/鉴权/WiFi/雷达/解析公共能力,兼容 ED713 与 ED719。
diff --git a/utils/ble/core/bleCore.js b/utils/ble/core/bleCore.js
new file mode 100644
index 0000000..b56d39d
--- /dev/null
+++ b/utils/ble/core/bleCore.js
@@ -0,0 +1,548 @@
+import { createEventBus } from './eventBus'
+import { createBleState, BLE_STAGE } from './state'
+import { createBleError, getBleErrorText, normalizeBleError } from './errors'
+import { uint8ArrayToArrayBuffer, toUint8Array } from '../utils/bytes'
+import { toFullUuid, uuidEquals, uuidInList } from '../utils/uuid'
+import { runWithRetry } from './retry'
+import { getBleProfile, DEVICE_PROFILE } from '../protocols/profiles'
+
+// 把 uni 的 success/fail 回调风格统一成 Promise,便于串联 async 流程。
+function promisifyUniApi(apiName, params = {}) {
+ return new Promise((resolve, reject) => {
+ uni[apiName]({
+ ...params,
+ success: (res) => resolve(res),
+ fail: (err) => reject(err)
+ })
+ })
+}
+
+/**
+ * BLE 核心能力封装:
+ * 负责适配器生命周期、扫描、连接、服务/特征发现、读写与通知监听。
+ */
+export function createBleCore(options = {}) {
+ const {
+ reconnect = {
+ enabled: true,
+ retries: 2,
+ delay: 1200
+ },
+ logger = console,
+ profileId = DEVICE_PROFILE.UNKNOWN
+ } = options
+
+ const eventBus = createEventBus()
+ const state = createBleState()
+ let currentProfile = getBleProfile(profileId)
+ let deviceFoundHandler = null
+ let adapterStateHandler = null
+ let connectionStateHandler = null
+ let characteristicValueHandler = null
+
+ // 返回状态快照,避免调用方直接改写内部状态对象。
+ function snapshotState() {
+ return {
+ ...state,
+ auth: { ...state.auth },
+ deviceMap: { ...state.deviceMap },
+ characteristics: { ...state.characteristics }
+ }
+ }
+
+ // 统一 patch 状态并广播 state:change,保证 UI 状态来源单一。
+ function patchState(partialState) {
+ Object.assign(state, partialState)
+ eventBus.emit('state:change', snapshotState())
+ }
+
+ function setStage(stage) {
+ patchState({ stage })
+ }
+
+ function setProfile(nextProfileId) {
+ currentProfile = getBleProfile(nextProfileId)
+ patchState({ profileId: currentProfile.id })
+ }
+
+ function getProfile() {
+ return currentProfile
+ }
+
+ function setAuthState(partialAuth = {}) {
+ patchState({
+ auth: {
+ ...state.auth,
+ ...partialAuth
+ }
+ })
+ }
+
+ function emit(eventName, payload) {
+ eventBus.emit(eventName, payload)
+ }
+
+ function emitError(error) {
+ const normalized = normalizeBleError(error)
+ patchState({ lastError: normalized })
+ eventBus.emit('error', normalized)
+ return normalized
+ }
+
+ async function openAdapter() {
+ try {
+ await promisifyUniApi('openBluetoothAdapter')
+ patchState({ available: true })
+ setStage(BLE_STAGE.ADAPTER_OPENED)
+ return true
+ } catch (error) {
+ throw emitError(error)
+ }
+ }
+
+ async function closeAdapter() {
+ try {
+ await promisifyUniApi('closeBluetoothAdapter')
+ } catch (error) {
+ logger.warn('[BLE] close adapter failed', error)
+ }
+
+ patchState({
+ stage: BLE_STAGE.IDLE,
+ available: false,
+ discovering: false,
+ connected: false,
+ deviceId: '',
+ serviceId: '',
+ characteristics: {}
+ })
+ }
+
+ async function getAdapterState() {
+ try {
+ const result = await promisifyUniApi('getBluetoothAdapterState')
+ patchState({
+ available: Boolean(result.available),
+ discovering: Boolean(result.discovering)
+ })
+ return result
+ } catch (error) {
+ throw emitError(error)
+ }
+ }
+
+ function watchAdapterState() {
+ if (adapterStateHandler) {
+ return
+ }
+
+ adapterStateHandler = (result) => {
+ patchState({
+ available: Boolean(result.available),
+ discovering: Boolean(result.discovering)
+ })
+ eventBus.emit('adapter:state', result)
+
+ if (!result.available) {
+ setStage(BLE_STAGE.DISCONNECTED)
+ }
+ }
+
+ uni.onBluetoothAdapterStateChange(adapterStateHandler)
+ }
+
+ function unwatchAdapterState() {
+ if (!adapterStateHandler) {
+ return
+ }
+
+ if (uni.offBluetoothAdapterStateChange) {
+ uni.offBluetoothAdapterStateChange(adapterStateHandler)
+ }
+
+ adapterStateHandler = null
+ }
+
+ /**
+ * 启动设备扫描。
+ * services 传入时会做 UUID 标准化,用于只扫描目标服务设备。
+ */
+ async function startDiscovery(params = {}) {
+ const {
+ services = [],
+ allowDuplicatesKey = true,
+ interval = 0,
+ powerLevel = 'high'
+ } = params
+
+ try {
+ if (!state.available) {
+ await openAdapter()
+ }
+
+ const options = {
+ allowDuplicatesKey,
+ interval
+ }
+
+ if (services.length) {
+ options.services = services.map((uuid) => toFullUuid(uuid) || uuid)
+ }
+
+ if (powerLevel) {
+ options.powerLevel = powerLevel
+ }
+
+ await promisifyUniApi('startBluetoothDevicesDiscovery', options)
+ patchState({ discovering: true, deviceMap: {} })
+ setStage(BLE_STAGE.DISCOVERING)
+ return true
+ } catch (error) {
+ throw emitError(error)
+ }
+ }
+
+ async function stopDiscovery() {
+ try {
+ await promisifyUniApi('stopBluetoothDevicesDiscovery')
+ } catch (error) {
+ logger.warn('[BLE] stop discovery failed', error)
+ }
+
+ patchState({ discovering: false })
+ }
+
+ function watchDeviceFound() {
+ if (deviceFoundHandler) {
+ return
+ }
+
+ deviceFoundHandler = (result) => {
+ const list = Array.isArray(result.devices) ? result.devices : [result]
+ const nextMap = { ...state.deviceMap }
+
+ // 基于 deviceId 做去重与增量更新,避免列表无限叠加。
+ list.forEach((device) => {
+ if (!device || !device.deviceId) {
+ return
+ }
+
+ const previous = nextMap[device.deviceId] || {}
+ nextMap[device.deviceId] = {
+ ...previous,
+ ...device,
+ showName: device.name || device.localName || previous.showName || '未命名设备'
+ }
+ })
+
+ patchState({ deviceMap: nextMap })
+ eventBus.emit('device:found', Object.values(nextMap))
+ }
+
+ uni.onBluetoothDeviceFound(deviceFoundHandler)
+ }
+
+ function unwatchDeviceFound() {
+ if (!deviceFoundHandler) {
+ return
+ }
+
+ if (uni.offBluetoothDeviceFound) {
+ uni.offBluetoothDeviceFound(deviceFoundHandler)
+ }
+
+ deviceFoundHandler = null
+ }
+
+ async function connect(deviceId, options = {}) {
+ if (!deviceId) {
+ throw emitError(createBleError(10013, 'deviceId 不能为空'))
+ }
+
+ const {
+ timeout = 15000,
+ autoReconnect = reconnect.enabled
+ } = options
+
+ const runConnect = async () => {
+ setStage(BLE_STAGE.CONNECTING)
+ await promisifyUniApi('createBLEConnection', { deviceId, timeout })
+ patchState({ connected: true, deviceId })
+ setStage(BLE_STAGE.CONNECTED)
+ return true
+ }
+
+ try {
+ if (!autoReconnect) {
+ return await runConnect()
+ }
+
+ return await runWithRetry(runConnect, {
+ retries: reconnect.retries,
+ delay: reconnect.delay,
+ shouldRetry: (error) => {
+ const normalized = normalizeBleError(error)
+ return [10003, 10006, 10012].includes(normalized.code)
+ }
+ })
+ } catch (error) {
+ throw emitError(error)
+ }
+ }
+
+ async function disconnect(deviceId = state.deviceId) {
+ if (!deviceId) {
+ return
+ }
+
+ try {
+ await promisifyUniApi('closeBLEConnection', { deviceId })
+ } catch (error) {
+ logger.warn('[BLE] close connection failed', error)
+ }
+
+ patchState({ connected: false, deviceId: '', serviceId: '', characteristics: {} })
+ setStage(BLE_STAGE.DISCONNECTED)
+ }
+
+ function watchConnectionChange() {
+ if (connectionStateHandler) {
+ return
+ }
+
+ connectionStateHandler = (result) => {
+ if (state.deviceId && result.deviceId !== state.deviceId) {
+ return
+ }
+
+ patchState({ connected: Boolean(result.connected) })
+ eventBus.emit('connection:change', result)
+
+ if (!result.connected) {
+ setStage(BLE_STAGE.DISCONNECTED)
+ }
+ }
+
+ uni.onBLEConnectionStateChange(connectionStateHandler)
+ }
+
+ function unwatchConnectionChange() {
+ if (!connectionStateHandler) {
+ return
+ }
+
+ if (uni.offBLEConnectionStateChange) {
+ uni.offBLEConnectionStateChange(connectionStateHandler)
+ }
+
+ connectionStateHandler = null
+ }
+
+ /**
+ * 发现并锁定目标服务(协议要求 0x00F4)。
+ */
+ async function discoverServices(deviceId = state.deviceId) {
+ if (!deviceId) {
+ throw emitError(createBleError(10013, 'discoverServices 缺少 deviceId'))
+ }
+
+ try {
+ const result = await promisifyUniApi('getBLEDeviceServices', { deviceId })
+ const services = result.services || []
+ const targetService = services.find((service) => uuidEquals(service.uuid, currentProfile.uuids.service))
+
+ if (!targetService) {
+ throw createBleError(10004, '设备未暴露目标服务 0x00F4')
+ }
+
+ patchState({ serviceId: targetService.uuid })
+ setStage(BLE_STAGE.SERVICE_DISCOVERED)
+ return {
+ services,
+ targetService
+ }
+ } catch (error) {
+ throw emitError(error)
+ }
+ }
+
+ async function discoverCharacteristics(options = {}) {
+ const deviceId = options.deviceId || state.deviceId
+ const serviceId = options.serviceId || state.serviceId
+
+ if (!deviceId || !serviceId) {
+ throw emitError(createBleError(10013, 'discoverCharacteristics 缺少 deviceId/serviceId'))
+ }
+
+ try {
+ const result = await promisifyUniApi('getBLEDeviceCharacteristics', { deviceId, serviceId })
+ const list = result.characteristics || []
+ const profile = getProfile()
+
+ // 缓存业务侧常用特征,减少后续每次操作时的查找成本。
+ const characteristicMap = {
+ wifi: list.find((item) => uuidEquals(item.uuid, profile.uuids.wifi)) || null,
+ key: list.find((item) => uuidEquals(item.uuid, profile.uuids.key)) || null,
+ cmd: list.find((item) => uuidEquals(item.uuid, profile.uuids.cmd)) || null,
+ msg: list.find((item) => uuidEquals(item.uuid, profile.uuids.msg)) || null,
+ state: list.find((item) => uuidEquals(item.uuid, profile.uuids.state)) || null,
+ radar: list.find((item) => uuidEquals(item.uuid, profile.uuids.radar)) || null,
+ all: list
+ }
+
+ patchState({ characteristics: characteristicMap })
+ return characteristicMap
+ } catch (error) {
+ throw emitError(error)
+ }
+ }
+
+ async function readCharacteristic(characteristicId, options = {}) {
+ const deviceId = options.deviceId || state.deviceId
+ const serviceId = options.serviceId || state.serviceId
+
+ if (!deviceId || !serviceId || !characteristicId) {
+ throw emitError(createBleError(10013, 'readCharacteristic 参数缺失'))
+ }
+
+ try {
+ await promisifyUniApi('readBLECharacteristicValue', {
+ deviceId,
+ serviceId,
+ characteristicId
+ })
+ return true
+ } catch (error) {
+ throw emitError(error)
+ }
+ }
+
+ async function writeCharacteristic(characteristicId, value, options = {}) {
+ const deviceId = options.deviceId || state.deviceId
+ const serviceId = options.serviceId || state.serviceId
+
+ if (!deviceId || !serviceId || !characteristicId) {
+ throw emitError(createBleError(10013, 'writeCharacteristic 参数缺失'))
+ }
+
+ try {
+ await promisifyUniApi('writeBLECharacteristicValue', {
+ deviceId,
+ serviceId,
+ characteristicId,
+ value: uint8ArrayToArrayBuffer(toUint8Array(value))
+ })
+ return true
+ } catch (error) {
+ throw emitError(error)
+ }
+ }
+
+ async function notifyCharacteristic(characteristicId, stateFlag = true, options = {}) {
+ const deviceId = options.deviceId || state.deviceId
+ const serviceId = options.serviceId || state.serviceId
+
+ if (!deviceId || !serviceId || !characteristicId) {
+ throw emitError(createBleError(10013, 'notifyCharacteristic 参数缺失'))
+ }
+
+ try {
+ await promisifyUniApi('notifyBLECharacteristicValueChange', {
+ deviceId,
+ serviceId,
+ characteristicId,
+ state: Boolean(stateFlag)
+ })
+ return true
+ } catch (error) {
+ throw emitError(error)
+ }
+ }
+
+ function watchCharacteristicValue() {
+ if (characteristicValueHandler) {
+ return
+ }
+
+ // 统一转为 Uint8Array,简化上层解析逻辑。
+ characteristicValueHandler = (result) => {
+ eventBus.emit('characteristic:value', {
+ ...result,
+ value: toUint8Array(result.value)
+ })
+ }
+
+ uni.onBLECharacteristicValueChange(characteristicValueHandler)
+ }
+
+ function unwatchCharacteristicValue() {
+ if (!characteristicValueHandler) {
+ return
+ }
+
+ if (uni.offBLECharacteristicValueChange) {
+ uni.offBLECharacteristicValueChange(characteristicValueHandler)
+ }
+
+ characteristicValueHandler = null
+ }
+
+ function listDiscoveredDevices() {
+ return Object.values(state.deviceMap)
+ }
+
+ function pickDeviceByService(serviceUuid = currentProfile.uuids.service) {
+ const list = listDiscoveredDevices()
+
+ return list.find((device) => uuidInList(serviceUuid, device.advertisServiceUUIDs || [])) || null
+ }
+
+ async function cleanup() {
+ // 清理顺序:先停扫描与连接,再移除监听,最后关闭适配器。
+ await stopDiscovery()
+ await disconnect()
+
+ unwatchDeviceFound()
+ unwatchAdapterState()
+ unwatchConnectionChange()
+ unwatchCharacteristicValue()
+
+ await closeAdapter()
+ eventBus.clear()
+ }
+
+ return {
+ openAdapter,
+ closeAdapter,
+ getAdapterState,
+ watchAdapterState,
+ unwatchAdapterState,
+ startDiscovery,
+ stopDiscovery,
+ watchDeviceFound,
+ unwatchDeviceFound,
+ connect,
+ disconnect,
+ watchConnectionChange,
+ unwatchConnectionChange,
+ discoverServices,
+ discoverCharacteristics,
+ readCharacteristic,
+ writeCharacteristic,
+ notifyCharacteristic,
+ watchCharacteristicValue,
+ unwatchCharacteristicValue,
+ pickDeviceByService,
+ listDiscoveredDevices,
+ getState: snapshotState,
+ setStage,
+ setProfile,
+ getProfile,
+ setAuthState,
+ emit,
+ on: eventBus.on,
+ once: eventBus.once,
+ cleanup,
+ getBleErrorText
+ }
+}
diff --git a/utils/ble/core/controller.js b/utils/ble/core/controller.js
new file mode 100644
index 0000000..f669ff8
--- /dev/null
+++ b/utils/ble/core/controller.js
@@ -0,0 +1,184 @@
+import { createBleCore } from './bleCore'
+import { createAuthModule } from '../modules/auth'
+import { createWifiModule } from '../modules/wifi'
+import { createRadarModule } from '../modules/radar'
+import { parseStateCode, parseDeviceInfo } from '../parsers'
+import { DEVICE_PROFILE, guessProfileByRadarFrame } from '../protocols/profiles'
+import { uuidEquals } from '../utils/uuid'
+
+function createCharacteristicDispatcher(ble) {
+ return (event) => {
+ const state = ble.getState()
+ const profile = ble.getProfile()
+ const current = state.characteristics
+
+ if (current.state && uuidEquals(event.characteristicId, current.state.uuid)) {
+ const stateCode = parseStateCode(event.value)
+ ble.setAuthState({
+ lastStateCode: stateCode,
+ keyMatched: stateCode === 7 || stateCode === 5 || state.auth.keyMatched
+ })
+
+ ble.emit('protocol:state', {
+ ...event,
+ profileId: profile.id,
+ stateCode
+ })
+ return
+ }
+
+ if (current.msg && uuidEquals(event.characteristicId, current.msg.uuid)) {
+ ble.emit('protocol:msg', {
+ ...event,
+ profileId: profile.id,
+ deviceInfo: parseDeviceInfo(event.value)
+ })
+ return
+ }
+
+ if (current.radar && uuidEquals(event.characteristicId, current.radar.uuid)) {
+ const guessed = guessProfileByRadarFrame(event.value)
+ if (state.profileId === DEVICE_PROFILE.UNKNOWN && guessed !== DEVICE_PROFILE.UNKNOWN) {
+ ble.setProfile(guessed)
+ }
+
+ ble.emit('protocol:radar', {
+ ...event,
+ profileId: ble.getProfile().id
+ })
+ return
+ }
+
+ ble.emit('protocol:raw', {
+ ...event,
+ profileId: profile.id
+ })
+ }
+}
+
+export function createUnifiedBleController(options = {}) {
+ const ble = createBleCore(options)
+ const auth = createAuthModule(ble)
+ const wifi = createWifiModule(ble)
+ const radar = createRadarModule(ble)
+
+ const characteristicDispatcher = createCharacteristicDispatcher(ble)
+ let offCharacteristic = null
+
+ function bindDispatcher() {
+ if (offCharacteristic) {
+ return
+ }
+
+ offCharacteristic = ble.on('characteristic:value', characteristicDispatcher)
+ }
+
+ function unbindDispatcher() {
+ if (!offCharacteristic) {
+ return
+ }
+
+ offCharacteristic()
+ offCharacteristic = null
+ }
+
+ async function init() {
+ await ble.openAdapter()
+ ble.watchAdapterState()
+ ble.watchConnectionChange()
+ ble.watchCharacteristicValue()
+ bindDispatcher()
+ }
+
+ async function scanAndPickDevice({
+ serviceUuid,
+ scanDuration = 5000,
+ fallbackWithoutFilter = true
+ } = {}) {
+ const profile = ble.getProfile()
+ const targetService = serviceUuid || profile.uuids.service
+
+ ble.watchDeviceFound()
+
+ await ble.startDiscovery({ services: [targetService] })
+ await new Promise((resolve) => setTimeout(resolve, scanDuration))
+ await ble.stopDiscovery()
+
+ let selected = ble.pickDeviceByService(targetService)
+
+ if (!selected && fallbackWithoutFilter) {
+ await ble.startDiscovery({ services: [] })
+ await new Promise((resolve) => setTimeout(resolve, scanDuration))
+ await ble.stopDiscovery()
+ selected = ble.pickDeviceByService(targetService) || ble.listDiscoveredDevices()[0] || null
+ }
+
+ return selected
+ }
+
+ /**
+ * 连接后严格执行协议前置:
+ * - 发现服务和特征
+ * - 订阅 UUID_STATE / UUID_RADAR
+ */
+ async function connectAndDiscover(deviceId) {
+ await ble.connect(deviceId)
+ await ble.discoverServices(deviceId)
+ const chars = await ble.discoverCharacteristics()
+
+ if (chars.state && chars.state.uuid) {
+ await ble.notifyCharacteristic(chars.state.uuid, true)
+ }
+
+ if (chars.radar && chars.radar.uuid) {
+ await ble.notifyCharacteristic(chars.radar.uuid, true)
+ }
+
+ return chars
+ }
+
+ /**
+ * 标准流程:连接 -> 订阅 -> 鉴权。
+ * 鉴权内部会按 bindStatus 分支处理绑定/匹配。
+ */
+ async function standardConnectFlow({ deviceId, key }) {
+ await init()
+
+ let targetDeviceId = deviceId
+ if (!targetDeviceId) {
+ const device = await scanAndPickDevice()
+ if (!device) {
+ throw new Error('未找到可连接设备')
+ }
+
+ targetDeviceId = device.deviceId
+ }
+
+ await connectAndDiscover(targetDeviceId)
+ const authResult = await auth.ensureAuthorized(key)
+
+ return {
+ deviceId: targetDeviceId,
+ authResult,
+ state: ble.getState(),
+ profile: ble.getProfile()
+ }
+ }
+
+ async function cleanup() {
+ unbindDispatcher()
+ await ble.cleanup()
+ }
+
+ return {
+ ble,
+ auth,
+ wifi,
+ radar,
+ init,
+ scanAndPickDevice,
+ connectAndDiscover,
+ standardConnectFlow,
+ cleanup
+ }
+}
diff --git a/utils/ble/core/errors.js b/utils/ble/core/errors.js
new file mode 100644
index 0000000..9d247d3
--- /dev/null
+++ b/utils/ble/core/errors.js
@@ -0,0 +1,63 @@
+// 微信 BLE 常见 errCode 到可读文案映射。
+const ERROR_MESSAGES = Object.freeze({
+ 10000: '未初始化蓝牙适配器',
+ 10001: '当前蓝牙适配器不可用',
+ 10002: '没有找到指定设备',
+ 10003: '连接失败',
+ 10004: '没有找到指定服务',
+ 10005: '没有找到指定特征值',
+ 10006: '当前连接已断开',
+ 10007: '当前特征值不支持此操作',
+ 10008: '其余所有系统上报的异常',
+ 10009: 'Android 系统特有,系统版本低于 4.3 不支持 BLE',
+ 10010: '已连接',
+ 10011: '配对设备需要配对码',
+ 10012: '连接超时',
+ 10013: '连接 deviceId 为空或者是格式不正确',
+ 10014: 'writeCharacteristicValue: 找不到指定设备',
+ 10015: 'readCharacteristicValue: 找不到指定设备',
+ 10016: 'openBluetoothAdapter: 未找到蓝牙适配器',
+ 10017: 'startBluetoothDevicesDiscovery: 未找到蓝牙适配器',
+ 10018: 'stopBluetoothDevicesDiscovery: 调用失败',
+ 10019: 'getBluetoothDevices: 调用失败',
+ 10020: 'getConnectedBluetoothDevices: 调用失败'
+})
+
+/**
+ * 构造统一 BLE Error,方便上层直接读取 code/message。
+ */
+export function createBleError(code, message, payload = {}) {
+ const err = new Error(message || ERROR_MESSAGES[code] || '蓝牙操作失败')
+ err.name = 'BleError'
+ err.code = code
+ Object.assign(err, payload)
+ return err
+}
+
+/**
+ * 归一化任意错误对象(uni err / 原生 Error / 空值)。
+ */
+export function normalizeBleError(error, fallbackMessage = '蓝牙操作失败') {
+ if (!error) {
+ return createBleError(-1, fallbackMessage)
+ }
+
+ if (error.name === 'BleError') {
+ return error
+ }
+
+ const errCode = typeof error.errCode === 'number' ? error.errCode : -1
+ const message = error.errMsg || ERROR_MESSAGES[errCode] || fallbackMessage
+ return createBleError(errCode, message, { raw: error })
+}
+
+/**
+ * 安全获取错误文案,避免页面层处理 null/undefined 分支。
+ */
+export function getBleErrorText(error, fallbackText = '蓝牙操作失败') {
+ if (!error) {
+ return fallbackText
+ }
+
+ return error.message || ERROR_MESSAGES[error.code] || fallbackText
+}
diff --git a/utils/ble/core/eventBus.js b/utils/ble/core/eventBus.js
new file mode 100644
index 0000000..2d1e227
--- /dev/null
+++ b/utils/ble/core/eventBus.js
@@ -0,0 +1,64 @@
+/**
+ * 轻量事件总线:用于 BLE Core 与业务模块之间的事件解耦。
+ */
+export function createEventBus() {
+ const listeners = {}
+
+ function on(eventName, handler) {
+ if (!listeners[eventName]) {
+ listeners[eventName] = new Set()
+ }
+
+ listeners[eventName].add(handler)
+
+ // 返回取消订阅函数,方便调用方在页面卸载时回收监听。
+ return () => {
+ off(eventName, handler)
+ }
+ }
+
+ function once(eventName, handler) {
+ const unsubscribe = on(eventName, (payload) => {
+ unsubscribe()
+ handler(payload)
+ })
+
+ return unsubscribe
+ }
+
+ function off(eventName, handler) {
+ if (!listeners[eventName]) {
+ return
+ }
+
+ listeners[eventName].delete(handler)
+ }
+
+ function emit(eventName, payload) {
+ if (!listeners[eventName]) {
+ return
+ }
+
+ listeners[eventName].forEach((handler) => {
+ try {
+ handler(payload)
+ } catch (error) {
+ console.error('[BLE event handler error]', eventName, error)
+ }
+ })
+ }
+
+ function clear() {
+ Object.keys(listeners).forEach((eventName) => {
+ listeners[eventName].clear()
+ })
+ }
+
+ return {
+ on,
+ once,
+ off,
+ emit,
+ clear
+ }
+}
diff --git a/utils/ble/core/packet.js b/utils/ble/core/packet.js
new file mode 100644
index 0000000..d94d601
--- /dev/null
+++ b/utils/ble/core/packet.js
@@ -0,0 +1,56 @@
+import { splitUint8Array, toUint8Array } from '../utils/bytes'
+import { utf8ToBytes } from '../utils/hex'
+
+/**
+ * 按协议将 payload 拆成 A7 分包:
+ * 第 1 字节固定包头 0xA7,第 2 字节高4位为总包数、低4位为包序号。
+ */
+export function buildA7Packets(payload, packetConfig = {}) {
+ const {
+ head = 0xA7,
+ chunkSize = 18,
+ maxChunk = 15
+ } = packetConfig
+
+ const bytes = typeof payload === 'string' ? utf8ToBytes(payload) : toUint8Array(payload)
+ const chunks = splitUint8Array(bytes, chunkSize)
+
+ if (chunks.length > maxChunk) {
+ throw new Error(`分包数量超过上限: ${chunks.length} > ${maxChunk}`)
+ }
+
+ return chunks.map((chunk, index) => {
+ const packetIndex = index + 1
+ const lenByte = ((chunks.length & 0x0F) << 4) | (packetIndex & 0x0F)
+ const packet = new Uint8Array(2 + chunk.length)
+
+ packet[0] = head
+ packet[1] = lenByte
+ packet.set(chunk, 2)
+
+ return packet
+ })
+}
+
+/**
+ * 合并多个 A7 分包数据体(去掉前两字节头信息后拼接)。
+ */
+export function concatA7PacketData(packets = []) {
+ const sorted = [...packets].sort((a, b) => {
+ const indexA = a[1] & 0x0F
+ const indexB = b[1] & 0x0F
+ return indexA - indexB
+ })
+
+ const totalLength = sorted.reduce((sum, packet) => sum + Math.max(packet.length - 2, 0), 0)
+ const merged = new Uint8Array(totalLength)
+ let offset = 0
+
+ sorted.forEach((packet) => {
+ const body = packet.slice(2)
+ merged.set(body, offset)
+ offset += body.length
+ })
+
+ return merged
+}
diff --git a/utils/ble/core/retry.js b/utils/ble/core/retry.js
new file mode 100644
index 0000000..4667061
--- /dev/null
+++ b/utils/ble/core/retry.js
@@ -0,0 +1,40 @@
+// 简单 sleep 工具,供重试间隔等待使用。
+export function sleep(ms) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms)
+ })
+}
+
+/**
+ * 通用重试执行器。
+ * @param {(retryIndex:number)=>Promise} fn 被执行函数
+ * @param {object} options
+ * @param {number} options.retries 失败后最大重试次数
+ * @param {number} options.delay 每次重试前等待毫秒
+ * @param {(error:any,retryIndex:number)=>boolean} options.shouldRetry 是否重试
+ */
+export async function runWithRetry(fn, options = {}) {
+ const {
+ retries = 2,
+ delay = 500,
+ shouldRetry = () => true
+ } = options
+
+ let lastError = null
+
+ for (let index = 0; index <= retries; index += 1) {
+ try {
+ return await fn(index)
+ } catch (error) {
+ lastError = error
+
+ if (index >= retries || !shouldRetry(error, index)) {
+ throw error
+ }
+
+ await sleep(delay)
+ }
+ }
+
+ throw lastError
+}
diff --git a/utils/ble/core/state.js b/utils/ble/core/state.js
new file mode 100644
index 0000000..02fae55
--- /dev/null
+++ b/utils/ble/core/state.js
@@ -0,0 +1,38 @@
+// BLE 状态机阶段定义,便于页面层按阶段展示文案/按钮状态。
+export const BLE_STAGE = Object.freeze({
+ IDLE: 'IDLE',
+ ADAPTER_OPENED: 'ADAPTER_OPENED',
+ DISCOVERING: 'DISCOVERING',
+ CONNECTING: 'CONNECTING',
+ CONNECTED: 'CONNECTED',
+ SERVICE_DISCOVERED: 'SERVICE_DISCOVERED',
+ AUTHORIZING: 'AUTHORIZING',
+ AUTHORIZED: 'AUTHORIZED',
+ OPERATING: 'OPERATING',
+ DISCONNECTED: 'DISCONNECTED'
+})
+
+/**
+ * 创建 BLE 运行时状态。
+ * 注意:返回的是可变对象,由 bleCore 内部统一 patch。
+ */
+export function createBleState() {
+ return {
+ stage: BLE_STAGE.IDLE,
+ available: false,
+ discovering: false,
+ connected: false,
+ deviceId: '',
+ serviceId: '',
+ profileId: '',
+ reconnectCount: 0,
+ lastError: null,
+ deviceMap: {},
+ characteristics: {},
+ auth: {
+ bound: null,
+ keyMatched: false,
+ lastStateCode: null
+ }
+ }
+}
diff --git a/utils/ble/index.js b/utils/ble/index.js
new file mode 100644
index 0000000..d030f90
--- /dev/null
+++ b/utils/ble/index.js
@@ -0,0 +1,21 @@
+/**
+ * BLE 对外统一出口。
+ * 页面层/业务层应优先从此文件导入,避免直接依赖子模块内部路径。
+ */
+export { createBleCore } from './core/bleCore'
+export { createUnifiedBleController } from './core/controller'
+
+export { createAuthModule } from './modules/auth'
+export { createWifiModule } from './modules/wifi'
+export { createRadarModule } from './modules/radar'
+
+export { BLE_STAGE } from './core/state'
+export { DEVICE_PROFILE, getBleProfile, guessProfileByRadarFrame } from './protocols/profiles'
+
+export { buildA7Packets } from './core/packet'
+
+export { bytesToHex, hexToBytes, utf8ToBytes, bytesToUtf8, bytesToMac } from './utils/hex'
+export { splitToLowHigh, joinLowHigh, readUint16LE, readUint32LE, toSignedInt8 } from './utils/bytes'
+export { normalizeUuid, shortUuid, toFullUuid, uuidEquals, uuidInList } from './utils/uuid'
+
+export { parseStateCode, parseDeviceInfo, parseRadarData } from './parsers'
diff --git a/utils/ble/modules/auth.js b/utils/ble/modules/auth.js
new file mode 100644
index 0000000..750f009
--- /dev/null
+++ b/utils/ble/modules/auth.js
@@ -0,0 +1,180 @@
+import { buildA7Packets } from '../core/packet'
+import { parseDeviceInfo } from '../parsers'
+import { BLE_STAGE } from '../core/state'
+
+function waitForStateCode(ble, timeout = 6000) {
+ return new Promise((resolve, reject) => {
+ let timer = null
+ let off = null
+
+ const finish = (callback) => {
+ if (timer) {
+ clearTimeout(timer)
+ }
+
+ if (off) {
+ off()
+ }
+
+ callback()
+ }
+
+ timer = setTimeout(() => {
+ finish(() => reject(new Error('等待 UUID_STATE 超时')))
+ }, timeout)
+
+ off = ble.on('protocol:state', (payload) => {
+ finish(() => resolve(payload))
+ })
+ })
+}
+
+function generateRandomKey(length = 16) {
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'
+ let result = ''
+
+ for (let index = 0; index < length; index += 1) {
+ const random = Math.floor(Math.random() * chars.length)
+ result += chars[random]
+ }
+
+ return result
+}
+
+async function writePacketsToKeyCharacteristic(ble, keyText) {
+ const { key } = ble.getState().characteristics
+
+ if (!key || !key.uuid) {
+ throw new Error('未发现 UUID_KEY 特征值')
+ }
+
+ const profile = ble.getProfile()
+ const keyValue = String(keyText || '').trim()
+
+ if (keyValue.length !== profile.auth.keyLength) {
+ throw new Error(`密钥长度必须为 ${profile.auth.keyLength}`)
+ }
+
+ const packets = buildA7Packets(keyValue, profile.packet)
+ for (const packet of packets) {
+ await ble.writeCharacteristic(key.uuid, packet)
+ }
+
+ return keyValue
+}
+
+async function writeKeyAndExpectStates(ble, keyText, expectedStateCodes, options = {}) {
+ const keyValue = await writePacketsToKeyCharacteristic(ble, keyText)
+ const stateEvent = await waitForStateCode(ble, options.timeout || 6000)
+ const stateCode = stateEvent.stateCode
+
+ if (!expectedStateCodes.includes(stateCode)) {
+ throw new Error(`密钥流程状态异常,期望=${expectedStateCodes.join('/')} 实际=${stateCode}`)
+ }
+
+ ble.setStage(BLE_STAGE.AUTHORIZED)
+ ble.setAuthState({
+ keyMatched: true,
+ lastStateCode: stateCode
+ })
+
+ return {
+ success: true,
+ stateCode,
+ key: keyValue
+ }
+}
+
+export function createAuthModule(ble) {
+ async function readDeviceInfo() {
+ const { msg } = ble.getState().characteristics
+
+ if (!msg || !msg.uuid) {
+ throw new Error('未发现 UUID_MSG 特征值')
+ }
+
+ const valuePromise = new Promise((resolve) => {
+ const off = ble.on('protocol:msg', (payload) => {
+ off()
+ resolve(payload)
+ })
+ })
+
+ await ble.readCharacteristic(msg.uuid)
+ const payload = await valuePromise
+
+ return parseDeviceInfo(payload.value)
+ }
+
+ /**
+ * 严格遵循协议1.2绑定流程:
+ * 1) 先读取 UUID_MSG 获取 bindStatus;
+ * 2) bindStatus=0 -> 写随机密钥并等待 UUID_STATE=5(绑定成功);
+ * 3) bindStatus=1 -> 写已知密钥并等待 UUID_STATE=7(密钥匹配成功)。
+ *
+ * 兼容说明:当某些固件 MSG 未明确返回 0/1 时,回退为“已知密钥匹配优先”,
+ * 允许状态 7(匹配成功) 或 5(设备首次绑定成功)。
+ */
+ async function ensureAuthorized(keyText, options = {}) {
+ ble.setStage(BLE_STAGE.AUTHORIZING)
+
+ const info = await readDeviceInfo()
+ const bindStatus = Number(info.bindStatus)
+
+ ble.setAuthState({
+ bound: Number.isNaN(bindStatus) ? null : bindStatus,
+ keyMatched: false
+ })
+
+ // 未绑定:按协议执行绑定。
+ if (bindStatus === 0) {
+ const keyToBind = String(keyText || '').trim() || generateRandomKey(ble.getProfile().auth.keyLength)
+ const authResult = await writeKeyAndExpectStates(ble, keyToBind, [5], options)
+
+ return {
+ mode: 'BIND',
+ deviceInfo: info,
+ authResult,
+ key: keyToBind
+ }
+ }
+
+ // 已绑定:按协议执行密钥匹配。
+ if (bindStatus === 1) {
+ const keyToMatch = String(keyText || '').trim()
+ if (!keyToMatch) {
+ throw new Error('设备已绑定,必须提供已绑定密钥用于匹配')
+ }
+
+ const authResult = await writeKeyAndExpectStates(ble, keyToMatch, [7], options)
+
+ return {
+ mode: 'MATCH',
+ deviceInfo: info,
+ authResult,
+ key: keyToMatch
+ }
+ }
+
+ // 绑定状态不明确时,执行兼容分支(优先匹配,允许绑定成功)。
+ const fallbackKey = String(keyText || '').trim()
+ if (!fallbackKey) {
+ throw new Error(`无法识别的绑定状态: ${info.bindStatus},且未提供密钥`)
+ }
+
+ const fallbackResult = await writeKeyAndExpectStates(ble, fallbackKey, [7, 5], options)
+
+ return {
+ mode: 'UNKNOWN_BIND_STATE_FALLBACK',
+ deviceInfo: info,
+ authResult: fallbackResult,
+ key: fallbackKey
+ }
+ }
+
+ return {
+ readDeviceInfo,
+ ensureAuthorized,
+ generateRandomKey
+ }
+}
diff --git a/utils/ble/modules/radar.js b/utils/ble/modules/radar.js
new file mode 100644
index 0000000..ebe9673
--- /dev/null
+++ b/utils/ble/modules/radar.js
@@ -0,0 +1,88 @@
+import { parseRadarData } from '../parsers'
+
+export function createRadarModule(ble) {
+ function requireAuthorized() {
+ const state = ble.getState()
+
+ // 协议要求:获取雷达校准数据前必须先完成密钥匹配。
+ if (!state.auth || state.auth.keyMatched !== true) {
+ throw new Error('请先完成密钥匹配,再执行雷达命令')
+ }
+ }
+
+ async function sendCommand(commandByte) {
+ const { cmd } = ble.getState().characteristics
+
+ if (!cmd || !cmd.uuid) {
+ throw new Error('未发现 UUID_CMD 特征值')
+ }
+
+ await ble.writeCharacteristic(cmd.uuid, new Uint8Array([commandByte]))
+ }
+
+ async function startRadarStream() {
+ const profile = ble.getProfile()
+
+ // 协议1.2.2:先密钥匹配,再向 UUID_CMD 写 0xA1。
+ requireAuthorized()
+ await sendCommand(profile.radar.startCommand)
+ return true
+ }
+
+ async function stopRadarStream() {
+ const profile = ble.getProfile()
+
+ // 停止同样要求处于鉴权通过态,避免误发到未鉴权连接。
+ requireAuthorized()
+ await sendCommand(profile.radar.stopCommand)
+ return true
+ }
+
+ async function setNarrowMode(enabled) {
+ const profile = ble.getProfile()
+
+ if (!profile.cmd.supportsNarrowMode) {
+ throw new Error('当前设备不支持窄床模式设置')
+ }
+
+ requireAuthorized()
+ await sendCommand(enabled ? 0x7D : 0x7C)
+ }
+
+ async function writeFallParams(rawParams) {
+ const profile = ble.getProfile()
+
+ if (!profile.cmd.supportsFallParam67) {
+ throw new Error('当前设备不支持 0x67 跌倒参数写入')
+ }
+
+ requireAuthorized()
+
+ const payload = rawParams instanceof Uint8Array ? rawParams : new Uint8Array(rawParams)
+ const packet = new Uint8Array(1 + payload.length)
+ packet[0] = 0x67
+ packet.set(payload, 1)
+
+ const { cmd } = ble.getState().characteristics
+ if (!cmd || !cmd.uuid) {
+ throw new Error('未发现 UUID_CMD 特征值')
+ }
+
+ await ble.writeCharacteristic(cmd.uuid, packet)
+ }
+
+ function parseNotifyPayload(payload) {
+ const profile = ble.getProfile()
+ return parseRadarData(payload, profile.id)
+ }
+
+ return {
+ sendCommand,
+ startRadarStream,
+ stopRadarStream,
+ setNarrowMode,
+ writeFallParams,
+ parseNotifyPayload,
+ requireAuthorized
+ }
+}
diff --git a/utils/ble/modules/wifi.js b/utils/ble/modules/wifi.js
new file mode 100644
index 0000000..fcc9500
--- /dev/null
+++ b/utils/ble/modules/wifi.js
@@ -0,0 +1,96 @@
+import { buildA7Packets } from '../core/packet'
+
+/**
+ * 校验 WiFi 入参,规则来源于 profile.wifi。
+ */
+function validateWifi(profile, ssid, password) {
+ const errors = []
+
+ if (!ssid || ssid.length > profile.wifi.maxSsidLength) {
+ errors.push(`SSID 长度需在 1-${profile.wifi.maxSsidLength} 之间`)
+ }
+
+ if (password.length < profile.wifi.minPasswordLength || password.length > profile.wifi.maxPasswordLength) {
+ errors.push(`密码长度需在 ${profile.wifi.minPasswordLength}-${profile.wifi.maxPasswordLength} 之间`)
+ }
+
+ const invalidChars = profile.wifi.invalidChars || []
+ if (invalidChars.some((char) => ssid.includes(char))) {
+ errors.push(`SSID 不能包含特殊字符: ${invalidChars.join(' ')}`)
+ }
+
+ if (errors.length) {
+ throw new Error(errors.join(';'))
+ }
+}
+
+/**
+ * 监听协议状态通知,等待 WiFi 配网状态码返回。
+ */
+function waitForWifiState(ble, timeout = 8000) {
+ return new Promise((resolve, reject) => {
+ let timer = null
+ let off = null
+
+ const clearAll = () => {
+ if (timer) {
+ clearTimeout(timer)
+ }
+
+ if (off) {
+ off()
+ }
+ }
+
+ timer = setTimeout(() => {
+ clearAll()
+ reject(new Error('等待 WiFi 状态超时'))
+ }, timeout)
+
+ off = ble.on('protocol:state', (payload) => {
+ if (![1, 2, 3, 4].includes(payload.stateCode)) {
+ return
+ }
+
+ clearAll()
+ resolve(payload.stateCode)
+ })
+ })
+}
+
+/**
+ * WiFi 配网模块:负责打包并发送 ssid|password,随后等待状态通知。
+ */
+export function createWifiModule(ble) {
+ async function configureWifi(ssid, password, options = {}) {
+ const profile = ble.getProfile()
+ const targetSsid = String(ssid || '')
+ const targetPass = String(password || '')
+
+ validateWifi(profile, targetSsid, targetPass)
+
+ const { wifi } = ble.getState().characteristics
+ if (!wifi || !wifi.uuid) {
+ throw new Error('未发现 UUID_WIFI 特征值')
+ }
+
+ const payload = `${targetSsid}|${targetPass}`
+ const packets = buildA7Packets(payload, profile.packet)
+
+ // 按分包顺序逐包发送,保持设备端解析一致性。
+ for (const packet of packets) {
+ await ble.writeCharacteristic(wifi.uuid, packet)
+ }
+
+ const stateCode = await waitForWifiState(ble, options.timeout || 8000)
+
+ return {
+ success: stateCode === 3,
+ stateCode
+ }
+ }
+
+ return {
+ configureWifi
+ }
+}
diff --git a/utils/ble/parsers/commonParser.js b/utils/ble/parsers/commonParser.js
new file mode 100644
index 0000000..cfcb298
--- /dev/null
+++ b/utils/ble/parsers/commonParser.js
@@ -0,0 +1,127 @@
+import { bytesToMac } from '../utils/hex'
+import { joinLowHigh, readUint16LE, toUint8Array } from '../utils/bytes'
+
+/**
+ * 解析 UUID_STATE 的状态字节,转为有符号 8bit。
+ */
+export function parseStateCode(payload) {
+ const bytes = toUint8Array(payload)
+ if (!bytes.length) {
+ return null
+ }
+
+ return bytes[0] > 127 ? bytes[0] - 256 : bytes[0]
+}
+
+/**
+ * 解析 UUID_MSG 设备信息(ED713/ED719 通用字段优先)。
+ */
+export function parseDeviceInfo(payload) {
+ const bytes = toUint8Array(payload)
+
+ if (!bytes.length) {
+ return {
+ raw: bytes,
+ mac: '',
+ bindStatus: null,
+ productId: null,
+ deviceType: null,
+ wifiStatus: null,
+ cellularStatus: null
+ }
+ }
+
+ const macBytes = bytes.slice(0, 6)
+ const bindStatus = bytes.length > 6 ? bytes[6] : null
+ const productId = bytes.length > 10 ? joinLowHigh(bytes[10], bytes[9]) : null
+ const deviceType = bytes.length > 14 ? bytes[14] : null
+ const wifiStatus = bytes.length > 15 ? bytes[15] : null
+ const cellularStatus = bytes.length > 16 ? bytes[16] : null
+
+ return {
+ raw: bytes,
+ mac: bytesToMac(macBytes),
+ bindStatus,
+ productId,
+ deviceType,
+ wifiStatus,
+ cellularStatus,
+ baseBoardVersion: bytes.length > 11 ? bytes[11] : null,
+ coreBoardVersion: bytes.length > 12 ? bytes[12] : null
+ }
+}
+
+/**
+ * ED713 雷达数据解析。
+ * 协议帧长度不足时返回 valid:false,交由上层决定是否忽略。
+ */
+export function parseEd713Radar(payload) {
+ const bytes = toUint8Array(payload)
+
+ if (bytes.length < 60) {
+ return {
+ type: 'ED713',
+ valid: false,
+ reason: 'LENGTH_LT_60',
+ raw: bytes
+ }
+ }
+
+ return {
+ type: 'ED713',
+ valid: true,
+ raw: bytes,
+ peopleCount: bytes[24],
+ peopleLocalDecimeter: bytes[25],
+ peopleState: bytes[26],
+ heartRate: bytes[28],
+ respRate: bytes[29],
+ activityState: bytes[30],
+ moveIndex: bytes[35],
+ waveNum: bytes[37],
+ respWave: Array.from(bytes.slice(38, 58)),
+ narrowFlag: bytes[59]
+ }
+}
+
+/**
+ * ED719 雷达数据解析。
+ * 屏蔽区数量由 shieldNum 控制,每块固定 9 字节。
+ */
+export function parseEd719Radar(payload) {
+ const bytes = toUint8Array(payload)
+
+ if (bytes.length < 24) {
+ return {
+ type: 'ED719',
+ valid: false,
+ reason: 'LENGTH_LT_24',
+ raw: bytes
+ }
+ }
+
+ const shieldNum = bytes[27] || 0
+ const shieldStart = 28
+ const shieldBlockLength = Math.min(Math.max(shieldNum, 0), 4) * 9
+
+ return {
+ type: 'ED719',
+ valid: true,
+ raw: bytes,
+ eventState: bytes[4],
+ peopleState: bytes[5],
+ peopleNum: bytes[6],
+ installHeight: readUint16LE(bytes, 8),
+ installMode: bytes[10],
+ beeperOn: bytes[11],
+ leftDist: readUint16LE(bytes, 12),
+ rightDist: readUint16LE(bytes, 14),
+ frontDist: readUint16LE(bytes, 16),
+ backDist: readUint16LE(bytes, 18),
+ sensitive: bytes[20],
+ stateDelay: readUint16LE(bytes, 22),
+ shieldNum,
+ shieldZoneRaw: Array.from(bytes.slice(shieldStart, shieldStart + shieldBlockLength)),
+ wallOption: bytes[shieldStart + shieldBlockLength] ?? null
+ }
+}
diff --git a/utils/ble/parsers/index.js b/utils/ble/parsers/index.js
new file mode 100644
index 0000000..89ce317
--- /dev/null
+++ b/utils/ble/parsers/index.js
@@ -0,0 +1,35 @@
+import { parseDeviceInfo, parseEd713Radar, parseEd719Radar, parseStateCode } from './commonParser'
+import { DEVICE_PROFILE } from '../protocols/profiles'
+
+export { parseDeviceInfo, parseStateCode }
+
+/**
+ * 雷达数据统一解析入口:
+ * 优先按已识别 profile 解析,未知时再根据签名字节推断。
+ */
+export function parseRadarData(payload, profileId = DEVICE_PROFILE.UNKNOWN) {
+ if (profileId === DEVICE_PROFILE.ED713) {
+ return parseEd713Radar(payload)
+ }
+
+ if (profileId === DEVICE_PROFILE.ED719) {
+ return parseEd719Radar(payload)
+ }
+
+ const bytes = payload instanceof Uint8Array ? payload : new Uint8Array(payload)
+
+ if (bytes[3] === 0x7C) {
+ return parseEd713Radar(bytes)
+ }
+
+ if (bytes[3] === 0x67) {
+ return parseEd719Radar(bytes)
+ }
+
+ return {
+ type: 'UNKNOWN',
+ valid: false,
+ reason: 'UNKNOWN_SIGNATURE',
+ raw: bytes
+ }
+}
diff --git a/utils/ble/protocols/profiles.js b/utils/ble/protocols/profiles.js
new file mode 100644
index 0000000..c04ff5b
--- /dev/null
+++ b/utils/ble/protocols/profiles.js
@@ -0,0 +1,138 @@
+import { toFullUuid } from '../utils/uuid'
+
+// ED713 / ED719 当前协议共用的服务与特征 UUID。
+const COMMON_UUIDS = Object.freeze({
+ SERVICE: '00F4',
+ WIFI: 'F401',
+ KEY: 'F402',
+ CMD: 'F403',
+ MSG: 'F501',
+ STATE: 'F301',
+ RADAR: 'F302'
+})
+
+// UUID_STATE 常用状态码语义映射。
+const COMMON_STATE_MAP = Object.freeze({
+ 1: 'WIFI_PARAM_INVALID',
+ 2: 'WIFI_CONNECT_FAILED',
+ 3: 'WIFI_CONNECT_SUCCESS',
+ 4: 'WIFI_CONNECTING',
+ 5: 'BIND_SUCCESS',
+ 6: 'BIND_FAILED',
+ 7: 'KEY_MATCH_SUCCESS',
+ 8: 'KEY_MATCH_FAILED'
+})
+
+export const DEVICE_PROFILE = Object.freeze({
+ UNKNOWN: 'UNKNOWN',
+ ED713: 'ED713',
+ ED719: 'ED719'
+})
+
+/**
+ * 生成设备 profile 模板:
+ * 包含 UUID、分包规则、鉴权策略、WiFi 校验、雷达命令能力。
+ */
+function buildCommonProfile(id) {
+ return {
+ id,
+ uuids: {
+ service: toFullUuid(COMMON_UUIDS.SERVICE),
+ wifi: toFullUuid(COMMON_UUIDS.WIFI),
+ key: toFullUuid(COMMON_UUIDS.KEY),
+ cmd: toFullUuid(COMMON_UUIDS.CMD),
+ msg: toFullUuid(COMMON_UUIDS.MSG),
+ state: toFullUuid(COMMON_UUIDS.STATE),
+ radar: toFullUuid(COMMON_UUIDS.RADAR)
+ },
+ stateMap: { ...COMMON_STATE_MAP },
+ packet: {
+ head: 0xA7,
+ chunkSize: 18,
+ maxChunk: 15
+ },
+ auth: {
+ keyLength: 16,
+ supportsUnboundState: false,
+ unboundStateCode: null
+ },
+ wifi: {
+ maxSsidLength: 32,
+ minPasswordLength: 8,
+ maxPasswordLength: 63,
+ invalidChars: ['+', '#', '&', '=', '|', '<', '>', '^', '"', '\\']
+ },
+ radar: {
+ notifySignature: null,
+ startCommand: 0xA1,
+ stopCommand: 0xA2,
+ parseMode: 'GENERIC'
+ },
+ cmd: {
+ supportsNarrowMode: false,
+ supportsFallParam67: false
+ }
+ }
+}
+
+const unknownProfile = buildCommonProfile(DEVICE_PROFILE.UNKNOWN)
+
+const ed713Profile = {
+ ...buildCommonProfile(DEVICE_PROFILE.ED713),
+ auth: {
+ keyLength: 16,
+ supportsUnboundState: true,
+ unboundStateCode: -1
+ },
+ radar: {
+ notifySignature: 0x7C,
+ startCommand: 0xA1,
+ stopCommand: 0xA2,
+ parseMode: 'ED713'
+ },
+ cmd: {
+ supportsNarrowMode: true,
+ supportsFallParam67: false
+ }
+}
+
+const ed719Profile = {
+ ...buildCommonProfile(DEVICE_PROFILE.ED719),
+ radar: {
+ notifySignature: 0x67,
+ startCommand: 0xA1,
+ stopCommand: 0xA2,
+ parseMode: 'ED719'
+ },
+ cmd: {
+ supportsNarrowMode: false,
+ supportsFallParam67: true
+ }
+}
+
+export const BLE_PROFILE_MAP = Object.freeze({
+ [DEVICE_PROFILE.UNKNOWN]: unknownProfile,
+ [DEVICE_PROFILE.ED713]: ed713Profile,
+ [DEVICE_PROFILE.ED719]: ed719Profile
+})
+
+export function getBleProfile(profileId = DEVICE_PROFILE.UNKNOWN) {
+ return BLE_PROFILE_MAP[profileId] || BLE_PROFILE_MAP[DEVICE_PROFILE.UNKNOWN]
+}
+
+/**
+ * 根据雷达帧签名字节粗略推断设备类型。
+ */
+export function guessProfileByRadarFrame(frame = new Uint8Array()) {
+ const signature = frame[3]
+
+ if (signature === 0x7C) {
+ return DEVICE_PROFILE.ED713
+ }
+
+ if (signature === 0x67) {
+ return DEVICE_PROFILE.ED719
+ }
+
+ return DEVICE_PROFILE.UNKNOWN
+}
diff --git a/utils/ble/utils/bytes.js b/utils/ble/utils/bytes.js
new file mode 100644
index 0000000..47ad6f9
--- /dev/null
+++ b/utils/ble/utils/bytes.js
@@ -0,0 +1,116 @@
+/**
+ * 把常见二进制输入统一转换成 Uint8Array。
+ * 支持:Uint8Array / ArrayBuffer / TypedArray / number[]。
+ */
+export function toUint8Array(value) {
+ if (value instanceof Uint8Array) {
+ return value
+ }
+
+ if (value instanceof ArrayBuffer) {
+ return new Uint8Array(value)
+ }
+
+ if (ArrayBuffer.isView(value)) {
+ return new Uint8Array(value.buffer)
+ }
+
+ if (Array.isArray(value)) {
+ return Uint8Array.from(value)
+ }
+
+ return new Uint8Array()
+}
+
+/**
+ * 生成“刚好覆盖有效字节范围”的 ArrayBuffer。
+ * 避免把 TypedArray 背后多余 buffer 区域一并透传。
+ */
+export function uint8ArrayToArrayBuffer(value) {
+ const bytes = toUint8Array(value)
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
+}
+
+/**
+ * 把多个字节数组按顺序拼接。
+ */
+export function concatUint8Arrays(list = []) {
+ const bytesList = list.map((item) => toUint8Array(item))
+ const total = bytesList.reduce((sum, item) => sum + item.length, 0)
+
+ const merged = new Uint8Array(total)
+ let offset = 0
+
+ bytesList.forEach((item) => {
+ merged.set(item, offset)
+ offset += item.length
+ })
+
+ return merged
+}
+
+/**
+ * 按 chunkSize 对字节流分片。
+ * chunkSize 无效时返回原数据单片。
+ */
+export function splitUint8Array(value, chunkSize) {
+ const bytes = toUint8Array(value)
+
+ if (!chunkSize || chunkSize <= 0) {
+ return [bytes]
+ }
+
+ const chunks = []
+ for (let index = 0; index < bytes.length; index += chunkSize) {
+ chunks.push(bytes.slice(index, index + chunkSize))
+ }
+
+ return chunks
+}
+
+/**
+ * 按小端序读取 16 位无符号整数。
+ */
+export function readUint16LE(bytes, offset) {
+ const value = toUint8Array(bytes)
+ const low = value[offset] || 0
+ const high = value[offset + 1] || 0
+ return (high << 8) | low
+}
+
+/**
+ * 按小端序读取 32 位无符号整数。
+ */
+export function readUint32LE(bytes, offset) {
+ const value = toUint8Array(bytes)
+ return ((value[offset + 3] || 0) << 24) >>> 0
+ | ((value[offset + 2] || 0) << 16)
+ | ((value[offset + 1] || 0) << 8)
+ | (value[offset] || 0)
+}
+
+/**
+ * 把 16 位整数拆成低位/高位两个字节(小端常用)。
+ */
+export function splitToLowHigh(value) {
+ const normalized = Number(value) || 0
+ return {
+ low: normalized & 0xFF,
+ high: (normalized >> 8) & 0xFF
+ }
+}
+
+/**
+ * 把低位/高位字节还原成 16 位整数。
+ */
+export function joinLowHigh(low, high) {
+ return ((Number(high) || 0) << 8) | (Number(low) || 0)
+}
+
+/**
+ * 把 0~255 转成有符号 int8(-128~127)。
+ */
+export function toSignedInt8(value) {
+ const number = Number(value) || 0
+ return number > 127 ? number - 256 : number
+}
diff --git a/utils/ble/utils/hex.js b/utils/ble/utils/hex.js
new file mode 100644
index 0000000..63d7914
--- /dev/null
+++ b/utils/ble/utils/hex.js
@@ -0,0 +1,71 @@
+import { toUint8Array } from './bytes'
+
+/**
+ * 字节数组转十六进制字符串。
+ * withSpace=true 时输出 "aa bb cc",否则输出 "aabbcc"。
+ */
+export function bytesToHex(input, withSpace = false) {
+ const bytes = toUint8Array(input)
+ const parts = Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0'))
+ return withSpace ? parts.join(' ') : parts.join('')
+}
+
+/**
+ * 十六进制字符串转 Uint8Array。
+ * 会自动忽略分隔符,并兼容奇数字符长度(自动左补0)。
+ */
+export function hexToBytes(hexText = '') {
+ const cleaned = String(hexText || '').replace(/[^0-9a-fA-F]/g, '')
+
+ if (!cleaned) {
+ return new Uint8Array()
+ }
+
+ const normalized = cleaned.length % 2 === 0 ? cleaned : `0${cleaned}`
+ const result = new Uint8Array(normalized.length / 2)
+
+ for (let index = 0; index < normalized.length; index += 2) {
+ result[index / 2] = parseInt(normalized.slice(index, index + 2), 16)
+ }
+
+ return result
+}
+
+/**
+ * UTF-8 字符串转字节数组。
+ * 优先使用 TextEncoder,低版本环境走兼容路径。
+ */
+export function utf8ToBytes(text = '') {
+ if (typeof TextEncoder !== 'undefined') {
+ return new TextEncoder().encode(String(text || ''))
+ }
+
+ const encoded = unescape(encodeURIComponent(String(text || '')))
+ return Uint8Array.from(Array.from(encoded).map((char) => char.charCodeAt(0)))
+}
+
+/**
+ * 字节数组转 UTF-8 字符串。
+ * 优先使用 TextDecoder,低版本环境走兼容路径。
+ */
+export function bytesToUtf8(input) {
+ const bytes = toUint8Array(input)
+
+ if (!bytes.length) {
+ return ''
+ }
+
+ if (typeof TextDecoder !== 'undefined') {
+ return new TextDecoder('utf-8').decode(bytes)
+ }
+
+ const latin = Array.from(bytes).map((byte) => String.fromCharCode(byte)).join('')
+ return decodeURIComponent(escape(latin))
+}
+
+/**
+ * 把 MAC 字节转为 "aa:bb:cc:dd:ee:ff" 形式。
+ */
+export function bytesToMac(input) {
+ return bytesToHex(input, true).replace(/ /g, ':')
+}
diff --git a/utils/ble/utils/uuid.js b/utils/ble/utils/uuid.js
new file mode 100644
index 0000000..fdf38a4
--- /dev/null
+++ b/utils/ble/utils/uuid.js
@@ -0,0 +1,90 @@
+const BASE_UUID_SUFFIX = '0000-1000-8000-00805F9B34FB'
+
+/**
+ * 规范化 UUID:移除非16进制字符并转大写。
+ */
+export function normalizeUuid(uuid) {
+ return String(uuid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
+}
+
+/**
+ * 提取短 UUID(16-bit)用于不同写法间比较。
+ */
+export function shortUuid(uuid) {
+ const normalized = normalizeUuid(uuid)
+
+ if (!normalized) {
+ return ''
+ }
+
+ if (normalized.length === 4) {
+ return normalized
+ }
+
+ if (normalized.length === 8) {
+ return normalized.slice(4)
+ }
+
+ if (normalized.length === 32) {
+ return normalized.slice(4, 8)
+ }
+
+ return normalized.slice(-4)
+}
+
+function format32Uuid(normalized32) {
+ return [
+ normalized32.slice(0, 8),
+ normalized32.slice(8, 12),
+ normalized32.slice(12, 16),
+ normalized32.slice(16, 20),
+ normalized32.slice(20, 32)
+ ].join('-')
+}
+
+/**
+ * 把 16/32/128 位 UUID 统一转成标准 128 位字符串。
+ * 无法识别时返回空串。
+ */
+export function toFullUuid(uuid) {
+ const normalized = normalizeUuid(uuid)
+
+ if (!normalized) {
+ return ''
+ }
+
+ if (normalized.length === 32) {
+ return format32Uuid(normalized)
+ }
+
+ if (normalized.length === 8) {
+ return `${normalized}-${BASE_UUID_SUFFIX}`
+ }
+
+ if (normalized.length === 4) {
+ return `${`0000${normalized}`}-${BASE_UUID_SUFFIX}`
+ }
+
+ return ''
+}
+
+/**
+ * UUID 宽松比较:支持完整格式一致或短 UUID 一致。
+ */
+export function uuidEquals(left, right) {
+ const leftNormalized = normalizeUuid(left)
+ const rightNormalized = normalizeUuid(right)
+
+ if (!leftNormalized || !rightNormalized) {
+ return false
+ }
+
+ return leftNormalized === rightNormalized || shortUuid(leftNormalized) === shortUuid(rightNormalized)
+}
+
+/**
+ * 判断目标 UUID 是否在列表中(使用 uuidEquals 宽松匹配)。
+ */
+export function uuidInList(targetUuid, uuidList = []) {
+ return uuidList.some((uuid) => uuidEquals(targetUuid, uuid))
+}
diff --git a/utils/request.js b/utils/request.js
index b8972a5..1f63ff7 100644
--- a/utils/request.js
+++ b/utils/request.js
@@ -2,5 +2,6 @@ import request from '@/api/http/client'
/**
* 兼容层:保留旧导入路径 `@/utils/request`。
+ * 新代码建议直接使用 `@/api/http/client`。
*/
export default request