Compare commits
No commits in common. "82b287dd9286a55471a71e75883224f422841a41" and "e4a977532794ff226573ddbf53f2c92c2924eeb5" have entirely different histories.
82b287dd92
...
e4a9775327
|
|
@ -1,26 +0,0 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{js,ts,css,less,scss,vue,html,json,md}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.wxss]
|
||||
indent_size = 2
|
||||
|
||||
[*.json]
|
||||
indent_size = 2
|
||||
|
|
@ -2,24 +2,4 @@ node_modules/
|
|||
unpackage/
|
||||
.hbuilderx/
|
||||
.DS_Store
|
||||
.codex-tasks/
|
||||
|
||||
.idea/
|
||||
|
||||
.vscode/
|
||||
.prettierignore
|
||||
.prettierrc
|
||||
|
||||
# Graphify (generated)
|
||||
graphify-out/
|
||||
|
||||
# Claude Code && Codex
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
# Codegraph
|
||||
.codegraph/
|
||||
|
||||
# Project-specific
|
||||
docs/superpowers/
|
||||
.codex-tasks/
|
||||
11
App.vue
11
App.vue
|
|
@ -1,17 +1,12 @@
|
|||
<script>
|
||||
import { ensureCurrentPageAuth } from './utils/auth'
|
||||
|
||||
/**
|
||||
* 应用根组件:仅承载全局生命周期与登录态兜底校验。
|
||||
*/
|
||||
export default {
|
||||
onLaunch() {
|
||||
console.log('App Launch')
|
||||
},
|
||||
onShow() {
|
||||
console.log('App Show')
|
||||
|
||||
// 每次回到前台时检查当前页权限,避免未登录状态残留在受保护页面。
|
||||
ensureCurrentPageAuth()
|
||||
},
|
||||
onHide() {
|
||||
|
|
@ -20,8 +15,6 @@ export default {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'uview-plus/index.scss';
|
||||
|
||||
/* 每个页面公共 css(当前按需在页面中引入 common.css) */
|
||||
<style>
|
||||
/*每个页面公共css */
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ import { showToast } from '@/utils/toast'
|
|||
|
||||
let baseURL = DEFAULT_BASE_URL
|
||||
|
||||
/**
|
||||
* 拼接请求地址:支持绝对地址 / 相对地址 / 仅 baseURL。
|
||||
*/
|
||||
function buildUrl(url = '') {
|
||||
if (!url) {
|
||||
return baseURL
|
||||
|
|
@ -46,9 +43,6 @@ function buildHeaders(customHeader = {}, needAuth = true, withJson = true) {
|
|||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* 401 统一处理:清理本地登录态并回到登录页。
|
||||
*/
|
||||
function handleUnauthorized() {
|
||||
if (!isAuthEnabled()) {
|
||||
return
|
||||
|
|
@ -143,7 +137,6 @@ function request(options = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
// 语义化快捷方法,保持页面侧调用简洁。
|
||||
request.get = (url, params = {}, config = {}) => {
|
||||
return request({
|
||||
url,
|
||||
|
|
@ -180,9 +173,6 @@ request.delete = (url, data = {}, config = {}) => {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件封装:保持与 request 一致的错误处理与鉴权策略。
|
||||
*/
|
||||
request.upload = (url, filePath, name = 'file', formData = {}, config = {}) => {
|
||||
const {
|
||||
header = {},
|
||||
|
|
|
|||
|
|
@ -1,38 +1,16 @@
|
|||
/**
|
||||
* 目标服务 UUID(用于优先识别特定设备)。
|
||||
* 协议文档主服务是 0x00F4,部分固件会使用 0xF400 / 0xFFF4。
|
||||
*/
|
||||
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_SERVICE_UUID_COMPAT = 'FFF4'
|
||||
export const TARGET_SERVICE_UUID_COMPAT_FULL = '0000FFF4-0000-1000-8000-00805F9B34FB'
|
||||
|
||||
export const TARGET_SHORT_UUID = TARGET_SERVICE_UUID
|
||||
export const TARGET_SHORT_UUID_CANDIDATES = Object.freeze([
|
||||
TARGET_SERVICE_UUID,
|
||||
TARGET_SERVICE_UUID_ALT,
|
||||
TARGET_SERVICE_UUID_COMPAT
|
||||
])
|
||||
|
||||
export const DISCOVERY_FILTER_SERVICE_UUIDS = Object.freeze([
|
||||
TARGET_SERVICE_UUID_COMPAT_FULL,
|
||||
TARGET_SERVICE_UUID_FULL,
|
||||
TARGET_SERVICE_UUID_ALT_FULL
|
||||
])
|
||||
export const TARGET_SERVICE_UUID = '0000F400-0000-1000-8000-00805F9B34FB'
|
||||
export const TARGET_SHORT_UUID = '00F4'
|
||||
|
||||
/**
|
||||
* 蓝牙常见错误提示映射。
|
||||
*/
|
||||
export const BLUETOOTH_ERROR_MESSAGES = Object.freeze({
|
||||
10001: '蓝牙不可用,请先开启手机蓝牙',
|
||||
10002: '未找到指定设备,请确认设备在附近且处于广播状态',
|
||||
10003: '连接失败,请重试',
|
||||
10012: '操作超时,请重试',
|
||||
10013: '无效参数,请重试',
|
||||
10016: '蓝牙适配器初始化失败',
|
||||
10017: '启动搜索失败'
|
||||
10012: '操作超时,请重试'
|
||||
})
|
||||
|
||||
export const DEFAULT_BLUETOOTH_ERROR_MESSAGE = '蓝牙搜索启动失败'
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -1,168 +0,0 @@
|
|||
# 统一 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` 状态码
|
||||
- 当前项目约定:**已知密钥 = 设备 MAC 去分隔符后 + `0000`**,例如 `10:52:1C:81:64:F4` → `10521C8164F40000`
|
||||
- 鉴权成功后进行功能操作:
|
||||
- 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)`
|
||||
- `createAuthModule(ble).ensureAuthorizedByMac({ suffix: '0000' })`
|
||||
- `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。
|
||||
|
||||
|
||||
## 6. 修改记录
|
||||
|
||||
### 2026-04-30 对照协议文档第二轮修复
|
||||
|
||||
#### commonParser.js
|
||||
- `parseDeviceInfo(payload, profileId)`: 重写为按 ED713/ED719 分路解析
|
||||
- ED713:MAC[0:6] + bindStatus[6] + 其它[7:10] + productId[10:12] + baseBoard[12] + coreBoard[13] + 其它[14:16] + deviceType[16] + wifiStatus[17] + cellularStatus[18]
|
||||
- ED719:布局与 ED713 一致(bindStatus/productId 偏移相同,文档仅省略标注),复用 `parseEd713DeviceInfo`
|
||||
- `parseEd719Radar`: 修正 `shieldNum` 从 bytes[27] → bytes[24](按文档结构体偏移累加 header[4]+event+people+num+nodata+height+mode+beeper+left+right+front+back+sensitive+nodata1+stateDelay=24)
|
||||
- 新增 `wallOption`、`peopleHaveSwitch`、`peopleActivitySwitch` 字段解析
|
||||
|
||||
#### radar.js
|
||||
- `setNarrowMode(enabled)`: 修正命令格式,从单字节 `0x7C`/`0x7D` 改为两字节 `[0x7C, flag]`,flag 0=关 1=开(符合协议文档 CMD=0x7C + 1byte参数)
|
||||
|
||||
#### auth.js
|
||||
- `readDeviceInfo(options)`: 新增 `msgTimeout` 参数与 4 秒超时保护,防止 UUID_MSG 无回包时页面挂死
|
||||
- `readDeviceInfo()`: 调用 `parseDeviceInfo` 时传入 `ble.getProfile().id`
|
||||
- `ensureAuthorized`/`ensureAuthorizedByMac`: 透传 `options` 到 `readDeviceInfo`
|
||||
- `writeKeyAndExpectStates`: `rejectOnUnexpected` 从 `true` 改为 `false`,避免设备中间态(-1/0)直接 reject 阻断鉴权
|
||||
- `ensureAuthorizedWithDeviceInfo`: 移除 `bindStatus === null` 分支(ED719 实际布局与 ED713 一致,bindStatus 可正常读取)
|
||||
|
||||
#### controller.js
|
||||
- 特征分发器 `createCharacteristicDispatcher` 中 `parseDeviceInfo` 调用传入 `profile.id`
|
||||
|
||||
#### bleCore.js
|
||||
- `closeAdapter()`: 新增重置 `adapterStateHandler`/`connectionStateHandler`/`characteristicValueHandler`/`deviceFoundHandler` 为 `null`,确保 `openAdapter` 后 `init()` 可重新注册所有原生监听
|
||||
|
||||
#### pages/device/config.vue
|
||||
- `openWifiPanel`: `prepareWifiConfig` 超时从 `800000` 改为 `8000`,新增 `msgTimeout: 4000`
|
||||
- `wifiPanelVisible = true` 后加 `await this.$nextTick()` 确保 Vue DOM 更新后再调原生 toast
|
||||
|
||||
#### hooks/useBluetoothDiscovery.js
|
||||
- 新增 `SCAN_DURATION_MS = 30000`,蓝牙搜索 30 秒后自动停止
|
||||
- `startSearch`: 搜索开始前同步设置 `isSearching = true`(避免异步导致无法暂停);先 `stopDiscoverySilently()` 再 `resetDeviceMap()`(新搜索刷新数据);启动 30 秒自动停止定时器
|
||||
- `startSearch`: `stopDiscoverySilently()` 后执行完整适配器重置(`unwatch*` 注销所有原生监听 → `closeAdapter` 关闭适配器 → `initialized = false`),由 `ensureInitialized` 重新打开并注册全部监听,确保每次搜索都从干净适配器状态开始,修复二次搜索找不到设备的问题
|
||||
- `stopSearch`: 移除 `resetDeviceMap()` 调用,暂停搜索时保留已发现设备数据;清除自动停止定时器
|
||||
|
||||
### 2026-04-29 对照协议文档修复
|
||||
|
||||
(初次修复,记录同上,已被 2026-04-30 条目覆盖)
|
||||
File diff suppressed because it is too large
Load Diff
7
main.js
7
main.js
|
|
@ -1,7 +1,6 @@
|
|||
import App from './App'
|
||||
import { setupAuthInterceptors } from './utils/auth'
|
||||
|
||||
// 应用启动时注册统一路由鉴权拦截器(内部有幂等保护)。
|
||||
setupAuthInterceptors()
|
||||
|
||||
// #ifndef VUE3
|
||||
|
|
@ -17,14 +16,8 @@ app.$mount()
|
|||
|
||||
// #ifdef VUE3
|
||||
import { createSSRApp } from 'vue'
|
||||
import uviewPlus from 'uview-plus'
|
||||
|
||||
/**
|
||||
* uni-app Vue3 入口。
|
||||
*/
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
app.use(uviewPlus)
|
||||
return {
|
||||
app
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,8 +54,7 @@
|
|||
"setting" : {
|
||||
"urlCheck" : false
|
||||
},
|
||||
"usingComponents" : true,
|
||||
"libVersion" : "latest"
|
||||
"usingComponents" : true
|
||||
},
|
||||
"mp-alipay" : {
|
||||
"usingComponents" : true
|
||||
|
|
|
|||
|
|
@ -1,435 +0,0 @@
|
|||
{
|
||||
"name": "smarthome",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"sass": "^1.99.0",
|
||||
"uview-plus": "^3.7.36"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3",
|
||||
"is-glob": "^4.0.3",
|
||||
"node-addon-api": "^7.0.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher-android-arm64": "2.5.6",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.6",
|
||||
"@parcel/watcher-darwin-x64": "2.5.6",
|
||||
"@parcel/watcher-freebsd-x64": "2.5.6",
|
||||
"@parcel/watcher-linux-arm-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-arm-musl": "2.5.6",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-arm64-musl": "2.5.6",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-x64-musl": "2.5.6",
|
||||
"@parcel/watcher-win32-arm64": "2.5.6",
|
||||
"@parcel/watcher-win32-ia32": "2.5.6",
|
||||
"@parcel/watcher-win32-x64": "2.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-android-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-ia32": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
|
||||
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"is-extglob": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.99.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz",
|
||||
"integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^5.1.5",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"sass": "sass.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uview-plus": {
|
||||
"version": "3.7.36",
|
||||
"resolved": "https://registry.npmjs.org/uview-plus/-/uview-plus-3.7.36.tgz",
|
||||
"integrity": "sha512-44pW0oKKatYxCHhXPljaNv5E+CESSJlhsz0FRZRNpMv1ms02m5Agk+CNpgDnv/XNHt9YxHdYSYerN+xnNP5orw==",
|
||||
"engines": {
|
||||
"HBuilderX": "^3.1.0",
|
||||
"uni-app": "^4.66",
|
||||
"uni-app-x": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"sass": "^1.99.0",
|
||||
"uview-plus": "^3.7.36"
|
||||
}
|
||||
}
|
||||
137
pages.json
137
pages.json
|
|
@ -1,74 +1,67 @@
|
|||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/login/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/event/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/device/add",
|
||||
"style": {
|
||||
"navigationBarTitleText": "添加设备"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/device/config",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "智慧养老",
|
||||
"navigationBarBackgroundColor": "#F8F8F8",
|
||||
"backgroundColor": "#F1F1F4"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#666666",
|
||||
"selectedColor": "#2CCB98",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/index/index",
|
||||
"text": "首页"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/event/index",
|
||||
"text": "事件"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mine/index",
|
||||
"text": "我的"
|
||||
}
|
||||
]
|
||||
},
|
||||
"uniIdRouter": {},
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^u-(.*)": "uview-plus/components/u-$1/u-$1.vue",
|
||||
"^up-(.*)": "uview-plus/components/u-$1/u-$1.vue"
|
||||
}
|
||||
}
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/login/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/event/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/device/add",
|
||||
"style": {
|
||||
"navigationBarTitleText": "添加设备"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/device/config",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "智慧养老",
|
||||
"navigationBarBackgroundColor": "#F8F8F8",
|
||||
"backgroundColor": "#F1F1F4"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#666666",
|
||||
"selectedColor": "#2CCB98",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"borderStyle": "black",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/index/index",
|
||||
"text": "首页"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/event/index",
|
||||
"text": "事件"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mine/index",
|
||||
"text": "我的"
|
||||
}
|
||||
]
|
||||
},
|
||||
"uniIdRouter": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
<!-- 设备添加页:用于承接后续蓝牙搜索/连接配网流程 -->
|
||||
<template>
|
||||
<view class="page page-base">
|
||||
<view class="card card-base">
|
||||
|
|
@ -9,7 +8,6 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
// 当前仅提供页面壳,实际流程由蓝牙控制模块接入。
|
||||
export default {}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,122 +27,22 @@
|
|||
<view class="device-name-row">
|
||||
<text class="device-name">{{ device.showName }}</text>
|
||||
<text v-if="device.hasTargetService" class="device-tag">00F4</text>
|
||||
<text v-else-if="device.matchedByName || device.matchedByPayload" class="device-tag name-tag">名称匹配
|
||||
</text>
|
||||
</view>
|
||||
<text class="device-id">{{ device.deviceId }}</text>
|
||||
<view class="device-actions">
|
||||
<view class="action-btn" :class="{ disabled: isDevicePreparing(device.deviceId) }"
|
||||
@click.stop="openWifiPanel(device.deviceId)">
|
||||
{{ isDevicePreparing(device.deviceId) ? '鉴权中' : '网络配置' }}
|
||||
</view>
|
||||
<view class="action-btn">网络配置</view>
|
||||
<view class="action-btn">参数配置</view>
|
||||
<view class="action-btn">服务器配置</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="wifiPanelVisible" class="wifi-mask" @click="closeWifiPanel">
|
||||
<view class="wifi-dialog" @click.stop>
|
||||
<view class="wifi-dialog-header">
|
||||
<text class="wifi-dialog-title">设备网络设置</text>
|
||||
<text class="wifi-dialog-close" @click="closeWifiPanel">×</text>
|
||||
</view>
|
||||
|
||||
<view class="wifi-device-row">
|
||||
<text class="wifi-device-label">设备</text>
|
||||
<text class="wifi-device-value">{{ selectedDeviceName }}</text>
|
||||
</view>
|
||||
|
||||
<view class="wifi-row wifi-row-ssid">
|
||||
<text class="wifi-label">
|
||||
<text class="wifi-required">*</text>
|
||||
WiFi名称
|
||||
</text>
|
||||
<input
|
||||
v-model.trim="wifiForm.ssid"
|
||||
class="wifi-input wifi-input-ssid"
|
||||
placeholder="请输入 WiFi 名称"
|
||||
:maxlength="32"
|
||||
confirm-type="done"
|
||||
/>
|
||||
<view class="wifi-current-btn" :class="{ disabled: fetchingCurrentWifi }" @click.stop="fetchCurrentWifiSsid">
|
||||
{{ fetchingCurrentWifi ? '获取中...' : '一键获取当前连接WiFi' }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="wifi-row">
|
||||
<text class="wifi-label">
|
||||
<text class="wifi-required">*</text>
|
||||
WiFi密码
|
||||
</text>
|
||||
<input
|
||||
v-model.trim="wifiForm.password"
|
||||
class="wifi-input"
|
||||
placeholder="请输入密码"
|
||||
password
|
||||
:maxlength="63"
|
||||
confirm-type="done"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="wifi-import-row" @click="importLastWifiConfig">
|
||||
一键导入上次 WiFi 配置
|
||||
</view>
|
||||
|
||||
<view v-if="wifiErrorTip" class="wifi-error-tip">
|
||||
{{ wifiErrorTip }}
|
||||
</view>
|
||||
|
||||
<view class="wifi-submit-btn" :class="{ disabled: wifiSending }" @click="confirmWifiSettings">
|
||||
{{ wifiSending ? '发送中...' : '发送' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createBluetoothDiscovery } from '@/hooks/useBluetoothDiscovery'
|
||||
import { createBluetoothDiscovery } from '@/hooks/useBluetoothDiscovery'
|
||||
import { navigateBack } from '@/utils/navigation'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const WIFI_HISTORY_STORAGE_KEY = 'SMARTHOME_LAST_WIFI_CONFIG'
|
||||
|
||||
const WIFI_STATE_MESSAGES = Object.freeze({
|
||||
1: 'WiFi 参数校验失败,请检查名称与密码',
|
||||
2: '设备连接该 WiFi 失败,请确认密码是否正确',
|
||||
3: 'WiFi 配置成功',
|
||||
4: '设备正在连接 WiFi,请稍候'
|
||||
})
|
||||
|
||||
function getErrorMessage(error) {
|
||||
if (!error) {
|
||||
return '操作失败,请重试'
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
|
||||
return error.message || error.errMsg || '操作失败,请重试'
|
||||
}
|
||||
|
||||
function promisifyUniApi(apiName, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof uni[apiName] !== 'function') {
|
||||
reject(new Error(`当前环境不支持 ${apiName}`))
|
||||
return
|
||||
}
|
||||
|
||||
uni[apiName]({
|
||||
...params,
|
||||
success: (res) => resolve(res),
|
||||
fail: (err) => reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
|
|
@ -150,40 +50,17 @@ export default {
|
|||
isSearching: false,
|
||||
deviceList: [],
|
||||
deviceMap: {},
|
||||
discoveryController: null,
|
||||
|
||||
// WiFi 弹层状态。
|
||||
wifiPanelVisible: false,
|
||||
wifiPreparing: false,
|
||||
activePreparingDeviceId: '',
|
||||
wifiSending: false,
|
||||
fetchingCurrentWifi: false,
|
||||
wifiPrepared: false,
|
||||
wifiErrorTip: '',
|
||||
devicePreparingMap: {},
|
||||
selectedWifiDevice: null,
|
||||
wifiForm: {
|
||||
ssid: '',
|
||||
password: ''
|
||||
}
|
||||
discoveryController: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
searchBtnText() {
|
||||
return this.isSearching ? '搜索中' : '搜索'
|
||||
},
|
||||
|
||||
selectedDeviceName() {
|
||||
if (!this.selectedWifiDevice) {
|
||||
return '未选择设备'
|
||||
}
|
||||
|
||||
return this.selectedWifiDevice.showName || this.selectedWifiDevice.deviceId || '未命名设备'
|
||||
}
|
||||
},
|
||||
created() {
|
||||
/**
|
||||
* 蓝牙搜索控制器:统一处理设备发现、连接、鉴权、WiFi 发送等流程。
|
||||
* 蓝牙搜索控制器:统一处理设备发现、排序、清理逻辑。
|
||||
*/
|
||||
this.discoveryController = createBluetoothDiscovery({
|
||||
onSearchingChange: (value) => {
|
||||
|
|
@ -204,76 +81,11 @@ export default {
|
|||
this.cleanupDiscovery()
|
||||
},
|
||||
methods: {
|
||||
async safeDisconnect() {
|
||||
if (!this.discoveryController) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.discoveryController.disconnectDevice()
|
||||
} catch (error) {
|
||||
}
|
||||
},
|
||||
|
||||
cleanupWifiPanelState() {
|
||||
this.wifiPanelVisible = false
|
||||
this.wifiPrepared = false
|
||||
this.wifiPreparing = false
|
||||
this.activePreparingDeviceId = ''
|
||||
this.fetchingCurrentWifi = false
|
||||
this.wifiErrorTip = ''
|
||||
this.selectedWifiDevice = null
|
||||
this.resetWifiForm()
|
||||
},
|
||||
|
||||
resetWifiForm() {
|
||||
// 只清空当前弹层临时输入,不影响本地历史缓存。
|
||||
this.wifiForm.ssid = ''
|
||||
this.wifiForm.password = ''
|
||||
},
|
||||
|
||||
setDevicePreparing(deviceId, preparing) {
|
||||
const safeDeviceId = String(deviceId || '').trim()
|
||||
if (!safeDeviceId) {
|
||||
return
|
||||
}
|
||||
|
||||
this.devicePreparingMap = {
|
||||
...this.devicePreparingMap,
|
||||
[safeDeviceId]: Boolean(preparing)
|
||||
}
|
||||
},
|
||||
|
||||
isDevicePreparing(deviceId) {
|
||||
const safeDeviceId = String(deviceId || '').trim()
|
||||
if (!safeDeviceId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Boolean(this.devicePreparingMap[safeDeviceId])
|
||||
},
|
||||
|
||||
getDeviceById(deviceId) {
|
||||
const safeDeviceId = String(deviceId || '').trim()
|
||||
if (!safeDeviceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mapDevice = this.deviceMap[safeDeviceId]
|
||||
if (mapDevice) {
|
||||
return mapDevice
|
||||
}
|
||||
|
||||
return this.deviceList.find((item) => item.deviceId === safeDeviceId) || null
|
||||
},
|
||||
|
||||
cleanupDiscovery() {
|
||||
if (!this.discoveryController) {
|
||||
return
|
||||
}
|
||||
|
||||
this.devicePreparingMap = {}
|
||||
this.cleanupWifiPanelState()
|
||||
this.discoveryController.cleanupDiscovery()
|
||||
},
|
||||
|
||||
|
|
@ -296,229 +108,6 @@ export default {
|
|||
await this.discoveryController.startSearch()
|
||||
} catch (error) {
|
||||
}
|
||||
},
|
||||
|
||||
async openWifiPanel(deviceId) {
|
||||
if (this.wifiPreparing || this.wifiSending) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.discoveryController) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetDeviceId = String(deviceId || '').trim()
|
||||
if (!targetDeviceId) {
|
||||
showToast('未选择可配置设备')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.activePreparingDeviceId && this.activePreparingDeviceId !== targetDeviceId) {
|
||||
showToast('正在为其他设备鉴权,请稍候')
|
||||
return
|
||||
}
|
||||
|
||||
const targetDevice = this.getDeviceById(targetDeviceId)
|
||||
if (!targetDevice) {
|
||||
showToast('设备已失效,请重新搜索')
|
||||
return
|
||||
}
|
||||
|
||||
this.wifiPreparing = true
|
||||
this.activePreparingDeviceId = targetDeviceId
|
||||
this.wifiPrepared = false
|
||||
this.selectedWifiDevice = targetDevice
|
||||
this.setDevicePreparing(targetDeviceId, true)
|
||||
|
||||
uni.showLoading({
|
||||
title: '密钥鉴权中',
|
||||
mask: true
|
||||
})
|
||||
|
||||
let authError = null
|
||||
try {
|
||||
/**
|
||||
* 先按项目固定已知密钥规则(MAC+0000)完成鉴权,成功后再允许进入 WiFi 弹层。
|
||||
*/
|
||||
await this.discoveryController.prepareWifiConfig(targetDeviceId, {
|
||||
suffix: '0000',
|
||||
timeout: 8000,
|
||||
msgTimeout: 4000
|
||||
})
|
||||
|
||||
this.wifiPrepared = true
|
||||
this.wifiPanelVisible = true
|
||||
// 确保 Vue 完成 DOM 更新后再调原生 toast,避免原生层覆盖 Vue 弹层
|
||||
await this.$nextTick()
|
||||
} catch (error) {
|
||||
authError = error
|
||||
await this.safeDisconnect()
|
||||
} finally {
|
||||
this.setDevicePreparing(targetDeviceId, false)
|
||||
this.activePreparingDeviceId = ''
|
||||
this.wifiPreparing = false
|
||||
// showLoading 与 showToast 共用同一遮罩层,必须先 hideLoading 再弹提示,
|
||||
// 否则 hideLoading 会把 catch 里的 toast 一起清掉(表现为弹窗瞬间消失)。
|
||||
uni.hideLoading()
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
// 鉴权失败用模态弹窗,需用户主动确认,确保错误诊断信息可见可读
|
||||
uni.showModal({
|
||||
title: '密钥鉴权失败',
|
||||
content: getErrorMessage(authError),
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
})
|
||||
} else if (this.wifiPanelVisible) {
|
||||
showToast('密钥鉴权成功,请填写WiFi信息')
|
||||
}
|
||||
},
|
||||
|
||||
async closeWifiPanel(force = false) {
|
||||
if (!force && (this.wifiSending || this.wifiPreparing)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cleanupWifiPanelState()
|
||||
await this.safeDisconnect()
|
||||
},
|
||||
|
||||
async fetchCurrentWifiSsid() {
|
||||
if (this.fetchingCurrentWifi) {
|
||||
return
|
||||
}
|
||||
|
||||
this.fetchingCurrentWifi = true
|
||||
|
||||
try {
|
||||
// 启动 WiFi 模块(部分端调用 getConnectedWifi 前必须先 startWifi)。
|
||||
if (typeof uni.startWifi === 'function') {
|
||||
try {
|
||||
await promisifyUniApi('startWifi')
|
||||
} catch (error) {
|
||||
// 已启动等非致命错误忽略,继续尝试读取当前连接。
|
||||
}
|
||||
}
|
||||
|
||||
const result = await promisifyUniApi('getConnectedWifi')
|
||||
const ssid = String(result?.wifi?.SSID || result?.wifi?.ssid || result?.SSID || '').trim()
|
||||
|
||||
if (!ssid || ssid.toLowerCase().includes('unknown')) {
|
||||
throw new Error('未获取到当前连接 WiFi 名称')
|
||||
}
|
||||
|
||||
this.wifiForm.ssid = ssid
|
||||
showToast('已填入当前连接WiFi')
|
||||
} catch (error) {
|
||||
showToast(getErrorMessage(error))
|
||||
} finally {
|
||||
this.fetchingCurrentWifi = false
|
||||
}
|
||||
},
|
||||
|
||||
importLastWifiConfig() {
|
||||
try {
|
||||
const cached = uni.getStorageSync(WIFI_HISTORY_STORAGE_KEY)
|
||||
|
||||
if (!cached || typeof cached !== 'object') {
|
||||
showToast('暂无上次 WiFi 配置')
|
||||
return
|
||||
}
|
||||
|
||||
const ssid = String(cached.ssid || '').trim()
|
||||
const password = String(cached.password || '').trim()
|
||||
|
||||
if (!ssid || !password) {
|
||||
showToast('暂无可用的历史 WiFi 配置')
|
||||
return
|
||||
}
|
||||
|
||||
this.wifiForm.ssid = ssid
|
||||
this.wifiForm.password = password
|
||||
showToast('已导入上次 WiFi 配置')
|
||||
} catch (error) {
|
||||
showToast('读取历史 WiFi 配置失败')
|
||||
}
|
||||
},
|
||||
|
||||
cacheWifiConfig(ssid, password) {
|
||||
try {
|
||||
uni.setStorageSync(WIFI_HISTORY_STORAGE_KEY, {
|
||||
ssid,
|
||||
password,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
} catch (error) {
|
||||
}
|
||||
},
|
||||
|
||||
getWifiStateMessage(stateCode) {
|
||||
return WIFI_STATE_MESSAGES[stateCode] || `WiFi 状态码异常: ${stateCode}`
|
||||
},
|
||||
|
||||
async confirmWifiSettings() {
|
||||
if (this.wifiSending) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.wifiPrepared) {
|
||||
showToast('请先完成密钥鉴权')
|
||||
return
|
||||
}
|
||||
|
||||
const ssid = String(this.wifiForm.ssid || '').trim()
|
||||
const password = String(this.wifiForm.password || '').trim()
|
||||
|
||||
if (!ssid) {
|
||||
showToast('请输入 WiFi 名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
showToast('请输入 WiFi 密码')
|
||||
return
|
||||
}
|
||||
|
||||
this.wifiSending = true
|
||||
this.wifiErrorTip = ''
|
||||
|
||||
let needAutoClose = false
|
||||
let needCacheWifi = false
|
||||
let tipMessage = ''
|
||||
|
||||
try {
|
||||
/**
|
||||
* 发送后必须等待设备通过 UUID_STATE 返回最终结果(1/2/3)
|
||||
* 才进行提示并自动关闭弹层。
|
||||
*/
|
||||
const wifiResult = await this.discoveryController.configureWifi(ssid, password, {
|
||||
timeout: 60000
|
||||
})
|
||||
|
||||
const stateMessage = this.getWifiStateMessage(wifiResult.stateCode)
|
||||
tipMessage = stateMessage
|
||||
needAutoClose = true
|
||||
needCacheWifi = Boolean(wifiResult.success)
|
||||
} catch (error) {
|
||||
// 超时/异常场景:错误信息持久展示在弹层内,避免瞬态 toast 被覆盖看不清
|
||||
tipMessage = getErrorMessage(error)
|
||||
this.wifiErrorTip = tipMessage
|
||||
} finally {
|
||||
this.wifiSending = false
|
||||
|
||||
if (tipMessage) {
|
||||
showToast(tipMessage)
|
||||
}
|
||||
|
||||
if (needCacheWifi) {
|
||||
this.cacheWifiConfig(ssid, password)
|
||||
}
|
||||
|
||||
if (needAutoClose) {
|
||||
await this.closeWifiPanel(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -684,11 +273,6 @@ export default {
|
|||
padding: 2rpx 10rpx;
|
||||
}
|
||||
|
||||
.name-tag {
|
||||
color: #2f7de1;
|
||||
border-color: #2f7de1;
|
||||
}
|
||||
|
||||
.device-id {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
|
|
@ -711,169 +295,4 @@ export default {
|
|||
border-radius: 6rpx;
|
||||
background: #f8fffc;
|
||||
}
|
||||
|
||||
.action-btn.disabled {
|
||||
border-color: #9fdcc3;
|
||||
color: #7db89e;
|
||||
background: #f5faf7;
|
||||
}
|
||||
|
||||
.wifi-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.wifi-dialog {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 28rpx 28rpx 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wifi-dialog-header {
|
||||
height: 108rpx;
|
||||
border-bottom: 1rpx solid #efefef;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wifi-dialog-title {
|
||||
font-size: 42rpx;
|
||||
color: #2d2f33;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wifi-dialog-close {
|
||||
position: absolute;
|
||||
right: 26rpx;
|
||||
top: 18rpx;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
text-align: center;
|
||||
line-height: 72rpx;
|
||||
font-size: 56rpx;
|
||||
color: #b8bcc3;
|
||||
}
|
||||
|
||||
.wifi-device-row {
|
||||
min-height: 74rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
padding: 0 30rpx;
|
||||
border-bottom: 1rpx solid #f4f4f4;
|
||||
}
|
||||
|
||||
.wifi-device-label {
|
||||
font-size: 28rpx;
|
||||
color: #8a8f98;
|
||||
}
|
||||
|
||||
.wifi-device-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 27rpx;
|
||||
color: #4a4f56;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wifi-row {
|
||||
min-height: 102rpx;
|
||||
border-bottom: 1rpx solid #f2f2f2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 30rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.wifi-row-ssid {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wifi-label {
|
||||
width: 170rpx;
|
||||
font-size: 34rpx;
|
||||
color: #363b45;
|
||||
}
|
||||
|
||||
.wifi-required {
|
||||
color: #ff4f4f;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.wifi-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 80rpx;
|
||||
font-size: 32rpx;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.wifi-input-ssid {
|
||||
max-width: 260rpx;
|
||||
}
|
||||
|
||||
.wifi-current-btn {
|
||||
flex-shrink: 0;
|
||||
min-width: 190rpx;
|
||||
height: 62rpx;
|
||||
line-height: 62rpx;
|
||||
border: 1rpx solid #74dcb2;
|
||||
border-radius: 8rpx;
|
||||
color: #24b478;
|
||||
background: #effcf5;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
padding: 0 12rpx;
|
||||
}
|
||||
|
||||
.wifi-current-btn.disabled {
|
||||
border-color: #b8d8ca;
|
||||
color: #9bbbae;
|
||||
background: #f3f6f4;
|
||||
}
|
||||
|
||||
.wifi-import-row {
|
||||
min-height: 100rpx;
|
||||
border-bottom: 1rpx solid #f2f2f2;
|
||||
font-size: 34rpx;
|
||||
color: #3bc58d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wifi-error-tip {
|
||||
min-height: 80rpx;
|
||||
padding: 16rpx 30rpx;
|
||||
font-size: 26rpx;
|
||||
color: #d33b1d;
|
||||
background: #fff3f1;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.wifi-submit-btn {
|
||||
height: 100rpx;
|
||||
background: #11c462;
|
||||
color: #ffffff;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wifi-submit-btn.disabled {
|
||||
background: #8edcae;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,44 +1,46 @@
|
|||
<!-- 事件页:使用 uview-plus 筛选与空状态组件 -->
|
||||
<template>
|
||||
<view class="page page-base">
|
||||
<up-navbar title="事件" :fixed="false" :safe-area-inset-top="true" :border="false" left-icon="" />
|
||||
|
||||
<view class="filters">
|
||||
<view><up-button size="mini" type="primary" plain text="全部场所" /></view>
|
||||
<view><up-button size="mini" type="primary" plain text="全部状态" /></view>
|
||||
<view><up-button size="mini" type="primary" plain text="时间" /></view>
|
||||
<view class="top-nav top-nav-base">
|
||||
<view class="title top-nav-title-base">事件</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-area">
|
||||
<up-empty mode="message" text="暂无事件数据" />
|
||||
<view class="filters">
|
||||
<view class="filter-item">全部场所</view>
|
||||
<view class="filter-item">全部状态</view>
|
||||
<view class="filter-item">时间</view>
|
||||
</view>
|
||||
|
||||
<view class="empty-area empty-state">
|
||||
<view class="empty-box empty-state-box"></view>
|
||||
<text class="empty-text empty-state-text">暂无数据</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// 当前为静态页占位,不包含交互逻辑。
|
||||
export default {}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
<style>
|
||||
@import '@/styles/common.css';
|
||||
|
||||
.filters {
|
||||
> view + view {
|
||||
margin-left: 18rpx;
|
||||
}
|
||||
min-height: 96rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
height: 92rpx;
|
||||
background: #ffffff;
|
||||
border-top: 1rpx solid #ededed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 42rpx;
|
||||
color: #3a3a3a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-area {
|
||||
min-height: 880rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 980rpx;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<!-- 首页:切换为 uview-plus 组件化结构,保留原有设备添加与配置入口 -->
|
||||
<template>
|
||||
<view class="page page-base">
|
||||
<up-navbar title="智慧养老" :fixed="false" :safe-area-inset-top="true" :border="false" left-icon="" />
|
||||
<view class="top-nav top-nav-base">
|
||||
<view class="title">智慧养老</view>
|
||||
</view>
|
||||
|
||||
<view class="banner">
|
||||
<view class="banner-left">
|
||||
|
|
@ -9,33 +10,22 @@
|
|||
<text class="banner-sub">一键开启智慧好生活</text>
|
||||
</view>
|
||||
<view class="banner-illus">
|
||||
<up-icon name="home" color="#ffffff" size="52" />
|
||||
<view class="phone"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<up-notice-bar
|
||||
mode="link"
|
||||
:text="noticeText"
|
||||
color="#2f5a3a"
|
||||
bg-color="#e9fff3"
|
||||
@click="goConfig"
|
||||
/>
|
||||
|
||||
<view class="site-row" @click="goConfig">
|
||||
<view class="site-left">
|
||||
<up-icon name="list" size="18" color="#26c996" />
|
||||
<text class="site-text">请选择场所</text>
|
||||
</view>
|
||||
<up-icon name="arrow-right" size="14" color="#26c996" />
|
||||
<text class="menu">☰</text>
|
||||
<text class="site-text">请选择场所</text>
|
||||
<text class="down">⌄</text>
|
||||
</view>
|
||||
|
||||
<view class="empty-area">
|
||||
<up-empty mode="list" text="暂无数据" />
|
||||
<view class="empty-area empty-state">
|
||||
<view class="empty-box empty-state-box"></view>
|
||||
<text class="empty-text empty-state-text">暂无数据</text>
|
||||
</view>
|
||||
|
||||
<view class="fab-wrap">
|
||||
<up-button type="success" shape="circle" text="添加设备" @click="goAdd" />
|
||||
</view>
|
||||
<view class="fab" @click="goAdd">+</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
|
@ -44,17 +34,10 @@ import { ROUTES } from '@/constants/routes'
|
|||
import { navigateTo } from '@/utils/navigation'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
noticeText: '点击选择场所后可进入设备配置流程'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 进入设备添加页(后续会承接蓝牙配网流程入口)。
|
||||
goAdd() {
|
||||
navigateTo(ROUTES.DEVICE_ADD)
|
||||
},
|
||||
// 进入设备配置页。
|
||||
goConfig() {
|
||||
navigateTo(ROUTES.DEVICE_CONFIG)
|
||||
}
|
||||
|
|
@ -62,19 +45,25 @@ export default {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
<style>
|
||||
@import '@/styles/common.css';
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
padding-bottom: 180rpx;
|
||||
padding-bottom: 160rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.banner {
|
||||
margin: 0;
|
||||
min-height: 360rpx;
|
||||
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
|
||||
padding: 48rpx 44rpx;
|
||||
height: 380rpx;
|
||||
background: #28cda0;
|
||||
padding: 56rpx 52rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
|
@ -86,61 +75,75 @@ export default {
|
|||
}
|
||||
|
||||
.banner-title {
|
||||
font-size: 52rpx;
|
||||
font-size: 58rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.banner-sub {
|
||||
font-size: 34rpx;
|
||||
font-size: 46rpx;
|
||||
color: #e9fff8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.banner-illus {
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
width: 220rpx;
|
||||
height: 220rpx;
|
||||
border-radius: 16rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.phone {
|
||||
width: 120rpx;
|
||||
height: 170rpx;
|
||||
border: 8rpx solid rgba(255, 255, 255, 0.92);
|
||||
border-radius: 22rpx;
|
||||
}
|
||||
|
||||
.site-row {
|
||||
height: 108rpx;
|
||||
padding: 0 32rpx;
|
||||
padding: 0 36rpx;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 46rpx;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.site-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.menu {
|
||||
margin-right: 22rpx;
|
||||
}
|
||||
|
||||
.site-text {
|
||||
margin-left: 14rpx;
|
||||
font-size: 34rpx;
|
||||
color: #222;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.down {
|
||||
margin-left: 10rpx;
|
||||
color: #26c996;
|
||||
}
|
||||
|
||||
.empty-area {
|
||||
margin-top: 40rpx;
|
||||
min-height: 680rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 760rpx;
|
||||
}
|
||||
|
||||
.fab-wrap {
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32rpx;
|
||||
right: 38rpx;
|
||||
bottom: 220rpx;
|
||||
width: 240rpx;
|
||||
width: 118rpx;
|
||||
height: 118rpx;
|
||||
border-radius: 59rpx;
|
||||
background: #2cc998;
|
||||
color: #ffffff;
|
||||
font-size: 88rpx;
|
||||
line-height: 110rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 12rpx 24rpx rgba(29, 177, 130, 0.3);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,47 +6,31 @@
|
|||
</view>
|
||||
|
||||
<view class="card">
|
||||
<up-form :model="form" label-position="top" :border-bottom="false">
|
||||
<up-form-item label="手机号" :required="true">
|
||||
<view class="mobile-wrap">
|
||||
<text class="prefix">+86</text>
|
||||
<up-input
|
||||
v-model="form.mobile"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
clearable
|
||||
placeholder="请输入手机号"
|
||||
border="none"
|
||||
:custom-style="inputInnerStyle"
|
||||
/>
|
||||
</view>
|
||||
</up-form-item>
|
||||
<view class="form-row">
|
||||
<text class="prefix">+86</text>
|
||||
<input
|
||||
v-model="mobile"
|
||||
class="input"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<up-form-item label="验证码" :required="true">
|
||||
<view class="code-wrap">
|
||||
<up-input
|
||||
v-model="form.code"
|
||||
type="number"
|
||||
maxlength="6"
|
||||
clearable
|
||||
placeholder="请输入验证码"
|
||||
border="none"
|
||||
:custom-style="inputInnerStyle"
|
||||
/>
|
||||
<up-button
|
||||
class="code-btn"
|
||||
size="mini"
|
||||
type="primary"
|
||||
:plain="countdown > 0"
|
||||
:disabled="countdown > 0"
|
||||
:text="codeBtnText"
|
||||
@click="sendCode"
|
||||
/>
|
||||
</view>
|
||||
</up-form-item>
|
||||
</up-form>
|
||||
<view class="form-row code-row">
|
||||
<input
|
||||
v-model="code"
|
||||
class="input"
|
||||
type="number"
|
||||
maxlength="6"
|
||||
placeholder="请输入验证码"
|
||||
/>
|
||||
<button class="code-btn" :disabled="countdown > 0" @click="sendCode">
|
||||
{{ codeBtnText }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<up-button class="login-btn" type="success" shape="circle" text="手机号验证码登录" @click="loginByMobileSubmit" />
|
||||
<button class="login-btn" @click="loginByMobileSubmit">手机号验证码登录</button>
|
||||
|
||||
<view class="split">
|
||||
<view class="line"></view>
|
||||
|
|
@ -54,7 +38,7 @@
|
|||
<view class="line"></view>
|
||||
</view>
|
||||
|
||||
<up-button class="wx-btn" type="success" shape="circle" text="微信一键登录" @click="loginByWechatSubmit" />
|
||||
<button class="wx-btn" @click="loginByWechatSubmit">微信一键登录</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
|
@ -69,16 +53,11 @@ import { isValidMobile, isValidSmsCode } from '@/utils/validators'
|
|||
export default {
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
mobile: '',
|
||||
code: ''
|
||||
},
|
||||
mobile: '',
|
||||
code: '',
|
||||
countdown: 0,
|
||||
redirect: '',
|
||||
countdownController: null,
|
||||
inputInnerStyle: {
|
||||
padding: '0'
|
||||
}
|
||||
countdownController: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -114,7 +93,7 @@ export default {
|
|||
},
|
||||
|
||||
validateMobileOrToast() {
|
||||
if (isValidMobile(this.form.mobile)) {
|
||||
if (isValidMobile(this.mobile)) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +119,7 @@ export default {
|
|||
}
|
||||
|
||||
try {
|
||||
await sendSmsCode(this.form.mobile)
|
||||
await sendSmsCode(this.mobile)
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
|
|
@ -154,7 +133,7 @@ export default {
|
|||
return
|
||||
}
|
||||
|
||||
if (!isValidSmsCode(this.form.code)) {
|
||||
if (!isValidSmsCode(this.code)) {
|
||||
showToast('请输入6位验证码')
|
||||
return
|
||||
}
|
||||
|
|
@ -164,12 +143,12 @@ export default {
|
|||
})
|
||||
|
||||
try {
|
||||
const data = await loginByMobile(this.form.mobile, this.form.code)
|
||||
const data = await loginByMobile(this.mobile, this.code)
|
||||
this.finishLogin({
|
||||
token: data?.token,
|
||||
user: data?.user || {
|
||||
name: `用户${this.form.mobile.slice(-4)}`,
|
||||
mobile: this.form.mobile,
|
||||
name: `用户${this.mobile.slice(-4)}`,
|
||||
mobile: this.mobile,
|
||||
loginType: 'mobile'
|
||||
}
|
||||
})
|
||||
|
|
@ -235,7 +214,7 @@ export default {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
<style>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #ecf9f5 0%, #f5f6f8 48%, #f1f1f4 100%);
|
||||
|
|
@ -264,27 +243,22 @@ export default {
|
|||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
padding: 30rpx;
|
||||
padding: 38rpx 30rpx 42rpx;
|
||||
box-shadow: 0 16rpx 40rpx rgba(10, 63, 48, 0.08);
|
||||
}
|
||||
|
||||
.mobile-wrap,
|
||||
.code-wrap {
|
||||
.form-row {
|
||||
min-height: 98rpx;
|
||||
border: 1rpx solid #e8eaef;
|
||||
border-radius: 16rpx;
|
||||
padding: 0 20rpx;
|
||||
padding: 0 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mobile-wrap {
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
.code-wrap {
|
||||
margin-top: 10rpx;
|
||||
.form-row + .form-row {
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
|
||||
.prefix {
|
||||
|
|
@ -294,16 +268,57 @@ export default {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
height: 98rpx;
|
||||
font-size: 30rpx;
|
||||
color: #1f2b37;
|
||||
}
|
||||
|
||||
.code-row {
|
||||
padding-right: 12rpx;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
margin-left: 12rpx;
|
||||
width: 190rpx;
|
||||
height: 70rpx;
|
||||
line-height: 70rpx;
|
||||
border-radius: 36rpx;
|
||||
background: #e9faf4;
|
||||
color: #15b880;
|
||||
font-size: 26rpx;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.code-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.code-btn[disabled] {
|
||||
color: #8f98a3;
|
||||
background: #f1f3f6;
|
||||
}
|
||||
|
||||
.login-btn,
|
||||
.wx-btn {
|
||||
margin-top: 26rpx;
|
||||
width: 100%;
|
||||
height: 92rpx;
|
||||
line-height: 92rpx;
|
||||
border-radius: 16rpx;
|
||||
color: #ffffff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
margin-top: 28rpx;
|
||||
background: linear-gradient(90deg, #23c797, #11b880);
|
||||
}
|
||||
|
||||
.wx-btn {
|
||||
margin-top: 22rpx;
|
||||
.login-btn::after,
|
||||
.wx-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.split {
|
||||
|
|
@ -323,4 +338,9 @@ export default {
|
|||
font-size: 26rpx;
|
||||
color: #9aa2ad;
|
||||
}
|
||||
|
||||
.wx-btn {
|
||||
margin-top: 24rpx;
|
||||
background: #07c160;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,51 @@
|
|||
<!-- 我的页:引入 uview-plus 组件重构个人中心菜单 -->
|
||||
<template>
|
||||
<view class="page page-base">
|
||||
<up-navbar title="我的" :fixed="false" :safe-area-inset-top="true" :border="false" left-icon="" />
|
||||
<view class="top-nav top-nav-base">
|
||||
<view class="title top-nav-title-base">我的</view>
|
||||
</view>
|
||||
|
||||
<view class="user-card" @click="goLogin">
|
||||
<up-avatar size="58" text="U" bg-color="#35c48f" />
|
||||
<view class="user-main">
|
||||
<view class="avatar"></view>
|
||||
<view>
|
||||
<text class="user-name">{{ userName }}</text>
|
||||
<text class="user-tip">{{ loginTip }}</text>
|
||||
</view>
|
||||
<up-icon name="arrow-right" size="14" color="#b5b9c0" />
|
||||
</view>
|
||||
|
||||
<up-cell-group :border="false" custom-style="margin: 24rpx; border-radius: 24rpx; overflow: hidden; background: #ffffff;">
|
||||
<up-cell title="设备配置" is-link @click="goConfig" />
|
||||
<up-cell title="分享的设备" is-link />
|
||||
<up-cell title="场所管理" is-link />
|
||||
<up-cell title="接警人管理" is-link />
|
||||
<up-cell title="手机号" :value="phoneText" is-link />
|
||||
<up-cell title="语音广播" is-link />
|
||||
<up-cell title="当前版本" value="2.8.44" />
|
||||
<up-cell title="退出登录" @click="logout" />
|
||||
</up-cell-group>
|
||||
<view class="menu-card">
|
||||
<view class="item" @click="goConfig">
|
||||
<text class="left">设备配置</text>
|
||||
<text class="right">›</text>
|
||||
</view>
|
||||
<view class="item">
|
||||
<text class="left">分享的设备</text>
|
||||
<text class="right">›</text>
|
||||
</view>
|
||||
<view class="item">
|
||||
<text class="left">场所管理</text>
|
||||
<text class="right">›</text>
|
||||
</view>
|
||||
<view class="item">
|
||||
<text class="left">接警人管理</text>
|
||||
<text class="right">›</text>
|
||||
</view>
|
||||
<view class="item">
|
||||
<text class="left">手机号</text>
|
||||
<text class="mid">{{ phoneText }}</text>
|
||||
<text class="right">›</text>
|
||||
</view>
|
||||
<view class="item">
|
||||
<text class="left">语音广播</text>
|
||||
<text class="right">›</text>
|
||||
</view>
|
||||
<view class="item">
|
||||
<text class="left">当前版本</text>
|
||||
<text class="mid">2.8.44</text>
|
||||
</view>
|
||||
<view class="item" @click="logout">
|
||||
<text class="left">退出登录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
|
@ -44,20 +68,17 @@ export default {
|
|||
phoneText() {
|
||||
return this.userInfo?.mobile || '未绑定'
|
||||
},
|
||||
// 根据登录类型展示不同文案。
|
||||
loginTip() {
|
||||
return this.userInfo?.loginType === 'mobile' ? '手机号验证码登录' : '微信登录'
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
// 每次回到页面时刷新一次本地缓存用户信息。
|
||||
this.userInfo = getUser()
|
||||
},
|
||||
methods: {
|
||||
goConfig() {
|
||||
navigateTo(ROUTES.DEVICE_CONFIG)
|
||||
},
|
||||
// 已登录时不再重复跳转登录页。
|
||||
goLogin() {
|
||||
if (isLoggedIn()) {
|
||||
return
|
||||
|
|
@ -65,7 +86,6 @@ export default {
|
|||
|
||||
navigateTo(LOGIN_PAGE)
|
||||
},
|
||||
// 退出时清理本地登录态并回到首页。
|
||||
logout() {
|
||||
clearAuth()
|
||||
showToast('已退出登录')
|
||||
|
|
@ -75,7 +95,7 @@ export default {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
<style>
|
||||
@import '@/styles/common.css';
|
||||
|
||||
.page {
|
||||
|
|
@ -84,22 +104,25 @@ export default {
|
|||
|
||||
.user-card {
|
||||
margin: 24rpx;
|
||||
min-height: 180rpx;
|
||||
min-height: 200rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 28rpx;
|
||||
padding: 20rpx 34rpx;
|
||||
}
|
||||
|
||||
.user-main {
|
||||
flex: 1;
|
||||
margin-left: 20rpx;
|
||||
.avatar {
|
||||
width: 132rpx;
|
||||
height: 132rpx;
|
||||
border-radius: 66rpx;
|
||||
background: radial-gradient(circle at 35% 35%, #95d347 0 32%, #6ba83a 33% 100%);
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: block;
|
||||
font-size: 42rpx;
|
||||
font-size: 46rpx;
|
||||
color: #2a2d33;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
|
@ -108,6 +131,43 @@ export default {
|
|||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: #9ea1a6;
|
||||
font-size: 28rpx;
|
||||
font-size: 30rpx;
|
||||
}
|
||||
|
||||
.menu-card {
|
||||
margin: 24rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item {
|
||||
min-height: 94rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 30rpx;
|
||||
border-bottom: 1rpx solid #efefef;
|
||||
}
|
||||
|
||||
.item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.left {
|
||||
flex: 1;
|
||||
font-size: 48rpx;
|
||||
color: #2f3135;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mid {
|
||||
margin-right: 16rpx;
|
||||
color: #9ea1a6;
|
||||
font-size: 44rpx;
|
||||
}
|
||||
|
||||
.right {
|
||||
color: #9ea1a6;
|
||||
font-size: 54rpx;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
/* 页面通用背景基线:统一浅灰底色,减少各页面重复定义。 */
|
||||
.page-base {
|
||||
min-height: 100vh;
|
||||
background: #f1f1f4;
|
||||
}
|
||||
|
||||
/* 顶部导航通用布局:居中标题 + 左右扩展占位。 */
|
||||
.top-nav-base {
|
||||
height: 120rpx;
|
||||
padding: 24rpx 24rpx 0;
|
||||
|
|
@ -21,7 +19,6 @@
|
|||
color: #222222;
|
||||
}
|
||||
|
||||
/* 空状态通用样式,供首页/事件页等无数据场景复用。 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -43,7 +40,6 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 卡片外观基类。 */
|
||||
.card-base {
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
|
|
|
|||
|
|
@ -1,19 +1,13 @@
|
|||
// uni-app Promise 适配:把 [err, data] 风格的返回值统一为 resolve/reject。
|
||||
uni.addInterceptor({
|
||||
returnValue (res) {
|
||||
if (!(!!res && (typeof res === 'object' || typeof res === 'function') && typeof res.then === 'function')) {
|
||||
return res
|
||||
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
|
||||
return res;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
res.then((value) => {
|
||||
if (!value) {
|
||||
return resolve(value)
|
||||
}
|
||||
|
||||
// uni API Promise 通常返回 [error, result]。
|
||||
return value[0] ? reject(value[0]) : resolve(value[1])
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
res.then((res) => {
|
||||
if (!res) return resolve(res)
|
||||
return res[0] ? reject(res[0]) : resolve(res[1])
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
2
uni.scss
2
uni.scss
|
|
@ -6,8 +6,6 @@
|
|||
*
|
||||
*/
|
||||
|
||||
@import 'uview-plus/theme.scss';
|
||||
|
||||
/**
|
||||
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// 统一复用 api/user.js 的用户相关接口,供页面层按 utils 路径导入。
|
||||
export {
|
||||
getUserProfile,
|
||||
loginByMobile,
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
# BLE 模块说明
|
||||
|
||||
- 核心入口: `utils/ble/index.js`
|
||||
- 推荐使用: `createUnifiedBleController`
|
||||
- 本模块目标: 在不改变业务语义的前提下,抽离连接/鉴权/WiFi/雷达/解析公共能力,兼容 ED713 与 ED719。
|
||||
|
|
@ -1,669 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
||||
/** 打开蓝牙适配器,成功后标记可用并进入 ADAPTER_OPENED 阶段。 */
|
||||
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);
|
||||
}
|
||||
|
||||
// 重置原生 handler 引用,以便 openAdapter 后 init() 可重新注册
|
||||
adapterStateHandler = null;
|
||||
connectionStateHandler = null;
|
||||
characteristicValueHandler = null;
|
||||
deviceFoundHandler = null;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册适配器状态变更监听,蓝牙关闭时自动切换到 DISCONNECTED。 */
|
||||
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 });
|
||||
}
|
||||
|
||||
/** 注册设备发现监听,按 deviceId 去重合并后广播 device:found。 */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立BLE连接。
|
||||
* @param {string} deviceId 设备ID
|
||||
* @param {object} options
|
||||
* @param {number} options.timeout 连接超时(ms),默认15000
|
||||
* @param {boolean} options.autoReconnect 是否自动重试,默认跟随初始化配置
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 断开BLE连接并重置连接相关状态。 */
|
||||
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);
|
||||
}
|
||||
|
||||
/** 注册连接状态变更监听,断开时自动切换到 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;
|
||||
}
|
||||
|
||||
/** 获取候选服务UUID列表:profile主服务 + 兼容固件的 00F4/F400/FFF4。 */
|
||||
function getServiceCandidates() {
|
||||
const profileService = currentProfile?.uuids?.service;
|
||||
const candidates = [
|
||||
profileService,
|
||||
toFullUuid('00F4'),
|
||||
toFullUuid('F400'),
|
||||
toFullUuid('FFF4'),
|
||||
];
|
||||
|
||||
return candidates.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发现并锁定目标服务。
|
||||
* 文档主服务为 0x00F4,同时兼容部分固件 0xF400 / 0xFFF4。
|
||||
*/
|
||||
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 candidates = getServiceCandidates();
|
||||
const targetService = services.find((service) =>
|
||||
candidates.some((uuid) => uuidEquals(service.uuid, uuid)),
|
||||
);
|
||||
|
||||
if (!targetService) {
|
||||
throw createBleError(10004, '设备未暴露目标服务(00F4/F400/FFF4)');
|
||||
}
|
||||
|
||||
patchState({ serviceId: targetService.uuid });
|
||||
setStage(BLE_STAGE.SERVICE_DISCOVERED);
|
||||
return {
|
||||
services,
|
||||
targetService,
|
||||
};
|
||||
} catch (error) {
|
||||
throw emitError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发现特征值并按业务用途缓存(wifi/key/cmd/msg/state/radar)。
|
||||
* @param {object} options 可选覆盖 deviceId/serviceId
|
||||
* @returns {object} characteristicMap 各业务特征 + all 全量列表
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取指定特征值(触发 onBLECharacteristicValueChange 回调)。 */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 向指定特征值写入数据,自动将输入转为 ArrayBuffer。 */
|
||||
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 参数缺失'));
|
||||
}
|
||||
|
||||
// 取特征 properties,用于诊断写入类型是否匹配,并按需指定 writeType
|
||||
const chars = state.characteristics || {};
|
||||
const matched = Object.values(chars).find(
|
||||
(item) => item && uuidEquals(item.uuid, characteristicId),
|
||||
);
|
||||
const props = matched?.properties || {};
|
||||
const supportsWrite = Boolean(props.write);
|
||||
const supportsWriteNoResp = Boolean(props.writeWithoutResponse);
|
||||
// 默认优先有响应写入(可靠);仅当不支持有响应时退化为无响应写入
|
||||
const writeType =
|
||||
options.writeType ||
|
||||
(supportsWrite
|
||||
? 'write'
|
||||
: supportsWriteNoResp
|
||||
? 'writeWithoutResponse'
|
||||
: undefined);
|
||||
|
||||
try {
|
||||
console.debug('[BLE][WRITE]', characteristicId, {
|
||||
writeType,
|
||||
supportsWrite,
|
||||
supportsWriteNoResp,
|
||||
valueLen: value?.length
|
||||
});
|
||||
const params = {
|
||||
deviceId,
|
||||
serviceId,
|
||||
characteristicId,
|
||||
value: uint8ArrayToArrayBuffer(toUint8Array(value)),
|
||||
};
|
||||
if (writeType) {
|
||||
params.writeType = writeType;
|
||||
}
|
||||
await promisifyUniApi('writeBLECharacteristicValue', params);
|
||||
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) => {
|
||||
const value = toUint8Array(result.value);
|
||||
// 原始 trace:定位 notify 是否投递时,确认每个特征值变更的来源与内容
|
||||
try {
|
||||
const hex = Array.from(value)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join(' ');
|
||||
console.debug('[BLE][RAW]', result.characteristicId, 'len=', value.length, 'hex=', hex);
|
||||
} catch (e) {
|
||||
}
|
||||
eventBus.emit('characteristic:value', {
|
||||
...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);
|
||||
}
|
||||
|
||||
/** 从已发现设备中,按广播服务UUID匹配并返回第一个设备。 */
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
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';
|
||||
|
||||
/**
|
||||
* 订阅 F301 前先读一次 UUID_MSG(F501),满足厂家强制时序。
|
||||
* 先注册 protocol:msg 监听再触发读取,避免回调早于监听丢失数据。
|
||||
*/
|
||||
async function readMsgOnce(ble, chars, timeout = 4000) {
|
||||
if (!chars || !chars.msg || !chars.msg.uuid) {
|
||||
console.debug('[BLE][MSG]', 'readMsgOnce skipped: no MSG characteristic');
|
||||
return null;
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
let off = null;
|
||||
try {
|
||||
const payload = await new Promise((resolve, reject) => {
|
||||
const finish = (cb) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (off) off();
|
||||
cb();
|
||||
};
|
||||
off = ble.on('protocol:msg', (data) => finish(() => resolve(data)));
|
||||
timer = setTimeout(
|
||||
() => finish(() => reject(new Error('读取 UUID_MSG 超时'))),
|
||||
timeout
|
||||
);
|
||||
ble.readCharacteristic(chars.msg.uuid).catch((e) => finish(() => reject(e)));
|
||||
});
|
||||
const info = parseDeviceInfo(payload.value, ble.getProfile().id);
|
||||
console.debug('[BLE][MSG]', 'readMsgOnce ok', JSON.stringify(info));
|
||||
return info;
|
||||
} catch (e) {
|
||||
// 读 MSG 失败不阻断后续(仅作时序前置),但记录日志便于排查
|
||||
console.debug('[BLE][MSG]', 'readMsgOnce failed', e?.message || e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建特征值通知分发器。
|
||||
* 根据特征UUID将通知分发到对应协议事件:state / msg / radar / raw。
|
||||
* 雷达帧还能自动推断设备 profile(ED713/ED719)。
|
||||
*/
|
||||
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, profile.id),
|
||||
});
|
||||
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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一BLE控制器:整合核心能力 + 认证/WiFi/雷达三大业务模块。
|
||||
* 页面层应通过此控制器编排完整业务流程。
|
||||
* @param {object} options 透传给 createBleCore 的配置(重试策略/日志/profileId等)
|
||||
*/
|
||||
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;
|
||||
|
||||
/** 绑定特征值分发器到 ble 事件总线。 */
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描并选取目标设备。
|
||||
* 先按服务UUID过滤扫描,无结果时可退化为无过滤扫描。
|
||||
* @param {object} params
|
||||
* @param {string} params.serviceUuid 目标服务UUID,默认取 profile 配置
|
||||
* @param {number} params.scanDuration 扫描时长(ms),默认5000
|
||||
* @param {boolean} params.fallbackWithoutFilter 首次无结果时是否退化为无过滤扫描,默认true
|
||||
* @returns {object|null} 匹配到的设备对象,未找到返回 null
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接后严格执行协议前置(厂家要求顺序):
|
||||
* 1) 发现服务和特征
|
||||
* 2) 先读取 UUID_MSG(F501) —— 必须在订阅 F301 之前
|
||||
* 3) 订阅 UUID_STATE(F301) / UUID_RADAR(F302)
|
||||
*
|
||||
* 厂家说明:订阅 F301 前必须先读 F501,否则后续设备不回 STATE 通知,
|
||||
* 表现为写 KEY/WIFI 后 F301 零回执、等待超时。
|
||||
*/
|
||||
async function connectAndDiscover(deviceId) {
|
||||
await ble.connect(deviceId);
|
||||
await ble.discoverServices(deviceId);
|
||||
const chars = await ble.discoverCharacteristics();
|
||||
|
||||
// 先读 UUID_MSG(F501),再订阅 F301 —— 厂家强制顺序
|
||||
await readMsgOnce(ble, chars);
|
||||
|
||||
if (chars.state && chars.state.uuid) {
|
||||
// 打印 STATE 特征属性,区分 notify / indicate,定位通知不投递问题
|
||||
console.debug('[BLE][SUB]', 'STATE props', JSON.stringify(chars.state.properties || {}));
|
||||
// STATE 是鉴权/WiFi 的唯一回执通道,订阅失败必须显式抛出,
|
||||
// 否则后续写 KEY 后设备零回执,表现为"等待 UUID_STATE 超时"难以定位。
|
||||
try {
|
||||
await ble.notifyCharacteristic(chars.state.uuid, true);
|
||||
console.debug('[BLE][SUB]', 'notify STATE ok', chars.state.uuid);
|
||||
} catch (e) {
|
||||
throw new Error(`订阅 UUID_STATE(F301) 失败:${e?.message || e}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error('未发现 UUID_STATE(F301) 特征值,无法接收设备回执');
|
||||
}
|
||||
|
||||
if (chars.radar && chars.radar.uuid) {
|
||||
try {
|
||||
await ble.notifyCharacteristic(chars.radar.uuid, true);
|
||||
console.debug('[BLE][SUB]', 'notify RADAR ok', chars.radar.uuid);
|
||||
} catch (e) {
|
||||
console.debug('[BLE][SUB]', 'notify RADAR fail', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 释放所有资源:解绑分发器 + 清理核心BLE(停扫描/断连接/移监听/关适配器)。 */
|
||||
async function cleanup() {
|
||||
unbindDispatcher();
|
||||
await ble.cleanup();
|
||||
}
|
||||
|
||||
return {
|
||||
ble,
|
||||
auth,
|
||||
wifi,
|
||||
radar,
|
||||
init,
|
||||
scanAndPickDevice,
|
||||
connectAndDiscover,
|
||||
standardConnectFlow,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
// 微信 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;
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
/**
|
||||
* 轻量事件总线:用于 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);
|
||||
}
|
||||
|
||||
/** 广播事件,所有监听器同步执行(内部已 try-catch 隔离)。 */
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 payload 按 A7 规则分包后顺序写入目标特征。
|
||||
* 统一 auth/wifi 分包发送逻辑,避免多处重复实现。
|
||||
*/
|
||||
export async function writeA7Payload(ble, characteristicId, payload, packetConfig = {}, options = {}) {
|
||||
if (!ble || typeof ble.writeCharacteristic !== 'function') {
|
||||
throw new Error('writeA7Payload 缺少有效的 ble 实例')
|
||||
}
|
||||
|
||||
if (!characteristicId) {
|
||||
throw new Error('writeA7Payload 缺少 characteristicId')
|
||||
}
|
||||
|
||||
const { packetInterval = 0 } = options
|
||||
const packets = buildA7Packets(payload, packetConfig)
|
||||
|
||||
for (const packet of packets) {
|
||||
await ble.writeCharacteristic(characteristicId, packet)
|
||||
|
||||
if (packetInterval > 0) {
|
||||
// 部分固件在连续写入时需要极短间隔,默认 0 兼容原行为。
|
||||
await new Promise((resolve) => setTimeout(resolve, packetInterval))
|
||||
}
|
||||
}
|
||||
|
||||
return packets.length
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个 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
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
// 简单 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
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
/**
|
||||
* 统一等待 UUID_STATE 通知。
|
||||
*
|
||||
* @param {object} ble createBleCore 实例
|
||||
* @param {object} options 等待配置
|
||||
* @param {number[]} options.allowedCodes 允许通过的状态码;为空则接收任意状态码
|
||||
* @param {boolean} options.rejectOnUnexpected 收到非 allowedCodes 状态码时是否立刻失败
|
||||
* @param {number} options.timeout 超时时间(ms)
|
||||
* @param {string} options.timeoutMessage 超时提示
|
||||
* @param {string} options.unexpectedMessage 非预期状态提示前缀
|
||||
* @param {number[]} options.refreshOnCodes 收到这些中间态状态码时重置超时计时器
|
||||
* @param {number} options.refreshTimeout 重置后的窗口时长(ms),未传则回退到 timeout
|
||||
*
|
||||
* 中间态刷新机制:设备连接 WiFi 时会先发 STATE=4(连接中) 再发最终态,
|
||||
* 若总超时短于真实 DHCP 时长会误判超时。收到 refreshOnCodes 内的中间态时
|
||||
* 重置倒计时,最多延长至 refreshTimeout 指定的单次窗口时长。
|
||||
*/
|
||||
export function waitForProtocolState(ble, options = {}) {
|
||||
const {
|
||||
allowedCodes = [],
|
||||
rejectOnUnexpected = false,
|
||||
timeout = 6000,
|
||||
timeoutMessage = '等待 UUID_STATE 超时',
|
||||
unexpectedMessage = '收到非预期 UUID_STATE 状态码',
|
||||
refreshOnCodes = [],
|
||||
refreshTimeout = timeout
|
||||
} = options
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer = null
|
||||
let off = null
|
||||
try {
|
||||
console.debug('[BLE][STATEWAIT]', 'start', {
|
||||
allowed: allowedCodes.join(',') || 'ANY',
|
||||
timeout,
|
||||
refreshOn: refreshOnCodes.join(',') || 'NONE'
|
||||
})
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
const finish = (callback) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (off) {
|
||||
off()
|
||||
}
|
||||
|
||||
callback()
|
||||
}
|
||||
|
||||
const armTimer = (duration) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
try {
|
||||
console.debug('[BLE][STATEWAIT]', 'timeout')
|
||||
} catch (e) {
|
||||
}
|
||||
finish(() => reject(new Error(timeoutMessage)))
|
||||
}, duration)
|
||||
}
|
||||
|
||||
armTimer(timeout)
|
||||
|
||||
off = ble.on('protocol:state', (payload) => {
|
||||
const stateCode = Number(payload?.stateCode)
|
||||
|
||||
if (!allowedCodes.length || allowedCodes.includes(stateCode)) {
|
||||
try {
|
||||
console.debug('[BLE][STATEWAIT]', 'accept', stateCode)
|
||||
} catch (e) {
|
||||
}
|
||||
finish(() => resolve(payload))
|
||||
return
|
||||
}
|
||||
|
||||
// 命中中间态刷新:重置超时窗口,继续等待后续最终态
|
||||
if (refreshOnCodes.length && refreshOnCodes.includes(stateCode)) {
|
||||
try {
|
||||
console.debug('[BLE][STATEWAIT]', 'refresh', stateCode)
|
||||
} catch (e) {
|
||||
}
|
||||
armTimer(refreshTimeout)
|
||||
return
|
||||
}
|
||||
|
||||
if (rejectOnUnexpected) {
|
||||
try {
|
||||
console.debug('[BLE][STATEWAIT]', 'reject', stateCode)
|
||||
} catch (e) {
|
||||
}
|
||||
finish(() => reject(new Error(`${unexpectedMessage}: ${stateCode}`)))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
/**
|
||||
* 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, writeA7Payload } from './core/packet'
|
||||
export { waitForProtocolState } from './core/stateWait'
|
||||
|
||||
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'
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
import { writeA7Payload } from '../core/packet';
|
||||
import { waitForProtocolState } from '../core/stateWait';
|
||||
import { parseDeviceInfo } from '../parsers';
|
||||
import { BLE_STAGE } from '../core/state';
|
||||
|
||||
/** 生成指定长度的随机密钥(排除易混淆字符 0/O/1/I/l)。 */
|
||||
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;
|
||||
}
|
||||
|
||||
const authLog = (...args) => {
|
||||
try {
|
||||
console.debug('[BLE][AUTH]', ...args);
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
/** 将 MAC 地址去除非十六进制字符并转大写,用于密钥拼接。 */
|
||||
function normalizeMacForKey(macText = '') {
|
||||
return String(macText || '')
|
||||
.replace(/[^0-9a-fA-F]/g, '')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按协议约定生成设备密钥:MAC(去分隔符) + 固定后缀。
|
||||
* 默认后缀为 "0000",对应 12 位 MAC + 4 位后缀 = 16 位密钥。
|
||||
*/
|
||||
export function buildKeyFromMac(macText, suffix = '0000', expectedLength = 16) {
|
||||
const normalizedMac = normalizeMacForKey(macText);
|
||||
const normalizedSuffix = String(suffix || '').trim();
|
||||
|
||||
if (!normalizedSuffix) {
|
||||
throw new Error('密钥后缀不能为空');
|
||||
}
|
||||
|
||||
const requiredMacLength = expectedLength - normalizedSuffix.length;
|
||||
if (requiredMacLength <= 0) {
|
||||
throw new Error('密钥长度配置异常');
|
||||
}
|
||||
|
||||
// if (normalizedMac.length !== requiredMacLength) {
|
||||
// throw new Error(`设备 MAC 长度异常,期望 ${requiredMacLength} 位,实际 ${normalizedMac.length} 位`)
|
||||
// }
|
||||
|
||||
return `${normalizedMac}${normalizedSuffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将密钥通过A7分包写入 UUID_KEY 特征值。
|
||||
* @param {object} ble createBleCore 实例
|
||||
* @param {string} keyText 密钥文本
|
||||
* @returns {string} 实际写入的密钥值
|
||||
*/
|
||||
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}`)
|
||||
// }
|
||||
|
||||
await writeA7Payload(ble, key.uuid, keyValue, profile.packet);
|
||||
|
||||
return keyValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入密钥并等待 UUID_STATE 返回预期状态码。
|
||||
* 成功后自动设置 AUTHORIZED 阶段和 keyMatched 状态。
|
||||
* @param {object} ble createBleCore 实例
|
||||
* @param {string} keyText 密钥文本
|
||||
* @param {number[]} expectedStateCodes 期望的状态码列表
|
||||
* @param {object} options 超时等配置
|
||||
*/
|
||||
async function writeKeyAndExpectStates(
|
||||
ble,
|
||||
keyText,
|
||||
expectedStateCodes,
|
||||
options = {},
|
||||
) {
|
||||
// 设备订阅 STATE 后可能先发送 -1(未写密钥)/0(空闲) 等中间态,
|
||||
// 若 rejectOnUnexpected:true 会导致中间态直接 reject,阻断鉴权流程。
|
||||
const waitPromise = waitForProtocolState(ble, {
|
||||
allowedCodes: expectedStateCodes,
|
||||
rejectOnUnexpected: false,
|
||||
timeout: options.timeout || 6000,
|
||||
timeoutMessage: '等待 UUID_STATE 超时',
|
||||
unexpectedMessage: '密钥流程状态异常',
|
||||
});
|
||||
authLog('STATE wait before writeKey, expect=', expectedStateCodes);
|
||||
const keyValue = await writePacketsToKeyCharacteristic(ble, keyText);
|
||||
authLog('key written, length=', keyValue);
|
||||
try {
|
||||
const stateCode = Number((await waitPromise).stateCode);
|
||||
authLog('STATE received for KEY=', stateCode);
|
||||
ble.setStage(BLE_STAGE.AUTHORIZED);
|
||||
ble.setAuthState({ keyMatched: true, lastStateCode: stateCode });
|
||||
return { success: true, stateCode, key: keyValue };
|
||||
} catch (error) {
|
||||
// 鉴权超时多为黑盒:密钥不匹配(STATE=8)、F301 订阅失败、或写入丢失均表现为超时。
|
||||
// 这里带上绑定状态与设备最后上报的 STATE,区分根因方向。
|
||||
const authState = ble.getState().auth || {};
|
||||
const lastState = authState.lastStateCode;
|
||||
const bound = authState.bound;
|
||||
const expectedStr = expectedStateCodes.join('/');
|
||||
const boundHint = (bound === null || bound === undefined)
|
||||
? '绑定状态未知'
|
||||
: `绑定状态=${bound}`;
|
||||
const lastHint = (lastState === null || lastState === undefined)
|
||||
? '设备未上报任何 STATE(可能 F301 订阅失败或密钥写入丢失)'
|
||||
: `设备最后上报 STATE=${lastState}(期望 ${expectedStr},可能密钥不匹配)`;
|
||||
throw new Error(`密钥鉴权超时(${boundHint},${lastHint})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据设备绑定状态执行鉴权流程:
|
||||
* - bindStatus=0(未绑定):生成随机密钥写入,等待 STATE=5
|
||||
* - bindStatus=1(已绑定):使用已知密钥匹配,等待 STATE=7
|
||||
* - 其他状态:尝试用提供的密钥匹配,等待 STATE=7 或 5
|
||||
*/
|
||||
async function ensureAuthorizedWithDeviceInfo(
|
||||
ble,
|
||||
info,
|
||||
keyText,
|
||||
options = {},
|
||||
) {
|
||||
const bindStatus = info.bindStatus !== null ? Number(info.bindStatus) : null;
|
||||
const profile = ble.getProfile();
|
||||
|
||||
ble.setAuthState({
|
||||
bound: Number.isNaN(bindStatus) ? null : bindStatus,
|
||||
keyMatched: false,
|
||||
});
|
||||
|
||||
if (bindStatus === 0) {
|
||||
const keyToBind =
|
||||
String(keyText || '').trim() || generateRandomKey(profile.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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证模块工厂:封装设备绑定/密钥匹配全流程。
|
||||
* @param {object} ble createBleCore 实例
|
||||
*/
|
||||
export function createAuthModule(ble) {
|
||||
/**
|
||||
* 读取 UUID_MSG 获取设备信息(MAC/绑定状态/产品ID等)。
|
||||
* 先订阅 protocol:msg 事件,再触发 read,保证不丢通知。
|
||||
*/
|
||||
async function readDeviceInfo(options = {}) {
|
||||
const { msg } = ble.getState().characteristics;
|
||||
|
||||
if (!msg || !msg.uuid) {
|
||||
throw new Error('未发现 UUID_MSG 特征值');
|
||||
}
|
||||
|
||||
const timeout = Number(options.msgTimeout) || 4000;
|
||||
let timer = null;
|
||||
let off = null;
|
||||
|
||||
const valuePromise = new Promise((resolve, reject) => {
|
||||
const finish = (callback) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (off) off();
|
||||
callback();
|
||||
};
|
||||
|
||||
off = ble.on('protocol:msg', (payload) => finish(() => resolve(payload)));
|
||||
timer = setTimeout(
|
||||
() => finish(() => reject(new Error('读取 UUID_MSG 超时'))),
|
||||
timeout,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await ble.readCharacteristic(msg.uuid);
|
||||
const payload = await valuePromise;
|
||||
const info = parseDeviceInfo(payload.value, ble.getProfile().id);
|
||||
// 打印 MSG 原始字节与解析结果,验证 bindStatus/MAC 是否解析正确
|
||||
try {
|
||||
const rawBytes = Array.from(payload.value || []);
|
||||
const hex = rawBytes
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join(' ');
|
||||
console.debug('[BLE][AUTH]', 'MSG raw hex=', hex, 'parsed=', JSON.stringify(info));
|
||||
} catch (e) {
|
||||
}
|
||||
return info;
|
||||
} catch (error) {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (off) off();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格遵循协议1.2绑定流程:
|
||||
* 1) 读取 UUID_MSG 的 bindStatus;
|
||||
* 2) bindStatus=0 写随机/指定密钥并等待 STATE=5;
|
||||
* 3) bindStatus=1 写已知密钥并等待 STATE=7。
|
||||
*/
|
||||
async function ensureAuthorized(keyText, options = {}) {
|
||||
ble.setStage(BLE_STAGE.AUTHORIZING);
|
||||
const info = await readDeviceInfo(options);
|
||||
return ensureAuthorizedWithDeviceInfo(ble, info, keyText, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定规则鉴权:密钥 = 设备MAC(去分隔符) + “0000”。
|
||||
* 用于”先鉴权再进行 WiFi 配置”的页面流程。
|
||||
*/
|
||||
async function ensureAuthorizedByMac(options = {}) {
|
||||
ble.setStage(BLE_STAGE.AUTHORIZING);
|
||||
|
||||
const info = await readDeviceInfo(options);
|
||||
const profile = ble.getProfile();
|
||||
const keyByMac = buildKeyFromMac(
|
||||
info.mac,
|
||||
options.suffix || '0000',
|
||||
profile.auth.keyLength,
|
||||
);
|
||||
|
||||
const authResult = await ensureAuthorizedWithDeviceInfo(
|
||||
ble,
|
||||
info,
|
||||
keyByMac,
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
...authResult,
|
||||
key: keyByMac,
|
||||
keyRule: 'MAC+0000',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
readDeviceInfo,
|
||||
ensureAuthorized,
|
||||
ensureAuthorizedByMac,
|
||||
buildKeyFromMac,
|
||||
generateRandomKey,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import { parseRadarData } from '../parsers';
|
||||
|
||||
/**
|
||||
* 雷达控制模块:管理雷达启停、窄床模式、跌倒参数写入。
|
||||
* 所有命令发送前均校验鉴权状态。
|
||||
* @param {object} ble createBleCore 实例
|
||||
*/
|
||||
export function createRadarModule(ble) {
|
||||
function requireAuthorized() {
|
||||
const state = ble.getState();
|
||||
|
||||
// 协议要求:获取雷达校准数据前必须先完成密钥匹配。
|
||||
if (!state.auth || state.auth.keyMatched !== true) {
|
||||
throw new Error('请先完成密钥匹配,再执行雷达命令');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向 UUID_CMD 写入单字节指令。
|
||||
* @param {number} commandByte 指令字节(如 0xA1 启动、0xA2 停止)
|
||||
*/
|
||||
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]));
|
||||
}
|
||||
|
||||
/** 启动雷达数据流(写入 CMD = 0xA1)。 */
|
||||
async function startRadarStream() {
|
||||
const profile = ble.getProfile();
|
||||
|
||||
// 协议1.2.2:先密钥匹配,再向 UUID_CMD 写 0xA1。
|
||||
requireAuthorized();
|
||||
await sendCommand(profile.radar.startCommand);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 停止雷达数据流(写入 CMD = 0xA2)。 */
|
||||
async function stopRadarStream() {
|
||||
const profile = ble.getProfile();
|
||||
|
||||
// 停止同样要求处于鉴权通过态,避免误发到未鉴权连接。
|
||||
requireAuthorized();
|
||||
await sendCommand(profile.radar.stopCommand);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置窄床模式(仅 ED713 支持)。
|
||||
* 协议:CMD=0x7C,参数1byte(0=关, 1=开),设置后雷达重启。
|
||||
* @param {boolean} enabled true=开启,false=关闭
|
||||
*/
|
||||
async function setNarrowMode(enabled) {
|
||||
const profile = ble.getProfile();
|
||||
|
||||
if (!profile.cmd.supportsNarrowMode) {
|
||||
throw new Error('当前设备不支持窄床模式设置');
|
||||
}
|
||||
|
||||
requireAuthorized();
|
||||
|
||||
// 协议规定:写入 [0x7C, flag] 两字节
|
||||
const { cmd } = ble.getState().characteristics;
|
||||
if (!cmd || !cmd.uuid) {
|
||||
throw new Error('未发现 UUID_CMD 特征值');
|
||||
}
|
||||
await ble.writeCharacteristic(
|
||||
cmd.uuid,
|
||||
new Uint8Array([0x7c, enabled ? 1 : 0]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入跌倒参数(仅 ED719 支持,指令前缀 0x67)。
|
||||
* @param {Uint8Array|number[]} rawParams 跌倒参数字节
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/** 按当前 profile 解析雷达通知数据。 */
|
||||
function parseNotifyPayload(payload) {
|
||||
const profile = ble.getProfile();
|
||||
return parseRadarData(payload, profile.id);
|
||||
}
|
||||
|
||||
return {
|
||||
sendCommand,
|
||||
startRadarStream,
|
||||
stopRadarStream,
|
||||
setNarrowMode,
|
||||
writeFallParams,
|
||||
parseNotifyPayload,
|
||||
requireAuthorized,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
import { writeA7Payload } from '../core/packet'
|
||||
import { waitForProtocolState } from '../core/stateWait'
|
||||
|
||||
const WIFI_FINAL_STATE_CODES = Object.freeze([1, 2, 3])
|
||||
|
||||
// STATE=4 为 WiFi 连接中中间态,收到后用于刷新等待窗口
|
||||
const WIFI_CONNECTING_STATE_CODES = Object.freeze([4])
|
||||
|
||||
// 总等待上限:覆盖弱信号/5G/企业级路由真实 DHCP 时长
|
||||
const WIFI_TOTAL_TIMEOUT_MS = 60000
|
||||
// 收到 STATE=4 后的单次刷新窗口
|
||||
const WIFI_REFRESH_TIMEOUT_MS = 30000
|
||||
// ED713 分包写入间隔:连续 writeWithoutResponse 易丢包,固件需留处理时间
|
||||
const ED713_PACKET_INTERVAL_MS = 50
|
||||
|
||||
/**
|
||||
* 校验 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 requireAuthorized(ble) {
|
||||
const state = ble.getState()
|
||||
|
||||
if (!state.auth || state.auth.keyMatched !== true) {
|
||||
console.debug('[BLE][WiFi]', '密钥未匹配(密钥流程已废弃,继续配网)')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WiFi 配网模块:负责打包发送 ssid|password,并等待 STATE 回执。
|
||||
* 使用前必须先完成密钥鉴权。
|
||||
* @param {object} ble createBleCore 实例
|
||||
*/
|
||||
export function createWifiModule(ble) {
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, Number(ms) || 0))
|
||||
}
|
||||
const log = (...args) => { try { console.debug('[BLE][WiFi]', ...args) } catch (e) {} }
|
||||
|
||||
/**
|
||||
* 发送WiFi配网指令。
|
||||
* 将 ssid|password 按 A7 分包写入 UUID_WIFI,等待 STATE 回执判定结果。
|
||||
* @param {string} ssid WiFi名称
|
||||
* @param {string} password WiFi密码
|
||||
* @param {object} options
|
||||
* @param {number} options.timeout 等待状态回执总超时(ms),默认60000
|
||||
* @param {number} options.preDelayMs 写入前延迟(ms),ED713 默认120ms
|
||||
* @param {number} options.packetInterval 分包写入间隔(ms)
|
||||
* @returns {{ success: boolean, stateCode: number }} stateCode=3 表示连接成功
|
||||
*/
|
||||
async function configureWifi(ssid, password, options = {}) {
|
||||
const profile = ble.getProfile()
|
||||
const s = String(ssid || '').trim()
|
||||
const p = String(password || '').trim()
|
||||
validateWifi(profile, s, p)
|
||||
requireAuthorized(ble)
|
||||
|
||||
const { wifi } = ble.getState().characteristics
|
||||
if (!wifi || !wifi.uuid) {
|
||||
throw new Error('未发现 UUID_WIFI 特征值')
|
||||
}
|
||||
|
||||
const totalTimeout = options.timeout || WIFI_TOTAL_TIMEOUT_MS
|
||||
// 先注册 STATE 等待,再写入,确保不漏回执
|
||||
// 收到 STATE=4(连接中) 时刷新窗口,避免固定上限短于真实 DHCP 时长误判超时
|
||||
const waitPromise = waitForProtocolState(ble, {
|
||||
allowedCodes: WIFI_FINAL_STATE_CODES,
|
||||
rejectOnUnexpected: false,
|
||||
timeout: totalTimeout,
|
||||
timeoutMessage: '等待 WiFi 状态超时',
|
||||
refreshOnCodes: WIFI_CONNECTING_STATE_CODES,
|
||||
refreshTimeout: WIFI_REFRESH_TIMEOUT_MS
|
||||
})
|
||||
|
||||
// ED713 固件需要写入前延迟,否则可能丢包
|
||||
const pre = options.preDelayMs ?? (profile.id === 'ED713' ? 120 : 0)
|
||||
if (pre) {
|
||||
log('preDelay before WiFi write(ms)=', pre)
|
||||
await delay(pre)
|
||||
}
|
||||
|
||||
log('write WiFi start', { service: ble.getState().serviceId, char: wifi.uuid })
|
||||
const packetInterval = Number(options.packetInterval) || (profile.id === 'ED713' ? ED713_PACKET_INTERVAL_MS : 0)
|
||||
const sent = await writeA7Payload(ble, wifi.uuid, `${s}|${p}`, profile.packet, {
|
||||
packetInterval
|
||||
})
|
||||
log('write WiFi done, packets=', sent, 'interval=', packetInterval)
|
||||
|
||||
let code
|
||||
try {
|
||||
code = Number((await waitPromise).stateCode)
|
||||
} catch (error) {
|
||||
// 超时时带上设备最后上报的 STATE 码,便于区分"设备未响应"与"设备回了非预期态"
|
||||
const lastState = ble.getState().auth?.lastStateCode
|
||||
const hint = (lastState === undefined || lastState === null)
|
||||
? '(设备未上报任何 STATE,可能 WiFi 分包写入丢失或固件未处理)'
|
||||
: `(设备最后上报 STATE=${lastState},未收到最终态 1/2/3)`
|
||||
throw new Error(`${error.message}${hint}`)
|
||||
}
|
||||
log('STATE(final) received for WiFi=', code)
|
||||
|
||||
// STATE=3 表示 WiFi 连接成功
|
||||
return { success: code === 3, stateCode: code }
|
||||
}
|
||||
|
||||
return { configureWifi, requireAuthorized }
|
||||
}
|
||||
|
|
@ -1,157 +0,0 @@
|
|||
import {bytesToHex, bytesToMac} from '../utils/hex'
|
||||
import {joinLowHigh, readUint16LE, toUint8Array} from '../utils/bytes'
|
||||
import {DEVICE_PROFILE} from '../protocols/profiles'
|
||||
|
||||
/**
|
||||
* 解析 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]
|
||||
}
|
||||
|
||||
/**
|
||||
* ED713 MSG 解析。
|
||||
* 文档布局:MAC(6) + bindStatus(1) + 其它(3) + productId_H(1) + productId_L(1)
|
||||
* + baseBoard(1) + coreBoard(1) + 其它(2) + deviceType(1)
|
||||
* + wifiStatus(1) + cellularStatus(1) + 其它(1)
|
||||
*/
|
||||
function parseEd713DeviceInfo(bytes) {
|
||||
if (!bytes.length) {
|
||||
return {
|
||||
raw: bytes, mac: '', bindStatus: null, productId: null,
|
||||
deviceType: null, wifiStatus: null, cellularStatus: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
raw: bytes,
|
||||
mac: bytesToMac(bytes.slice(0, 6)),
|
||||
bindStatus: bytes.length > 6 ? bytes[6] : null,
|
||||
productId: bytes.length > 11 ? joinLowHigh(bytes[11], bytes[10]) : null,
|
||||
baseBoardVersion: bytes.length > 12 ? bytes[12] : null,
|
||||
coreBoardVersion: bytes.length > 13 ? bytes[13] : null,
|
||||
deviceType: bytes.length > 16 ? bytes[16] : null,
|
||||
wifiStatus: bytes.length > 17 ? bytes[17] : null,
|
||||
cellularStatus: bytes.length > 18 ? bytes[18] : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ED719 MSG 解析。
|
||||
* 实际偏移与 ED713 一致(文档省略了 bindStatus/productId 但底板数据布局相同):
|
||||
* MAC(6) + bindStatus(1) + 其它(3) + productId_H(1) + productId_L(1)
|
||||
* + baseBoard(1) + coreBoard(1) + 其它(2) + deviceType(1)
|
||||
* + wifiStatus(1) + cellularStatus(1) + 其它(1)
|
||||
*/
|
||||
function parseEd719DeviceInfo(bytes) {
|
||||
return parseEd713DeviceInfo(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 UUID_MSG 设备信息(按 profile 区分 ED713/ED719)。
|
||||
*/
|
||||
export function parseDeviceInfo(payload, profileId = DEVICE_PROFILE.UNKNOWN) {
|
||||
const bytes = toUint8Array(payload)
|
||||
|
||||
if (!bytes.length) {
|
||||
return {
|
||||
raw: bytes, mac: '', bindStatus: null, productId: null,
|
||||
deviceType: null, wifiStatus: null, cellularStatus: null
|
||||
}
|
||||
}
|
||||
|
||||
if (profileId === DEVICE_PROFILE.ED713) return parseEd713DeviceInfo(bytes)
|
||||
if (profileId === DEVICE_PROFILE.ED719) return parseEd719DeviceInfo(bytes)
|
||||
|
||||
// UNKNOWN:按 ED713 布局兼容
|
||||
return parseEd713DeviceInfo(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 雷达数据解析。
|
||||
* 按文档结构体定义:
|
||||
* header[4] + event(1) + people(1) + num(1) + nodata(1)
|
||||
* + height(2) + mode(1) + beeper(1) + left(2) + right(2) + front(2) + back(2)
|
||||
* + sensitive(1) + nodata1(1) + stateDelay(2) = 24 bytes
|
||||
* shield_num 在 offset 24,屏蔽区在 offset 25
|
||||
* 屏蔽区之后:wallOption + peopleHaveSwitch + peopleActivitySwitch
|
||||
*/
|
||||
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[24] || 0
|
||||
const shieldStart = 25
|
||||
const shieldBlockLength = Math.min(Math.max(shieldNum, 0), 4) * 9
|
||||
|
||||
const wallOffset = shieldStart + shieldBlockLength
|
||||
|
||||
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[wallOffset] ?? null,
|
||||
peopleHaveSwitch: bytes[wallOffset + 1] ?? null,
|
||||
peopleActivitySwitch: bytes[wallOffset + 2] ?? null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* 解析器统一出口。
|
||||
* 对外暴露 parseStateCode / parseDeviceInfo / parseRadarData 三个顶层函数。
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,143 +0,0 @@
|
|||
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
|
||||
})
|
||||
|
||||
/**
|
||||
* 根据设备型号获取对应 BLE Profile 配置。
|
||||
* @param {string} profileId 设备型号ID,默认 UNKNOWN
|
||||
* @returns {object} profile 配置对象
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
/**
|
||||
* 把常见二进制输入统一转换成 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
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
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, ':')
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
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,6 +2,5 @@ import request from '@/api/http/client'
|
|||
|
||||
/**
|
||||
* 兼容层:保留旧导入路径 `@/utils/request`。
|
||||
* 新代码建议直接使用 `@/api/http/client`。
|
||||
*/
|
||||
export default request
|
||||
|
|
|
|||
Loading…
Reference in New Issue