feat(ble): 新增蓝牙核心功能模块
- 实现 BLE 核心控制器,封装适配器、连接、服务发现等基础能力 - 添加蓝牙授权模块,支持设备绑定和密钥匹配流程 - 集成 WiFi 和雷达数据处理模块 - 添加设备信息解析和状态码处理功能 - 实现字节流工具函数,支持多种二进制格式转换 - 优化蓝牙错误处理和状态管理机制 - 更新 App.vue 注释,增强代码可读性 - 添加 .gitignore 配置,忽略 IDE 相关文件
This commit is contained in:
parent
e4a9775327
commit
640772ef0c
|
|
@ -0,0 +1,8 @@
|
||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
{
|
||||||
|
}
|
||||||
7
App.vue
7
App.vue
|
|
@ -1,12 +1,17 @@
|
||||||
<script>
|
<script>
|
||||||
import { ensureCurrentPageAuth } from './utils/auth'
|
import { ensureCurrentPageAuth } from './utils/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用根组件:仅承载全局生命周期与登录态兜底校验。
|
||||||
|
*/
|
||||||
export default {
|
export default {
|
||||||
onLaunch() {
|
onLaunch() {
|
||||||
console.log('App Launch')
|
console.log('App Launch')
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
console.log('App Show')
|
console.log('App Show')
|
||||||
|
|
||||||
|
// 每次回到前台时检查当前页权限,避免未登录状态残留在受保护页面。
|
||||||
ensureCurrentPageAuth()
|
ensureCurrentPageAuth()
|
||||||
},
|
},
|
||||||
onHide() {
|
onHide() {
|
||||||
|
|
@ -16,5 +21,5 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/*每个页面公共css */
|
/* 每个页面公共 css(当前按需在页面中引入 common.css) */
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
let baseURL = DEFAULT_BASE_URL
|
let baseURL = DEFAULT_BASE_URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接请求地址:支持绝对地址 / 相对地址 / 仅 baseURL。
|
||||||
|
*/
|
||||||
function buildUrl(url = '') {
|
function buildUrl(url = '') {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return baseURL
|
return baseURL
|
||||||
|
|
@ -43,6 +46,9 @@ function buildHeaders(customHeader = {}, needAuth = true, withJson = true) {
|
||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 401 统一处理:清理本地登录态并回到登录页。
|
||||||
|
*/
|
||||||
function handleUnauthorized() {
|
function handleUnauthorized() {
|
||||||
if (!isAuthEnabled()) {
|
if (!isAuthEnabled()) {
|
||||||
return
|
return
|
||||||
|
|
@ -137,6 +143,7 @@ function request(options = {}) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 语义化快捷方法,保持页面侧调用简洁。
|
||||||
request.get = (url, params = {}, config = {}) => {
|
request.get = (url, params = {}, config = {}) => {
|
||||||
return request({
|
return request({
|
||||||
url,
|
url,
|
||||||
|
|
@ -173,6 +180,9 @@ request.delete = (url, data = {}, config = {}) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件封装:保持与 request 一致的错误处理与鉴权策略。
|
||||||
|
*/
|
||||||
request.upload = (url, filePath, name = 'file', formData = {}, config = {}) => {
|
request.upload = (url, filePath, name = 'file', formData = {}, config = {}) => {
|
||||||
const {
|
const {
|
||||||
header = {},
|
header = {},
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,30 @@
|
||||||
/**
|
/**
|
||||||
* 目标服务 UUID(用于优先识别特定设备)。
|
* 目标服务 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 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({
|
export const BLUETOOTH_ERROR_MESSAGES = Object.freeze({
|
||||||
10001: '蓝牙不可用,请先开启手机蓝牙',
|
10001: '蓝牙不可用,请先开启手机蓝牙',
|
||||||
|
10002: '未找到指定设备,请确认设备在附近且处于广播状态',
|
||||||
10003: '连接失败,请重试',
|
10003: '连接失败,请重试',
|
||||||
10012: '操作超时,请重试'
|
10012: '操作超时,请重试',
|
||||||
|
10013: '无效参数,请重试',
|
||||||
|
10016: '蓝牙适配器初始化失败',
|
||||||
|
10017: '启动搜索失败'
|
||||||
})
|
})
|
||||||
|
|
||||||
export const DEFAULT_BLUETOOTH_ERROR_MESSAGE = '蓝牙搜索启动失败'
|
export const DEFAULT_BLUETOOTH_ERROR_MESSAGE = '蓝牙搜索启动失败'
|
||||||
|
|
|
||||||
|
|
@ -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。
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
import {
|
import {
|
||||||
BLUETOOTH_ERROR_MESSAGES,
|
BLUETOOTH_ERROR_MESSAGES,
|
||||||
DEFAULT_BLUETOOTH_ERROR_MESSAGE,
|
DEFAULT_BLUETOOTH_ERROR_MESSAGE,
|
||||||
TARGET_SERVICE_UUID,
|
DISCOVERY_FILTER_SERVICE_UUIDS,
|
||||||
|
TARGET_SERVICE_UUID_FULL,
|
||||||
|
TARGET_SERVICE_UUID_ALT_FULL,
|
||||||
TARGET_SHORT_UUID
|
TARGET_SHORT_UUID
|
||||||
} from '@/constants/bluetooth'
|
} from '@/constants/bluetooth'
|
||||||
|
import { DEVICE_PROFILE, createUnifiedBleController } from '@/utils/ble'
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
|
const FILTER_SCAN_TIMEOUT_MS = 4000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 蓝牙设备搜索逻辑封装。
|
* 蓝牙设备搜索逻辑封装。
|
||||||
* 通过回调把状态同步给页面,减少页面文件复杂度。
|
* 基于 utils/ble 统一蓝牙控制器实现。
|
||||||
*/
|
*/
|
||||||
export function createBluetoothDiscovery(options = {}) {
|
export function createBluetoothDiscovery(options = {}) {
|
||||||
const {
|
const {
|
||||||
|
|
@ -17,21 +22,27 @@ export function createBluetoothDiscovery(options = {}) {
|
||||||
onDeviceListChange = () => {}
|
onDeviceListChange = () => {}
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
|
const controller = createUnifiedBleController({
|
||||||
|
profileId: DEVICE_PROFILE.UNKNOWN
|
||||||
|
})
|
||||||
|
|
||||||
let isSearching = false
|
let isSearching = false
|
||||||
let deviceMap = {}
|
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) {
|
function setSearching(value) {
|
||||||
isSearching = Boolean(value)
|
isSearching = Boolean(value)
|
||||||
onSearchingChange(isSearching)
|
onSearchingChange(isSearching)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDeviceMap(nextMap) {
|
|
||||||
deviceMap = nextMap
|
|
||||||
onDeviceMapChange({ ...deviceMap })
|
|
||||||
emitDeviceList()
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitDeviceList() {
|
function emitDeviceList() {
|
||||||
const list = Object.values(deviceMap).sort((a, b) => {
|
const list = Object.values(deviceMap).sort((a, b) => {
|
||||||
if (a.hasTargetService !== b.hasTargetService) {
|
if (a.hasTargetService !== b.hasTargetService) {
|
||||||
|
|
@ -46,6 +57,16 @@ export function createBluetoothDiscovery(options = {}) {
|
||||||
onDeviceListChange(list)
|
onDeviceListChange(list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDeviceMap(nextMap) {
|
||||||
|
deviceMap = nextMap
|
||||||
|
onDeviceMapChange({ ...deviceMap })
|
||||||
|
emitDeviceList()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDeviceMap() {
|
||||||
|
setDeviceMap({})
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUuid(uuid) {
|
function normalizeUuid(uuid) {
|
||||||
return String(uuid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
|
return String(uuid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
@ -55,37 +76,94 @@ export function createBluetoothDiscovery(options = {}) {
|
||||||
return false
|
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) => {
|
return device.advertisServiceUUIDs.some((uuid) => {
|
||||||
const value = normalizeUuid(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() {
|
function hasTargetDeviceInMap() {
|
||||||
return new Promise((resolve, reject) => {
|
return Object.values(deviceMap).some((item) => item.hasTargetService)
|
||||||
uni.openBluetoothAdapter({
|
}
|
||||||
|
|
||||||
|
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,
|
success: resolve,
|
||||||
fail: reject
|
fail: reject
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const authSetting = settings.authSetting || {}
|
||||||
|
const locationSetting = authSetting['scope.userLocation']
|
||||||
|
|
||||||
|
if (locationSetting === true) {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function startDiscoveryWithServices(services = []) {
|
await new Promise((resolve, reject) => {
|
||||||
return new Promise((resolve, reject) => {
|
uni.authorize({
|
||||||
const options = {
|
scope: 'scope.userLocation',
|
||||||
allowDuplicatesKey: true,
|
|
||||||
interval: 0,
|
|
||||||
success: resolve,
|
success: resolve,
|
||||||
fail: reject
|
fail: reject
|
||||||
}
|
|
||||||
|
|
||||||
if (services.length) {
|
|
||||||
options.services = services
|
|
||||||
}
|
|
||||||
|
|
||||||
uni.startBluetoothDevicesDiscovery(options)
|
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Android建议开启定位权限/定位开关(已继续尝试搜索)')
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertDevice(rawDevice) {
|
function upsertDevice(rawDevice) {
|
||||||
|
|
@ -93,12 +171,24 @@ export function createBluetoothDiscovery(options = {}) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetMatched = hasTargetService(rawDevice)
|
||||||
|
if (!targetMatched) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const existing = deviceMap[rawDevice.deviceId] || {}
|
const existing = deviceMap[rawDevice.deviceId] || {}
|
||||||
const merged = {
|
const merged = {
|
||||||
...existing,
|
...existing,
|
||||||
...rawDevice,
|
...rawDevice,
|
||||||
showName: rawDevice.name || rawDevice.localName || existing.showName || '未命名设备',
|
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({
|
setDeviceMap({
|
||||||
|
|
@ -107,75 +197,177 @@ export function createBluetoothDiscovery(options = {}) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerDeviceFoundHandler() {
|
async function ensureInitialized() {
|
||||||
if (deviceFoundHandler) {
|
if (initialized) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceFoundHandler = (res) => {
|
await controller.init()
|
||||||
const devices = Array.isArray(res.devices) ? res.devices : [res]
|
controller.ble.watchDeviceFound()
|
||||||
|
|
||||||
|
offDeviceFound = controller.ble.on('device:found', (devices) => {
|
||||||
|
if (!acceptingDeviceEvents) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSession = searchSessionId
|
||||||
devices.forEach((device) => {
|
devices.forEach((device) => {
|
||||||
|
if (!acceptingDeviceEvents || currentSession !== searchSessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
upsertDevice(device)
|
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) {
|
function stopSearch(showStopToast = true) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
uni.stopBluetoothDevicesDiscovery({
|
clearFallbackTimer()
|
||||||
complete: () => {
|
|
||||||
|
searchSessionId += 1
|
||||||
|
acceptingDeviceEvents = false
|
||||||
|
|
||||||
|
controller.ble.stopDiscovery()
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
setSearching(false)
|
setSearching(false)
|
||||||
if (showStopToast) {
|
if (showStopToast) {
|
||||||
showToast('已停止搜索')
|
showToast('已停止搜索')
|
||||||
}
|
}
|
||||||
resolve()
|
resolve()
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupDiscovery() {
|
function cleanupDiscovery() {
|
||||||
if (isSearching) {
|
clearFallbackTimer()
|
||||||
uni.stopBluetoothDevicesDiscovery({
|
|
||||||
complete: () => {}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
searchSessionId += 1
|
||||||
|
acceptingDeviceEvents = false
|
||||||
setSearching(false)
|
setSearching(false)
|
||||||
|
|
||||||
if (deviceFoundHandler && uni.offBluetoothDeviceFound) {
|
if (offDeviceFound) {
|
||||||
uni.offBluetoothDeviceFound(deviceFoundHandler)
|
offDeviceFound()
|
||||||
|
offDeviceFound = null
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceFoundHandler = null
|
if (offAdapterState) {
|
||||||
|
offAdapterState()
|
||||||
|
offAdapterState = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBluetoothErrorText(error) {
|
if (!initialized) {
|
||||||
const errCode = error && error.errCode
|
return
|
||||||
return BLUETOOTH_ERROR_MESSAGES[errCode] || DEFAULT_BLUETOOTH_ERROR_MESSAGE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startSearch() {
|
initialized = false
|
||||||
setDeviceMap({})
|
|
||||||
|
|
||||||
try {
|
controller.cleanup().catch(() => {})
|
||||||
await openBluetoothAdapter()
|
|
||||||
registerDeviceFoundHandler()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await startDiscoveryWithServices([TARGET_SERVICE_UUID])
|
|
||||||
} catch (error) {
|
|
||||||
await startDiscoveryWithServices([])
|
|
||||||
}
|
|
||||||
|
|
||||||
setSearching(true)
|
|
||||||
} catch (error) {
|
|
||||||
setSearching(false)
|
|
||||||
showToast(getBluetoothErrorText(error))
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
5
main.js
5
main.js
|
|
@ -1,6 +1,7 @@
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { setupAuthInterceptors } from './utils/auth'
|
import { setupAuthInterceptors } from './utils/auth'
|
||||||
|
|
||||||
|
// 应用启动时注册统一路由鉴权拦截器(内部有幂等保护)。
|
||||||
setupAuthInterceptors()
|
setupAuthInterceptors()
|
||||||
|
|
||||||
// #ifndef VUE3
|
// #ifndef VUE3
|
||||||
|
|
@ -16,6 +17,10 @@ app.$mount()
|
||||||
|
|
||||||
// #ifdef VUE3
|
// #ifdef VUE3
|
||||||
import { createSSRApp } from 'vue'
|
import { createSSRApp } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uni-app Vue3 入口。
|
||||||
|
*/
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = createSSRApp(App)
|
const app = createSSRApp(App)
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
<!-- 设备添加页:用于承接后续蓝牙搜索/连接配网流程 -->
|
||||||
<template>
|
<template>
|
||||||
<view class="page page-base">
|
<view class="page page-base">
|
||||||
<view class="card card-base">
|
<view class="card card-base">
|
||||||
|
|
@ -8,6 +9,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// 当前仅提供页面壳,实际流程由蓝牙控制模块接入。
|
||||||
export default {}
|
export default {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
<!-- 事件页:展示事件筛选头与空状态占位,后续可接入告警列表数据 -->
|
||||||
<template>
|
<template>
|
||||||
<view class="page page-base">
|
<view class="page page-base">
|
||||||
<view class="top-nav top-nav-base">
|
<view class="top-nav top-nav-base">
|
||||||
|
|
@ -18,6 +19,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// 当前为静态页占位,不包含交互逻辑。
|
||||||
export default {}
|
export default {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
<!-- 首页:当前以静态壳页面为主,负责跳转到设备添加与配置入口 -->
|
||||||
<template>
|
<template>
|
||||||
<view class="page page-base">
|
<view class="page page-base">
|
||||||
<view class="top-nav top-nav-base">
|
<view class="top-nav top-nav-base">
|
||||||
|
|
@ -35,9 +36,11 @@ import { navigateTo } from '@/utils/navigation'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
methods: {
|
methods: {
|
||||||
|
// 进入设备添加页(后续会承接蓝牙配网流程入口)。
|
||||||
goAdd() {
|
goAdd() {
|
||||||
navigateTo(ROUTES.DEVICE_ADD)
|
navigateTo(ROUTES.DEVICE_ADD)
|
||||||
},
|
},
|
||||||
|
// 进入设备配置页。
|
||||||
goConfig() {
|
goConfig() {
|
||||||
navigateTo(ROUTES.DEVICE_CONFIG)
|
navigateTo(ROUTES.DEVICE_CONFIG)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
<!-- 我的页:展示用户信息、账号入口与设备配置等个人中心菜单 -->
|
||||||
<template>
|
<template>
|
||||||
<view class="page page-base">
|
<view class="page page-base">
|
||||||
<view class="top-nav top-nav-base">
|
<view class="top-nav top-nav-base">
|
||||||
|
|
@ -68,17 +69,20 @@ export default {
|
||||||
phoneText() {
|
phoneText() {
|
||||||
return this.userInfo?.mobile || '未绑定'
|
return this.userInfo?.mobile || '未绑定'
|
||||||
},
|
},
|
||||||
|
// 根据登录类型展示不同文案。
|
||||||
loginTip() {
|
loginTip() {
|
||||||
return this.userInfo?.loginType === 'mobile' ? '手机号验证码登录' : '微信登录'
|
return this.userInfo?.loginType === 'mobile' ? '手机号验证码登录' : '微信登录'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
|
// 每次回到页面时刷新一次本地缓存用户信息。
|
||||||
this.userInfo = getUser()
|
this.userInfo = getUser()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
goConfig() {
|
goConfig() {
|
||||||
navigateTo(ROUTES.DEVICE_CONFIG)
|
navigateTo(ROUTES.DEVICE_CONFIG)
|
||||||
},
|
},
|
||||||
|
// 已登录时不再重复跳转登录页。
|
||||||
goLogin() {
|
goLogin() {
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) {
|
||||||
return
|
return
|
||||||
|
|
@ -86,6 +90,7 @@ export default {
|
||||||
|
|
||||||
navigateTo(LOGIN_PAGE)
|
navigateTo(LOGIN_PAGE)
|
||||||
},
|
},
|
||||||
|
// 退出时清理本地登录态并回到首页。
|
||||||
logout() {
|
logout() {
|
||||||
clearAuth()
|
clearAuth()
|
||||||
showToast('已退出登录')
|
showToast('已退出登录')
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
/* 页面通用背景基线:统一浅灰底色,减少各页面重复定义。 */
|
||||||
.page-base {
|
.page-base {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #f1f1f4;
|
background: #f1f1f4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 顶部导航通用布局:居中标题 + 左右扩展占位。 */
|
||||||
.top-nav-base {
|
.top-nav-base {
|
||||||
height: 120rpx;
|
height: 120rpx;
|
||||||
padding: 24rpx 24rpx 0;
|
padding: 24rpx 24rpx 0;
|
||||||
|
|
@ -19,6 +21,7 @@
|
||||||
color: #222222;
|
color: #222222;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 空状态通用样式,供首页/事件页等无数据场景复用。 */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -40,6 +43,7 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 卡片外观基类。 */
|
||||||
.card-base {
|
.card-base {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
|
// uni-app Promise 适配:把 [err, data] 风格的返回值统一为 resolve/reject。
|
||||||
uni.addInterceptor({
|
uni.addInterceptor({
|
||||||
returnValue (res) {
|
returnValue (res) {
|
||||||
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
|
if (!(!!res && (typeof res === 'object' || typeof res === 'function') && typeof res.then === 'function')) {
|
||||||
return res;
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
res.then((res) => {
|
res.then((value) => {
|
||||||
if (!res) return resolve(res)
|
if (!value) {
|
||||||
return res[0] ? reject(res[0]) : resolve(res[1])
|
return resolve(value)
|
||||||
});
|
}
|
||||||
});
|
|
||||||
},
|
// uni API Promise 通常返回 [error, result]。
|
||||||
});
|
return value[0] ? reject(value[0]) : resolve(value[1])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// 统一复用 api/user.js 的用户相关接口,供页面层按 utils 路径导入。
|
||||||
export {
|
export {
|
||||||
getUserProfile,
|
getUserProfile,
|
||||||
loginByMobile,
|
loginByMobile,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# BLE 模块说明
|
||||||
|
|
||||||
|
- 核心入口: `utils/ble/index.js`
|
||||||
|
- 推荐使用: `createUnifiedBleController`
|
||||||
|
- 本模块目标: 在不改变业务语义的前提下,抽离连接/鉴权/WiFi/雷达/解析公共能力,兼容 ED713 与 ED719。
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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, ':')
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -2,5 +2,6 @@ import request from '@/api/http/client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 兼容层:保留旧导入路径 `@/utils/request`。
|
* 兼容层:保留旧导入路径 `@/utils/request`。
|
||||||
|
* 新代码建议直接使用 `@/api/http/client`。
|
||||||
*/
|
*/
|
||||||
export default request
|
export default request
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue