feat(ble): 新增蓝牙核心功能模块

- 实现 BLE 核心控制器,封装适配器、连接、服务发现等基础能力
- 添加蓝牙授权模块,支持设备绑定和密钥匹配流程
- 集成 WiFi 和雷达数据处理模块
- 添加设备信息解析和状态码处理功能
- 实现字节流工具函数,支持多种二进制格式转换
- 优化蓝牙错误处理和状态管理机制
- 更新 App.vue 注释,增强代码可读性
- 添加 .gitignore 配置,忽略 IDE 相关文件
This commit is contained in:
ozh 2026-04-17 16:09:14 +08:00
parent e4a9775327
commit 640772ef0c
37 changed files with 2455 additions and 85 deletions

8
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/smarthome.iml" filepath="$PROJECT_DIR$/.idea/smarthome.iml" />
</modules>
</component>
</project>

12
.idea/smarthome.iml Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

2
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,2 @@
{
}

View File

@ -1,12 +1,17 @@
<script>
import { ensureCurrentPageAuth } from './utils/auth'
/**
* 应用根组件仅承载全局生命周期与登录态兜底校验
*/
export default {
onLaunch() {
console.log('App Launch')
},
onShow() {
console.log('App Show')
//
ensureCurrentPageAuth()
},
onHide() {
@ -16,5 +21,5 @@ export default {
</script>
<style>
/*每个页面公共css */
/* 每个页面公共 css(当前按需在页面中引入 common.css */
</style>

View File

@ -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 = {},

View File

@ -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 = '蓝牙搜索启动失败'

124
docs/unified-ble-flow.md Normal file
View File

@ -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。

View File

@ -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({
function hasTargetDeviceInMap() {
return Object.values(deviceMap).some((item) => item.hasTargetService)
}
function getDeviceStableSignature(device = {}) {
return [
device.deviceId || '',
device.RSSI ?? '',
device.name || '',
device.localName || '',
Array.isArray(device.advertisServiceUUIDs) ? device.advertisServiceUUIDs.map((item) => normalizeUuid(item)).sort().join('|') : '',
device.advertisData ? JSON.stringify(Array.from(new Uint8Array(device.advertisData))) : ''
].join('#')
}
function clearFallbackTimer() {
if (!filterFallbackTimer) {
return
}
clearTimeout(filterFallbackTimer)
filterFallbackTimer = null
}
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
async function ensureAndroidLocationPermission() {
let systemInfo = {}
try {
systemInfo = uni.getSystemInfoSync() || {}
} catch (error) {
return true
}
const platform = String(systemInfo.platform || '').toLowerCase()
if (platform !== 'android') {
return true
}
try {
const settings = await new Promise((resolve, reject) => {
uni.getSetting({
success: resolve,
fail: reject
})
})
const authSetting = settings.authSetting || {}
const locationSetting = authSetting['scope.userLocation']
if (locationSetting === true) {
return true
}
function startDiscoveryWithServices(services = []) {
return new Promise((resolve, reject) => {
const options = {
allowDuplicatesKey: true,
interval: 0,
await new Promise((resolve, reject) => {
uni.authorize({
scope: 'scope.userLocation',
success: resolve,
fail: reject
}
if (services.length) {
options.services = services
}
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
})
}
uni.onBluetoothDeviceFound(deviceFoundHandler)
async function stopDiscoverySilently() {
try {
await controller.ble.stopDiscovery()
} catch (error) {
}
}
async function fallbackToUnfilteredDiscoveryIfNeeded(sessionId) {
clearFallbackTimer()
filterFallbackTimer = setTimeout(async () => {
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId || hasTargetDeviceInMap()) {
return
}
try {
await stopDiscoverySilently()
await delay(120)
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {
return
}
await startDiscoveryWithServices([])
} catch (error) {
}
}, FILTER_SCAN_TIMEOUT_MS)
}
function getBluetoothErrorText(error) {
const errCode = (error && (error.errCode ?? error.code))
const text = BLUETOOTH_ERROR_MESSAGES[errCode] || DEFAULT_BLUETOOTH_ERROR_MESSAGE
if (errCode === undefined || errCode === null) {
return text
}
return `${text}(${errCode})`
}
async function startSearch() {
if (isSearching) {
return
}
clearFallbackTimer()
searchSessionId += 1
const sessionId = searchSessionId
resetDeviceMap()
try {
await ensureAndroidLocationPermission()
await ensureInitialized()
const adapterState = await controller.ble.getAdapterState()
if (adapterState && adapterState.available === false) {
throw { errCode: 10001 }
}
await stopDiscoverySilently()
acceptingDeviceEvents = true
try {
await startDiscoveryWithServices(DISCOVERY_FILTER_SERVICE_UUIDS)
} catch (error) {
await startDiscoveryWithServices([])
}
if (sessionId !== searchSessionId) {
return
}
setSearching(true)
await fallbackToUnfilteredDiscoveryIfNeeded(sessionId)
} catch (error) {
acceptingDeviceEvents = false
setSearching(false)
showToast(getBluetoothErrorText(error))
throw error
}
}
function stopSearch(showStopToast = true) {
return new Promise((resolve) => {
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
if (offAdapterState) {
offAdapterState()
offAdapterState = null
}
function getBluetoothErrorText(error) {
const errCode = error && error.errCode
return BLUETOOTH_ERROR_MESSAGES[errCode] || DEFAULT_BLUETOOTH_ERROR_MESSAGE
if (!initialized) {
return
}
async function startSearch() {
setDeviceMap({})
initialized = false
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
}
controller.cleanup().catch(() => {})
}
return {

View File

@ -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 {

View File

@ -1,3 +1,4 @@
<!-- 设备添加页用于承接后续蓝牙搜索/连接配网流程 -->
<template>
<view class="page page-base">
<view class="card card-base">
@ -8,6 +9,7 @@
</template>
<script>
//
export default {}
</script>

View File

@ -1,3 +1,4 @@
<!-- 事件页展示事件筛选头与空状态占位后续可接入告警列表数据 -->
<template>
<view class="page page-base">
<view class="top-nav top-nav-base">
@ -18,6 +19,7 @@
</template>
<script>
//
export default {}
</script>

View File

@ -1,3 +1,4 @@
<!-- 首页当前以静态壳页面为主负责跳转到设备添加与配置入口 -->
<template>
<view class="page page-base">
<view class="top-nav top-nav-base">
@ -35,9 +36,11 @@ import { navigateTo } from '@/utils/navigation'
export default {
methods: {
//
goAdd() {
navigateTo(ROUTES.DEVICE_ADD)
},
//
goConfig() {
navigateTo(ROUTES.DEVICE_CONFIG)
}

View File

@ -1,3 +1,4 @@
<!-- 我的页展示用户信息账号入口与设备配置等个人中心菜单 -->
<template>
<view class="page page-base">
<view class="top-nav top-nav-base">
@ -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('已退出登录')

View File

@ -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;

View File

@ -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])
});
});
},
});
res.then((value) => {
if (!value) {
return resolve(value)
}
// uni API Promise 通常返回 [error, result]。
return value[0] ? reject(value[0]) : resolve(value[1])
})
})
}
})

View File

@ -1,3 +1,4 @@
// 统一复用 api/user.js 的用户相关接口,供页面层按 utils 路径导入。
export {
getUserProfile,
loginByMobile,

5
utils/ble/README.md Normal file
View File

@ -0,0 +1,5 @@
# BLE 模块说明
- 核心入口: `utils/ble/index.js`
- 推荐使用: `createUnifiedBleController`
- 本模块目标: 在不改变业务语义的前提下,抽离连接/鉴权/WiFi/雷达/解析公共能力,兼容 ED713 与 ED719。

548
utils/ble/core/bleCore.js Normal file
View File

@ -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
}
}

View File

@ -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
}
}

63
utils/ble/core/errors.js Normal file
View File

@ -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
}

View File

@ -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
}
}

56
utils/ble/core/packet.js Normal file
View File

@ -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
}

40
utils/ble/core/retry.js Normal file
View File

@ -0,0 +1,40 @@
// 简单 sleep 工具,供重试间隔等待使用。
export function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
/**
* 通用重试执行器
* @param {(retryIndex:number)=>Promise<any>} 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
}

38
utils/ble/core/state.js Normal file
View File

@ -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
}
}
}

21
utils/ble/index.js Normal file
View File

@ -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'

180
utils/ble/modules/auth.js Normal file
View File

@ -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
}
}

View File

@ -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
}
}

96
utils/ble/modules/wifi.js Normal file
View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}

116
utils/ble/utils/bytes.js Normal file
View File

@ -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
}

71
utils/ble/utils/hex.js Normal file
View File

@ -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, ':')
}

90
utils/ble/utils/uuid.js Normal file
View File

@ -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()
}
/**
* 提取短 UUID16-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))
}

View File

@ -2,5 +2,6 @@ import request from '@/api/http/client'
/**
* 兼容层保留旧导入路径 `@/utils/request`
* 新代码建议直接使用 `@/api/http/client`
*/
export default request