Compare commits

...

10 Commits

Author SHA1 Message Date
ozh 82b287dd92 refactor(ble): BLE 模块重构与协议时序修复
- 从 git 追踪中移除 .idea、.claude/rules、CLAUDE.md、AGENTS.md
- 更新 .gitignore,添加 prettier 配置
- BLE 核心模块重构(bleCore/controller/auth/radar/wifi/errors/eventBus/stateWait)
- 修复协议时序:订阅 F301 前先读 F501(readMsgOnce)
- 设备配置页优化:鉴权失败改用模态弹窗,WiFi 错误持久展示
- 密钥流程已废弃,鉴权失败不阻断后续配网流程
- pages.json 格式化(tab → 空格)
2026-06-24 17:09:25 +08:00
ozh cc02eb5998 fix(ble): 修复首次搜索时 stopDiscovery 误报适配器未打开错误
stopDiscoverySilently 移入 initialized 判断块内,仅在适配器已初始化时才尝试停止扫描
2026-04-30 16:52:48 +08:00
ozh 2a6eeccbed fix(ble): 协议一致性修复、WiFi面板超时修复、搜索行为优化
- 修正 ED713/ED719 MSG 解析分路与雷达 shieldNum 偏移
- 雷达窄床模式命令格式改为两字节 [0x7C, flag]
- 鉴权流程 rejectOnUnexpected=false 避免中间态阻断
- readDeviceInfo 增加 msgTimeout 4秒超时保护
- config.vue prepareWifiConfig 超时修正(800000→8000)
- 搜索 30s 自动停止,暂停保留设备数据
- closeAdapter 重置 handler 引用,每次搜索前完整适配器重开
2026-04-30 11:21:41 +08:00
ozh 68b1485d52 style(ble): 格式化蓝牙模块代码并添加注释
- 统一代码风格,修复 import 语句格式
- 为关键函数添加 JSDoc 注释文档
- 添加 .editorconfig 文件统一代码格式
- 修复页面设备配置超时时间单位错误
- 优化错误处理和日志输出格式
2026-04-23 17:24:14 +08:00
ozh 099349258b feat(ble): 支持多种蓝牙服务UUID并优化调试日志
- 新增 FFF4 服务UUID兼容性支持,适配不同固件版本
- 重构服务发现逻辑,支持多候选UUID匹配
- 添加详细的蓝牙协议状态调试日志
- 优化WiFi配置模块的日志输出和预延时机制
- 更新设备发现过滤器以支持新UUID格式
- 添加AGENTS.md和CLAUDE.md项目指导文件
- 重构认证模块增加调试日志功能
2026-04-22 17:50:20 +08:00
ozh bcf2285b65 feat(device): 更新设备配置页面功能
- 添加EP832设备型号支持到蓝牙发现模块
- 重构设备匹配显示逻辑,改进UI布局结构
- 增强设备列表操作的错误处理机制
2026-04-21 16:43:57 +08:00
ozh aa0264ba19 feat(ble): 重构蓝牙认证模块并新增WiFi配置功能
- 新增buildKeyFromMac函数,按协议生成MAC+0000固定规则密钥
- 在设备列表页添加WiFi配置弹层界面
- 实现WiFi名称密码输入、当前WiFi获取、历史配置导入功能
- 添加prepareWifiConfig流程,专用于WiFi配置前的MAC鉴权
- 更新configureWifi方法,使用新的状态等待机制并验证授权状态
2026-04-20 15:11:00 +08:00
ozh 599a01cd73 feat(ui): 集成 uview-plus 组件库并重构界面 2026-04-20 11:08:55 +08:00
ozh fd617947f1 fix(ble): 成功搜索到ED719设备 2026-04-17 17:17:39 +08:00
ozh 640772ef0c feat(ble): 新增蓝牙核心功能模块
- 实现 BLE 核心控制器,封装适配器、连接、服务发现等基础能力
- 添加蓝牙授权模块,支持设备绑定和密钥匹配流程
- 集成 WiFi 和雷达数据处理模块
- 添加设备信息解析和状态码处理功能
- 实现字节流工具函数,支持多种二进制格式转换
- 优化蓝牙错误处理和状态管理机制
- 更新 App.vue 注释,增强代码可读性
- 添加 .gitignore 配置,忽略 IDE 相关文件
2026-04-17 16:09:14 +08:00
44 changed files with 4931 additions and 424 deletions

26
.editorconfig Normal file
View File

@ -0,0 +1,26 @@
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

22
.gitignore vendored
View File

@ -2,4 +2,24 @@ node_modules/
unpackage/ unpackage/
.hbuilderx/ .hbuilderx/
.DS_Store .DS_Store
.codex-tasks/ .codex-tasks/
.idea/
.vscode/
.prettierignore
.prettierrc
# Graphify (generated)
graphify-out/
# Claude Code && Codex
.claude/
CLAUDE.md
AGENTS.md
# Codegraph
.codegraph/
# Project-specific
docs/superpowers/

11
App.vue
View File

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

View File

@ -4,6 +4,9 @@ import { showToast } from '@/utils/toast'
let baseURL = DEFAULT_BASE_URL let baseURL = DEFAULT_BASE_URL
/**
* 拼接请求地址支持绝对地址 / 相对地址 / baseURL
*/
function buildUrl(url = '') { function buildUrl(url = '') {
if (!url) { if (!url) {
return baseURL return baseURL
@ -43,6 +46,9 @@ function buildHeaders(customHeader = {}, needAuth = true, withJson = true) {
return headers return headers
} }
/**
* 401 统一处理清理本地登录态并回到登录页
*/
function handleUnauthorized() { function handleUnauthorized() {
if (!isAuthEnabled()) { if (!isAuthEnabled()) {
return return
@ -137,6 +143,7 @@ function request(options = {}) {
}) })
} }
// 语义化快捷方法,保持页面侧调用简洁。
request.get = (url, params = {}, config = {}) => { request.get = (url, params = {}, config = {}) => {
return request({ return request({
url, url,
@ -173,6 +180,9 @@ request.delete = (url, data = {}, config = {}) => {
}) })
} }
/**
* 上传文件封装保持与 request 一致的错误处理与鉴权策略
*/
request.upload = (url, filePath, name = 'file', formData = {}, config = {}) => { request.upload = (url, filePath, name = 'file', formData = {}, config = {}) => {
const { const {
header = {}, header = {},

View File

@ -1,16 +1,38 @@
/** /**
* 目标服务 UUID用于优先识别特定设备 * 目标服务 UUID用于优先识别特定设备
* 协议文档主服务是 0x00F4部分固件会使用 0xF400 / 0xFFF4
*/ */
export const TARGET_SERVICE_UUID = '0000F400-0000-1000-8000-00805F9B34FB' export const TARGET_SERVICE_UUID = '00F4'
export const TARGET_SHORT_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 BLUETOOTH_ERROR_MESSAGES = Object.freeze({ export const BLUETOOTH_ERROR_MESSAGES = Object.freeze({
10001: '蓝牙不可用,请先开启手机蓝牙', 10001: '蓝牙不可用,请先开启手机蓝牙',
10002: '未找到指定设备,请确认设备在附近且处于广播状态',
10003: '连接失败,请重试', 10003: '连接失败,请重试',
10012: '操作超时,请重试' 10012: '操作超时,请重试',
10013: '无效参数,请重试',
10016: '蓝牙适配器初始化失败',
10017: '启动搜索失败'
}) })
export const DEFAULT_BLUETOOTH_ERROR_MESSAGE = '蓝牙搜索启动失败' export const DEFAULT_BLUETOOTH_ERROR_MESSAGE = '蓝牙搜索启动失败'

BIN
docs/ED713-ble.pdf Normal file

Binary file not shown.

BIN
docs/ED719-ble.docx Normal file

Binary file not shown.

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

@ -0,0 +1,168 @@
# 统一 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 分路解析
- ED713MAC[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

View File

@ -1,6 +1,7 @@
import App from './App' import App from './App'
import { setupAuthInterceptors } from './utils/auth' import { setupAuthInterceptors } from './utils/auth'
// 应用启动时注册统一路由鉴权拦截器(内部有幂等保护)。
setupAuthInterceptors() setupAuthInterceptors()
// #ifndef VUE3 // #ifndef VUE3
@ -16,8 +17,14 @@ app.$mount()
// #ifdef VUE3 // #ifdef VUE3
import { createSSRApp } from 'vue' import { createSSRApp } from 'vue'
import uviewPlus from 'uview-plus'
/**
* uni-app Vue3 入口
*/
export function createApp() { export function createApp() {
const app = createSSRApp(App) const app = createSSRApp(App)
app.use(uviewPlus)
return { return {
app app
} }

View File

@ -54,7 +54,8 @@
"setting" : { "setting" : {
"urlCheck" : false "urlCheck" : false
}, },
"usingComponents" : true "usingComponents" : true,
"libVersion" : "latest"
}, },
"mp-alipay" : { "mp-alipay" : {
"usingComponents" : true "usingComponents" : true

435
package-lock.json generated Normal file
View File

@ -0,0 +1,435 @@
{
"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": ""
}
}
}
}

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"dependencies": {
"sass": "^1.99.0",
"uview-plus": "^3.7.36"
}
}

View File

@ -1,67 +1,74 @@
{ {
"pages": [ "pages": [
{ {
"path": "pages/login/index", "path": "pages/login/index",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/index/index", "path": "pages/index/index",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/event/index", "path": "pages/event/index",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/mine/index", "path": "pages/mine/index",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
}, },
{ {
"path": "pages/device/add", "path": "pages/device/add",
"style": { "style": {
"navigationBarTitleText": "添加设备" "navigationBarTitleText": "添加设备"
} }
}, },
{ {
"path": "pages/device/config", "path": "pages/device/config",
"style": { "style": {
"navigationStyle": "custom" "navigationStyle": "custom"
} }
} }
], ],
"globalStyle": { "globalStyle": {
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"navigationBarTitleText": "智慧养老", "navigationBarTitleText": "智慧养老",
"navigationBarBackgroundColor": "#F8F8F8", "navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F1F1F4" "backgroundColor": "#F1F1F4"
}, },
"tabBar": { "tabBar": {
"color": "#666666", "color": "#666666",
"selectedColor": "#2CCB98", "selectedColor": "#2CCB98",
"backgroundColor": "#FFFFFF", "backgroundColor": "#FFFFFF",
"borderStyle": "black", "borderStyle": "black",
"list": [ "list": [
{ {
"pagePath": "pages/index/index", "pagePath": "pages/index/index",
"text": "首页" "text": "首页"
}, },
{ {
"pagePath": "pages/event/index", "pagePath": "pages/event/index",
"text": "事件" "text": "事件"
}, },
{ {
"pagePath": "pages/mine/index", "pagePath": "pages/mine/index",
"text": "我的" "text": "我的"
} }
] ]
}, },
"uniIdRouter": {} "uniIdRouter": {},
"easycom": {
"autoscan": true,
"custom": {
"^u-(.*)": "uview-plus/components/u-$1/u-$1.vue",
"^up-(.*)": "uview-plus/components/u-$1/u-$1.vue"
}
}
} }

View File

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

View File

@ -27,22 +27,122 @@
<view class="device-name-row"> <view class="device-name-row">
<text class="device-name">{{ device.showName }}</text> <text class="device-name">{{ device.showName }}</text>
<text v-if="device.hasTargetService" class="device-tag">00F4</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> </view>
<text class="device-id">{{ device.deviceId }}</text> <text class="device-id">{{ device.deviceId }}</text>
<view class="device-actions"> <view class="device-actions">
<view class="action-btn">网络配置</view> <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 class="action-btn">服务器配置</view>
</view> </view>
</view> </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> </view>
</template> </template>
<script> <script>
import { createBluetoothDiscovery } from '@/hooks/useBluetoothDiscovery' import { createBluetoothDiscovery } from '@/hooks/useBluetoothDiscovery'
import { navigateBack } from '@/utils/navigation' 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 { export default {
data() { data() {
@ -50,17 +150,40 @@ export default {
isSearching: false, isSearching: false,
deviceList: [], deviceList: [],
deviceMap: {}, deviceMap: {},
discoveryController: null discoveryController: null,
// WiFi
wifiPanelVisible: false,
wifiPreparing: false,
activePreparingDeviceId: '',
wifiSending: false,
fetchingCurrentWifi: false,
wifiPrepared: false,
wifiErrorTip: '',
devicePreparingMap: {},
selectedWifiDevice: null,
wifiForm: {
ssid: '',
password: ''
}
} }
}, },
computed: { computed: {
searchBtnText() { searchBtnText() {
return this.isSearching ? '搜索中' : '搜索' return this.isSearching ? '搜索中' : '搜索'
},
selectedDeviceName() {
if (!this.selectedWifiDevice) {
return '未选择设备'
}
return this.selectedWifiDevice.showName || this.selectedWifiDevice.deviceId || '未命名设备'
} }
}, },
created() { created() {
/** /**
* 蓝牙搜索控制器统一处理设备发现排序清理逻辑 * 蓝牙搜索控制器统一处理设备发现连接鉴权WiFi 发送等流程
*/ */
this.discoveryController = createBluetoothDiscovery({ this.discoveryController = createBluetoothDiscovery({
onSearchingChange: (value) => { onSearchingChange: (value) => {
@ -81,11 +204,76 @@ export default {
this.cleanupDiscovery() this.cleanupDiscovery()
}, },
methods: { 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() { cleanupDiscovery() {
if (!this.discoveryController) { if (!this.discoveryController) {
return return
} }
this.devicePreparingMap = {}
this.cleanupWifiPanelState()
this.discoveryController.cleanupDiscovery() this.discoveryController.cleanupDiscovery()
}, },
@ -108,6 +296,229 @@ export default {
await this.discoveryController.startSearch() await this.discoveryController.startSearch()
} catch (error) { } 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)
}
}
} }
} }
} }
@ -273,6 +684,11 @@ export default {
padding: 2rpx 10rpx; padding: 2rpx 10rpx;
} }
.name-tag {
color: #2f7de1;
border-color: #2f7de1;
}
.device-id { .device-id {
display: block; display: block;
margin-top: 8rpx; margin-top: 8rpx;
@ -295,4 +711,169 @@ export default {
border-radius: 6rpx; border-radius: 6rpx;
background: #f8fffc; 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> </style>

View File

@ -1,46 +1,44 @@
<!-- 事件页使用 uview-plus 筛选与空状态组件 -->
<template> <template>
<view class="page page-base"> <view class="page page-base">
<view class="top-nav top-nav-base"> <up-navbar title="事件" :fixed="false" :safe-area-inset-top="true" :border="false" left-icon="" />
<view class="title top-nav-title-base">事件</view>
</view>
<view class="filters"> <view class="filters">
<view class="filter-item">全部场所</view> <view><up-button size="mini" type="primary" plain text="全部场所" /></view>
<view class="filter-item">全部状态</view> <view><up-button size="mini" type="primary" plain text="全部状态" /></view>
<view class="filter-item">时间</view> <view><up-button size="mini" type="primary" plain text="时间" /></view>
</view> </view>
<view class="empty-area empty-state"> <view class="empty-area">
<view class="empty-box empty-state-box"></view> <up-empty mode="message" text="暂无事件数据" />
<text class="empty-text empty-state-text">暂无数据</text>
</view> </view>
</view> </view>
</template> </template>
<script> <script>
//
export default {} export default {}
</script> </script>
<style> <style lang="scss" scoped>
@import '@/styles/common.css'; @import '@/styles/common.css';
.filters { .filters {
height: 92rpx; > view + view {
margin-left: 18rpx;
}
min-height: 96rpx;
padding: 16rpx 24rpx;
background: #ffffff; background: #ffffff;
border-top: 1rpx solid #ededed; border-top: 1rpx solid #ededed;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.filter-item {
flex: 1;
text-align: center;
font-size: 42rpx;
color: #3a3a3a;
font-weight: 600;
}
.empty-area { .empty-area {
min-height: 980rpx; min-height: 880rpx;
display: flex;
align-items: center;
justify-content: center;
} }
</style> </style>

View File

@ -1,8 +1,7 @@
<!-- 首页切换为 uview-plus 组件化结构保留原有设备添加与配置入口 -->
<template> <template>
<view class="page page-base"> <view class="page page-base">
<view class="top-nav top-nav-base"> <up-navbar title="智慧养老" :fixed="false" :safe-area-inset-top="true" :border="false" left-icon="" />
<view class="title">智慧养老</view>
</view>
<view class="banner"> <view class="banner">
<view class="banner-left"> <view class="banner-left">
@ -10,22 +9,33 @@
<text class="banner-sub">一键开启智慧好生活</text> <text class="banner-sub">一键开启智慧好生活</text>
</view> </view>
<view class="banner-illus"> <view class="banner-illus">
<view class="phone"></view> <up-icon name="home" color="#ffffff" size="52" />
</view> </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-row" @click="goConfig">
<text class="menu"></text> <view class="site-left">
<text class="site-text">请选择场所</text> <up-icon name="list" size="18" color="#26c996" />
<text class="down"></text> <text class="site-text">请选择场所</text>
</view>
<up-icon name="arrow-right" size="14" color="#26c996" />
</view> </view>
<view class="empty-area empty-state"> <view class="empty-area">
<view class="empty-box empty-state-box"></view> <up-empty mode="list" text="暂无数据" />
<text class="empty-text empty-state-text">暂无数据</text>
</view> </view>
<view class="fab" @click="goAdd">+</view> <view class="fab-wrap">
<up-button type="success" shape="circle" text="添加设备" @click="goAdd" />
</view>
</view> </view>
</template> </template>
@ -34,10 +44,17 @@ import { ROUTES } from '@/constants/routes'
import { navigateTo } from '@/utils/navigation' import { navigateTo } from '@/utils/navigation'
export default { export default {
data() {
return {
noticeText: '点击选择场所后可进入设备配置流程'
}
},
methods: { methods: {
//
goAdd() { goAdd() {
navigateTo(ROUTES.DEVICE_ADD) navigateTo(ROUTES.DEVICE_ADD)
}, },
//
goConfig() { goConfig() {
navigateTo(ROUTES.DEVICE_CONFIG) navigateTo(ROUTES.DEVICE_CONFIG)
} }
@ -45,25 +62,19 @@ export default {
} }
</script> </script>
<style> <style lang="scss" scoped>
@import '@/styles/common.css'; @import '@/styles/common.css';
.page { .page {
position: relative; position: relative;
padding-bottom: 160rpx; padding-bottom: 180rpx;
}
.title {
font-size: 48rpx;
font-weight: 700;
color: #222;
} }
.banner { .banner {
margin: 0; margin: 0;
height: 380rpx; min-height: 360rpx;
background: #28cda0; background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
padding: 56rpx 52rpx; padding: 48rpx 44rpx;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -75,75 +86,61 @@ export default {
} }
.banner-title { .banner-title {
font-size: 58rpx; font-size: 52rpx;
color: #ffffff; color: #ffffff;
font-weight: 700; font-weight: 700;
margin-bottom: 24rpx; margin-bottom: 20rpx;
} }
.banner-sub { .banner-sub {
font-size: 46rpx; font-size: 34rpx;
color: #e9fff8; color: #e9fff8;
font-weight: 600; font-weight: 600;
} }
.banner-illus { .banner-illus {
width: 220rpx; width: 180rpx;
height: 220rpx; height: 180rpx;
border-radius: 16rpx; border-radius: 24rpx;
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.18);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.phone {
width: 120rpx;
height: 170rpx;
border: 8rpx solid rgba(255, 255, 255, 0.92);
border-radius: 22rpx;
}
.site-row { .site-row {
height: 108rpx; height: 108rpx;
padding: 0 36rpx; padding: 0 32rpx;
background: #ffffff; background: #ffffff;
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 46rpx; justify-content: space-between;
color: #222;
} }
.menu { .site-left {
margin-right: 22rpx; display: flex;
align-items: center;
} }
.site-text { .site-text {
margin-left: 14rpx;
font-size: 34rpx;
color: #222;
font-weight: 600; font-weight: 600;
} }
.down {
margin-left: 10rpx;
color: #26c996;
}
.empty-area { .empty-area {
margin-top: 40rpx; margin-top: 40rpx;
min-height: 760rpx; min-height: 680rpx;
display: flex;
align-items: center;
justify-content: center;
} }
.fab { .fab-wrap {
position: fixed; position: fixed;
right: 38rpx; right: 32rpx;
bottom: 220rpx; bottom: 220rpx;
width: 118rpx; width: 240rpx;
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> </style>

View File

@ -6,31 +6,47 @@
</view> </view>
<view class="card"> <view class="card">
<view class="form-row"> <up-form :model="form" label-position="top" :border-bottom="false">
<text class="prefix">+86</text> <up-form-item label="手机号" :required="true">
<input <view class="mobile-wrap">
v-model="mobile" <text class="prefix">+86</text>
class="input" <up-input
type="number" v-model="form.mobile"
maxlength="11" type="number"
placeholder="请输入手机号" maxlength="11"
/> clearable
</view> placeholder="请输入手机号"
border="none"
:custom-style="inputInnerStyle"
/>
</view>
</up-form-item>
<view class="form-row code-row"> <up-form-item label="验证码" :required="true">
<input <view class="code-wrap">
v-model="code" <up-input
class="input" v-model="form.code"
type="number" type="number"
maxlength="6" maxlength="6"
placeholder="请输入验证码" clearable
/> placeholder="请输入验证码"
<button class="code-btn" :disabled="countdown > 0" @click="sendCode"> border="none"
{{ codeBtnText }} :custom-style="inputInnerStyle"
</button> />
</view> <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>
<button class="login-btn" @click="loginByMobileSubmit">手机号验证码登录</button> <up-button class="login-btn" type="success" shape="circle" text="手机号验证码登录" @click="loginByMobileSubmit" />
<view class="split"> <view class="split">
<view class="line"></view> <view class="line"></view>
@ -38,7 +54,7 @@
<view class="line"></view> <view class="line"></view>
</view> </view>
<button class="wx-btn" @click="loginByWechatSubmit">微信一键登录</button> <up-button class="wx-btn" type="success" shape="circle" text="微信一键登录" @click="loginByWechatSubmit" />
</view> </view>
</view> </view>
</template> </template>
@ -53,11 +69,16 @@ import { isValidMobile, isValidSmsCode } from '@/utils/validators'
export default { export default {
data() { data() {
return { return {
mobile: '', form: {
code: '', mobile: '',
code: ''
},
countdown: 0, countdown: 0,
redirect: '', redirect: '',
countdownController: null countdownController: null,
inputInnerStyle: {
padding: '0'
}
} }
}, },
computed: { computed: {
@ -93,7 +114,7 @@ export default {
}, },
validateMobileOrToast() { validateMobileOrToast() {
if (isValidMobile(this.mobile)) { if (isValidMobile(this.form.mobile)) {
return true return true
} }
@ -119,7 +140,7 @@ export default {
} }
try { try {
await sendSmsCode(this.mobile) await sendSmsCode(this.form.mobile)
} catch (error) { } catch (error) {
return return
} }
@ -133,7 +154,7 @@ export default {
return return
} }
if (!isValidSmsCode(this.code)) { if (!isValidSmsCode(this.form.code)) {
showToast('请输入6位验证码') showToast('请输入6位验证码')
return return
} }
@ -143,12 +164,12 @@ export default {
}) })
try { try {
const data = await loginByMobile(this.mobile, this.code) const data = await loginByMobile(this.form.mobile, this.form.code)
this.finishLogin({ this.finishLogin({
token: data?.token, token: data?.token,
user: data?.user || { user: data?.user || {
name: `用户${this.mobile.slice(-4)}`, name: `用户${this.form.mobile.slice(-4)}`,
mobile: this.mobile, mobile: this.form.mobile,
loginType: 'mobile' loginType: 'mobile'
} }
}) })
@ -214,7 +235,7 @@ export default {
} }
</script> </script>
<style> <style lang="scss" scoped>
.page { .page {
min-height: 100vh; min-height: 100vh;
background: linear-gradient(180deg, #ecf9f5 0%, #f5f6f8 48%, #f1f1f4 100%); background: linear-gradient(180deg, #ecf9f5 0%, #f5f6f8 48%, #f1f1f4 100%);
@ -243,22 +264,27 @@ export default {
.card { .card {
background: #ffffff; background: #ffffff;
border-radius: 24rpx; border-radius: 24rpx;
padding: 38rpx 30rpx 42rpx; padding: 30rpx;
box-shadow: 0 16rpx 40rpx rgba(10, 63, 48, 0.08); box-shadow: 0 16rpx 40rpx rgba(10, 63, 48, 0.08);
} }
.form-row { .mobile-wrap,
.code-wrap {
min-height: 98rpx; min-height: 98rpx;
border: 1rpx solid #e8eaef; border: 1rpx solid #e8eaef;
border-radius: 16rpx; border-radius: 16rpx;
padding: 0 24rpx; padding: 0 20rpx;
display: flex; display: flex;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
} }
.form-row + .form-row { .mobile-wrap {
margin-top: 22rpx; margin-top: 10rpx;
}
.code-wrap {
margin-top: 10rpx;
} }
.prefix { .prefix {
@ -268,57 +294,16 @@ export default {
font-weight: 600; font-weight: 600;
} }
.input {
flex: 1;
height: 98rpx;
font-size: 30rpx;
color: #1f2b37;
}
.code-row {
padding-right: 12rpx;
}
.code-btn { .code-btn {
width: 190rpx; margin-left: 12rpx;
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 { .login-btn {
background: linear-gradient(90deg, #23c797, #11b880); margin-top: 28rpx;
} }
.login-btn::after, .wx-btn {
.wx-btn::after { margin-top: 22rpx;
border: none;
} }
.split { .split {
@ -338,9 +323,4 @@ export default {
font-size: 26rpx; font-size: 26rpx;
color: #9aa2ad; color: #9aa2ad;
} }
.wx-btn {
margin-top: 24rpx;
background: #07c160;
}
</style> </style>

View File

@ -1,51 +1,27 @@
<!-- 我的页引入 uview-plus 组件重构个人中心菜单 -->
<template> <template>
<view class="page page-base"> <view class="page page-base">
<view class="top-nav top-nav-base"> <up-navbar title="我的" :fixed="false" :safe-area-inset-top="true" :border="false" left-icon="" />
<view class="title top-nav-title-base">我的</view>
</view>
<view class="user-card" @click="goLogin"> <view class="user-card" @click="goLogin">
<view class="avatar"></view> <up-avatar size="58" text="U" bg-color="#35c48f" />
<view> <view class="user-main">
<text class="user-name">{{ userName }}</text> <text class="user-name">{{ userName }}</text>
<text class="user-tip">{{ loginTip }}</text> <text class="user-tip">{{ loginTip }}</text>
</view> </view>
<up-icon name="arrow-right" size="14" color="#b5b9c0" />
</view> </view>
<view class="menu-card"> <up-cell-group :border="false" custom-style="margin: 24rpx; border-radius: 24rpx; overflow: hidden; background: #ffffff;">
<view class="item" @click="goConfig"> <up-cell title="设备配置" is-link @click="goConfig" />
<text class="left">设备配置</text> <up-cell title="分享的设备" is-link />
<text class="right"></text> <up-cell title="场所管理" is-link />
</view> <up-cell title="接警人管理" is-link />
<view class="item"> <up-cell title="手机号" :value="phoneText" is-link />
<text class="left">分享的设备</text> <up-cell title="语音广播" is-link />
<text class="right"></text> <up-cell title="当前版本" value="2.8.44" />
</view> <up-cell title="退出登录" @click="logout" />
<view class="item"> </up-cell-group>
<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> </view>
</template> </template>
@ -68,17 +44,20 @@ export default {
phoneText() { phoneText() {
return this.userInfo?.mobile || '未绑定' return this.userInfo?.mobile || '未绑定'
}, },
//
loginTip() { loginTip() {
return this.userInfo?.loginType === 'mobile' ? '手机号验证码登录' : '微信登录' return this.userInfo?.loginType === 'mobile' ? '手机号验证码登录' : '微信登录'
} }
}, },
onShow() { onShow() {
//
this.userInfo = getUser() this.userInfo = getUser()
}, },
methods: { methods: {
goConfig() { goConfig() {
navigateTo(ROUTES.DEVICE_CONFIG) navigateTo(ROUTES.DEVICE_CONFIG)
}, },
//
goLogin() { goLogin() {
if (isLoggedIn()) { if (isLoggedIn()) {
return return
@ -86,6 +65,7 @@ export default {
navigateTo(LOGIN_PAGE) navigateTo(LOGIN_PAGE)
}, },
// 退
logout() { logout() {
clearAuth() clearAuth()
showToast('已退出登录') showToast('已退出登录')
@ -95,7 +75,7 @@ export default {
} }
</script> </script>
<style> <style lang="scss" scoped>
@import '@/styles/common.css'; @import '@/styles/common.css';
.page { .page {
@ -104,25 +84,22 @@ export default {
.user-card { .user-card {
margin: 24rpx; margin: 24rpx;
min-height: 200rpx; min-height: 180rpx;
border-radius: 24rpx; border-radius: 24rpx;
background: #ffffff; background: #ffffff;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 20rpx 34rpx; padding: 20rpx 28rpx;
} }
.avatar { .user-main {
width: 132rpx; flex: 1;
height: 132rpx; margin-left: 20rpx;
border-radius: 66rpx;
background: radial-gradient(circle at 35% 35%, #95d347 0 32%, #6ba83a 33% 100%);
margin-right: 24rpx;
} }
.user-name { .user-name {
display: block; display: block;
font-size: 46rpx; font-size: 42rpx;
color: #2a2d33; color: #2a2d33;
font-weight: 700; font-weight: 700;
} }
@ -131,43 +108,6 @@ export default {
display: block; display: block;
margin-top: 12rpx; margin-top: 12rpx;
color: #9ea1a6; color: #9ea1a6;
font-size: 30rpx; font-size: 28rpx;
}
.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> </style>

View File

@ -1,8 +1,10 @@
/* 页面通用背景基线:统一浅灰底色,减少各页面重复定义。 */
.page-base { .page-base {
min-height: 100vh; min-height: 100vh;
background: #f1f1f4; background: #f1f1f4;
} }
/* 顶部导航通用布局:居中标题 + 左右扩展占位。 */
.top-nav-base { .top-nav-base {
height: 120rpx; height: 120rpx;
padding: 24rpx 24rpx 0; padding: 24rpx 24rpx 0;
@ -19,6 +21,7 @@
color: #222222; color: #222222;
} }
/* 空状态通用样式,供首页/事件页等无数据场景复用。 */
.empty-state { .empty-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -40,6 +43,7 @@
font-weight: 600; font-weight: 600;
} }
/* 卡片外观基类。 */
.card-base { .card-base {
background: #ffffff; background: #ffffff;
border-radius: 24rpx; border-radius: 24rpx;

View File

@ -1,13 +1,19 @@
// uni-app Promise 适配:把 [err, data] 风格的返回值统一为 resolve/reject。
uni.addInterceptor({ uni.addInterceptor({
returnValue (res) { returnValue (res) {
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) { if (!(!!res && (typeof res === 'object' || typeof res === 'function') && typeof res.then === 'function')) {
return res; return res
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
res.then((res) => { res.then((value) => {
if (!res) return resolve(res) if (!value) {
return res[0] ? reject(res[0]) : resolve(res[1]) return resolve(value)
}); }
});
}, // uni API Promise 通常返回 [error, result]。
}); return value[0] ? reject(value[0]) : resolve(value[1])
})
})
}
})

View File

@ -6,6 +6,8 @@
* *
*/ */
@import 'uview-plus/theme.scss';
/** /**
* 如果你是App开发者插件使用者你可以通过修改这些变量来定制自己的插件主题实现自定义主题功能 * 如果你是App开发者插件使用者你可以通过修改这些变量来定制自己的插件主题实现自定义主题功能
* *

View File

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

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

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

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

@ -0,0 +1,669 @@
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,
};
}

View File

@ -0,0 +1,284 @@
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
* 雷达帧还能自动推断设备 profileED713/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,
};
}

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

@ -0,0 +1,63 @@
// 微信 BLE 常见 errCode 到可读文案映射。
const ERROR_MESSAGES = Object.freeze({
10000: '未初始化蓝牙适配器',
10001: '当前蓝牙适配器不可用',
10002: '没有找到指定设备',
10003: '连接失败',
10004: '没有找到指定服务',
10005: '没有找到指定特征值',
10006: '当前连接已断开',
10007: '当前特征值不支持此操作',
10008: '其余所有系统上报的异常',
10009: 'Android 系统特有,系统版本低于 4.3 不支持 BLE',
10010: '已连接',
10011: '配对设备需要配对码',
10012: '连接超时',
10013: '连接 deviceId 为空或者是格式不正确',
10014: 'writeCharacteristicValue: 找不到指定设备',
10015: 'readCharacteristicValue: 找不到指定设备',
10016: 'openBluetoothAdapter: 未找到蓝牙适配器',
10017: 'startBluetoothDevicesDiscovery: 未找到蓝牙适配器',
10018: 'stopBluetoothDevicesDiscovery: 调用失败',
10019: 'getBluetoothDevices: 调用失败',
10020: 'getConnectedBluetoothDevices: 调用失败',
});
/**
* 构造统一 BLE Error方便上层直接读取 code/message
*/
export function createBleError(code, message, payload = {}) {
const err = new Error(message || ERROR_MESSAGES[code] || '蓝牙操作失败');
err.name = 'BleError';
err.code = code;
Object.assign(err, payload);
return err;
}
/**
* 归一化任意错误对象uni err / 原生 Error / 空值
*/
export function normalizeBleError(error, fallbackMessage = '蓝牙操作失败') {
if (!error) {
return createBleError(-1, fallbackMessage);
}
if (error.name === 'BleError') {
return error;
}
const errCode = typeof error.errCode === 'number' ? error.errCode : -1;
const message = error.errMsg || ERROR_MESSAGES[errCode] || fallbackMessage;
return createBleError(errCode, message, { raw: error });
}
/**
* 安全获取错误文案避免页面层处理 null/undefined 分支
*/
export function getBleErrorText(error, fallbackText = '蓝牙操作失败') {
if (!error) {
return fallbackText;
}
return error.message || ERROR_MESSAGES[error.code] || fallbackText;
}

View File

@ -0,0 +1,69 @@
/**
* 轻量事件总线用于 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,
};
}

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

@ -0,0 +1,84 @@
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
}

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

@ -0,0 +1,40 @@
// 简单 sleep 工具,供重试间隔等待使用。
export function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
/**
* 通用重试执行器
* @param {(retryIndex:number)=>Promise<any>} fn 被执行函数
* @param {object} options
* @param {number} options.retries 失败后最大重试次数
* @param {number} options.delay 每次重试前等待毫秒
* @param {(error:any,retryIndex:number)=>boolean} options.shouldRetry 是否重试
*/
export async function runWithRetry(fn, options = {}) {
const {
retries = 2,
delay = 500,
shouldRetry = () => true
} = options
let lastError = null
for (let index = 0; index <= retries; index += 1) {
try {
return await fn(index)
} catch (error) {
lastError = error
if (index >= retries || !shouldRetry(error, index)) {
throw error
}
await sleep(delay)
}
}
throw lastError
}

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

@ -0,0 +1,38 @@
// BLE 状态机阶段定义,便于页面层按阶段展示文案/按钮状态。
export const BLE_STAGE = Object.freeze({
IDLE: 'IDLE',
ADAPTER_OPENED: 'ADAPTER_OPENED',
DISCOVERING: 'DISCOVERING',
CONNECTING: 'CONNECTING',
CONNECTED: 'CONNECTED',
SERVICE_DISCOVERED: 'SERVICE_DISCOVERED',
AUTHORIZING: 'AUTHORIZING',
AUTHORIZED: 'AUTHORIZED',
OPERATING: 'OPERATING',
DISCONNECTED: 'DISCONNECTED'
})
/**
* 创建 BLE 运行时状态
* 注意返回的是可变对象 bleCore 内部统一 patch
*/
export function createBleState() {
return {
stage: BLE_STAGE.IDLE,
available: false,
discovering: false,
connected: false,
deviceId: '',
serviceId: '',
profileId: '',
reconnectCount: 0,
lastError: null,
deviceMap: {},
characteristics: {},
auth: {
bound: null,
keyMatched: false,
lastStateCode: null
}
}
}

View File

@ -0,0 +1,99 @@
/**
* 统一等待 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}`)))
}
})
})
}

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

@ -0,0 +1,22 @@
/**
* 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'

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

@ -0,0 +1,312 @@
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,
};
}

119
utils/ble/modules/radar.js Normal file
View File

@ -0,0 +1,119 @@
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参数1byte0=, 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,
};
}

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

@ -0,0 +1,130 @@
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 }
}

View File

@ -0,0 +1,157 @@
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
}
}

View File

@ -0,0 +1,39 @@
/**
* 解析器统一出口
* 对外暴露 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
}
}

View File

@ -0,0 +1,143 @@
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
}

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

@ -0,0 +1,116 @@
/**
* 把常见二进制输入统一转换成 Uint8Array
* 支持Uint8Array / ArrayBuffer / TypedArray / number[]
*/
export function toUint8Array(value) {
if (value instanceof Uint8Array) {
return value
}
if (value instanceof ArrayBuffer) {
return new Uint8Array(value)
}
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer)
}
if (Array.isArray(value)) {
return Uint8Array.from(value)
}
return new Uint8Array()
}
/**
* 生成刚好覆盖有效字节范围 ArrayBuffer
* 避免把 TypedArray 背后多余 buffer 区域一并透传
*/
export function uint8ArrayToArrayBuffer(value) {
const bytes = toUint8Array(value)
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
}
/**
* 把多个字节数组按顺序拼接
*/
export function concatUint8Arrays(list = []) {
const bytesList = list.map((item) => toUint8Array(item))
const total = bytesList.reduce((sum, item) => sum + item.length, 0)
const merged = new Uint8Array(total)
let offset = 0
bytesList.forEach((item) => {
merged.set(item, offset)
offset += item.length
})
return merged
}
/**
* chunkSize 对字节流分片
* chunkSize 无效时返回原数据单片
*/
export function splitUint8Array(value, chunkSize) {
const bytes = toUint8Array(value)
if (!chunkSize || chunkSize <= 0) {
return [bytes]
}
const chunks = []
for (let index = 0; index < bytes.length; index += chunkSize) {
chunks.push(bytes.slice(index, index + chunkSize))
}
return chunks
}
/**
* 按小端序读取 16 位无符号整数
*/
export function readUint16LE(bytes, offset) {
const value = toUint8Array(bytes)
const low = value[offset] || 0
const high = value[offset + 1] || 0
return (high << 8) | low
}
/**
* 按小端序读取 32 位无符号整数
*/
export function readUint32LE(bytes, offset) {
const value = toUint8Array(bytes)
return ((value[offset + 3] || 0) << 24) >>> 0
| ((value[offset + 2] || 0) << 16)
| ((value[offset + 1] || 0) << 8)
| (value[offset] || 0)
}
/**
* 16 位整数拆成低位/高位两个字节小端常用
*/
export function splitToLowHigh(value) {
const normalized = Number(value) || 0
return {
low: normalized & 0xFF,
high: (normalized >> 8) & 0xFF
}
}
/**
* 把低位/高位字节还原成 16 位整数
*/
export function joinLowHigh(low, high) {
return ((Number(high) || 0) << 8) | (Number(low) || 0)
}
/**
* 0~255 转成有符号 int8-128~127
*/
export function toSignedInt8(value) {
const number = Number(value) || 0
return number > 127 ? number - 256 : number
}

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

@ -0,0 +1,71 @@
import { toUint8Array } from './bytes'
/**
* 字节数组转十六进制字符串
* withSpace=true 时输出 "aa bb cc"否则输出 "aabbcc"
*/
export function bytesToHex(input, withSpace = false) {
const bytes = toUint8Array(input)
const parts = Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0'))
return withSpace ? parts.join(' ') : parts.join('')
}
/**
* 十六进制字符串转 Uint8Array
* 会自动忽略分隔符并兼容奇数字符长度自动左补0
*/
export function hexToBytes(hexText = '') {
const cleaned = String(hexText || '').replace(/[^0-9a-fA-F]/g, '')
if (!cleaned) {
return new Uint8Array()
}
const normalized = cleaned.length % 2 === 0 ? cleaned : `0${cleaned}`
const result = new Uint8Array(normalized.length / 2)
for (let index = 0; index < normalized.length; index += 2) {
result[index / 2] = parseInt(normalized.slice(index, index + 2), 16)
}
return result
}
/**
* UTF-8 字符串转字节数组
* 优先使用 TextEncoder低版本环境走兼容路径
*/
export function utf8ToBytes(text = '') {
if (typeof TextEncoder !== 'undefined') {
return new TextEncoder().encode(String(text || ''))
}
const encoded = unescape(encodeURIComponent(String(text || '')))
return Uint8Array.from(Array.from(encoded).map((char) => char.charCodeAt(0)))
}
/**
* 字节数组转 UTF-8 字符串
* 优先使用 TextDecoder低版本环境走兼容路径
*/
export function bytesToUtf8(input) {
const bytes = toUint8Array(input)
if (!bytes.length) {
return ''
}
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder('utf-8').decode(bytes)
}
const latin = Array.from(bytes).map((byte) => String.fromCharCode(byte)).join('')
return decodeURIComponent(escape(latin))
}
/**
* MAC 字节转为 "aa:bb:cc:dd:ee:ff" 形式
*/
export function bytesToMac(input) {
return bytesToHex(input, true).replace(/ /g, ':')
}

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

@ -0,0 +1,90 @@
const BASE_UUID_SUFFIX = '0000-1000-8000-00805F9B34FB'
/**
* 规范化 UUID移除非16进制字符并转大写
*/
export function normalizeUuid(uuid) {
return String(uuid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
}
/**
* 提取短 UUID16-bit用于不同写法间比较
*/
export function shortUuid(uuid) {
const normalized = normalizeUuid(uuid)
if (!normalized) {
return ''
}
if (normalized.length === 4) {
return normalized
}
if (normalized.length === 8) {
return normalized.slice(4)
}
if (normalized.length === 32) {
return normalized.slice(4, 8)
}
return normalized.slice(-4)
}
function format32Uuid(normalized32) {
return [
normalized32.slice(0, 8),
normalized32.slice(8, 12),
normalized32.slice(12, 16),
normalized32.slice(16, 20),
normalized32.slice(20, 32)
].join('-')
}
/**
* 16/32/128 UUID 统一转成标准 128 位字符串
* 无法识别时返回空串
*/
export function toFullUuid(uuid) {
const normalized = normalizeUuid(uuid)
if (!normalized) {
return ''
}
if (normalized.length === 32) {
return format32Uuid(normalized)
}
if (normalized.length === 8) {
return `${normalized}-${BASE_UUID_SUFFIX}`
}
if (normalized.length === 4) {
return `${`0000${normalized}`}-${BASE_UUID_SUFFIX}`
}
return ''
}
/**
* UUID 宽松比较支持完整格式一致或短 UUID 一致
*/
export function uuidEquals(left, right) {
const leftNormalized = normalizeUuid(left)
const rightNormalized = normalizeUuid(right)
if (!leftNormalized || !rightNormalized) {
return false
}
return leftNormalized === rightNormalized || shortUuid(leftNormalized) === shortUuid(rightNormalized)
}
/**
* 判断目标 UUID 是否在列表中使用 uuidEquals 宽松匹配
*/
export function uuidInList(targetUuid, uuidList = []) {
return uuidList.some((uuid) => uuidEquals(targetUuid, uuid))
}

View File

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