Compare commits
No commits in common. "82b287dd9286a55471a71e75883224f422841a41" and "e4a977532794ff226573ddbf53f2c92c2924eeb5" have entirely different histories.
82b287dd92
...
e4a9775327
|
|
@ -1,26 +0,0 @@
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
|
|
||||||
[*.{js,ts,css,less,scss,vue,html,json,md}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.{yml,yaml}]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
|
|
||||||
[*.wxss]
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.json]
|
|
||||||
indent_size = 2
|
|
||||||
|
|
@ -3,23 +3,3 @@ 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
11
App.vue
|
|
@ -1,17 +1,12 @@
|
||||||
<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() {
|
||||||
|
|
@ -20,8 +15,6 @@ export default {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style>
|
||||||
@import 'uview-plus/index.scss';
|
/*每个页面公共css */
|
||||||
|
|
||||||
/* 每个页面公共 css(当前按需在页面中引入 common.css) */
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,6 @@ 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
|
||||||
|
|
@ -46,9 +43,6 @@ function buildHeaders(customHeader = {}, needAuth = true, withJson = true) {
|
||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 401 统一处理:清理本地登录态并回到登录页。
|
|
||||||
*/
|
|
||||||
function handleUnauthorized() {
|
function handleUnauthorized() {
|
||||||
if (!isAuthEnabled()) {
|
if (!isAuthEnabled()) {
|
||||||
return
|
return
|
||||||
|
|
@ -143,7 +137,6 @@ function request(options = {}) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语义化快捷方法,保持页面侧调用简洁。
|
|
||||||
request.get = (url, params = {}, config = {}) => {
|
request.get = (url, params = {}, config = {}) => {
|
||||||
return request({
|
return request({
|
||||||
url,
|
url,
|
||||||
|
|
@ -180,9 +173,6 @@ request.delete = (url, data = {}, config = {}) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传文件封装:保持与 request 一致的错误处理与鉴权策略。
|
|
||||||
*/
|
|
||||||
request.upload = (url, filePath, name = 'file', formData = {}, config = {}) => {
|
request.upload = (url, filePath, name = 'file', formData = {}, config = {}) => {
|
||||||
const {
|
const {
|
||||||
header = {},
|
header = {},
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,16 @@
|
||||||
/**
|
/**
|
||||||
* 目标服务 UUID(用于优先识别特定设备)。
|
* 目标服务 UUID(用于优先识别特定设备)。
|
||||||
* 协议文档主服务是 0x00F4,部分固件会使用 0xF400 / 0xFFF4。
|
|
||||||
*/
|
*/
|
||||||
export const TARGET_SERVICE_UUID = '00F4'
|
export const TARGET_SERVICE_UUID = '0000F400-0000-1000-8000-00805F9B34FB'
|
||||||
export const TARGET_SERVICE_UUID_FULL = '000000F4-0000-1000-8000-00805F9B34FB'
|
export const TARGET_SHORT_UUID = '00F4'
|
||||||
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 = '蓝牙搜索启动失败'
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,168 +0,0 @@
|
||||||
# 统一 BLE 连接与控制流程(ED713 / ED719)
|
|
||||||
|
|
||||||
## 1. 标准连接流程图(文字版)
|
|
||||||
1. 初始化阶段
|
|
||||||
- `openBluetoothAdapter`
|
|
||||||
- 注册监听:`onBluetoothAdapterStateChange`、`onBLEConnectionStateChange`、`onBLECharacteristicValueChange`
|
|
||||||
|
|
||||||
2. 搜索阶段
|
|
||||||
- 优先过滤扫描:`startBluetoothDevicesDiscovery({ services:[0x00F4] })`
|
|
||||||
- 超时无结果则无过滤兜底扫描
|
|
||||||
- 选择目标设备 `deviceId`
|
|
||||||
|
|
||||||
3. 连接与发现阶段
|
|
||||||
- `createBLEConnection(deviceId)`
|
|
||||||
- `getBLEDeviceServices`,定位服务 `0x00F4`
|
|
||||||
- `getBLEDeviceCharacteristics`,识别 `F401/F402/F403/F501/F301/F302`
|
|
||||||
- 订阅通知:`notify(F301)=true`、`notify(F302)=true`
|
|
||||||
|
|
||||||
4. 标准业务阶段
|
|
||||||
- 读取设备信息:`read(F501)`
|
|
||||||
- 鉴权:`write(F402, A7分包key)`,监听 `F301` 状态码
|
|
||||||
- 当前项目约定:**已知密钥 = 设备 MAC 去分隔符后 + `0000`**,例如 `10:52:1C:81:64:F4` → `10521C8164F40000`
|
|
||||||
- 鉴权成功后进行功能操作:
|
|
||||||
- WiFi 配网:`write(F401, A7分包ssid|pass)`
|
|
||||||
- 控制命令:`write(F403, [cmd])`
|
|
||||||
- 雷达数据:`cmd=0xA1` 开始、`cmd=0xA2` 停止,解析 `F302`
|
|
||||||
|
|
||||||
5. 收尾阶段
|
|
||||||
- 停止扫描 / 断开连接 / 关闭适配器
|
|
||||||
- 清理监听器与状态
|
|
||||||
|
|
||||||
## 2. 目录结构
|
|
||||||
```text
|
|
||||||
utils/ble/
|
|
||||||
core/
|
|
||||||
bleCore.js # 蓝牙核心能力(连接/发现/读写/订阅/重连)
|
|
||||||
controller.js # 标准流程编排(scan->connect->discover->auth)
|
|
||||||
errors.js # 统一异常
|
|
||||||
eventBus.js # 回调分发
|
|
||||||
packet.js # A7 分包工具
|
|
||||||
retry.js # 重试工具
|
|
||||||
state.js # 状态模型
|
|
||||||
modules/
|
|
||||||
auth.js # 鉴权流程(读MSG + 写KEY)
|
|
||||||
wifi.js # WiFi配置流程
|
|
||||||
radar.js # 雷达控制流程
|
|
||||||
parsers/
|
|
||||||
commonParser.js # MSG/STATE/雷达通用解析
|
|
||||||
index.js # 解析聚合出口
|
|
||||||
protocols/
|
|
||||||
profiles.js # ED713/ED719/UNKNOWN profile 差异配置
|
|
||||||
utils/
|
|
||||||
bytes.js # 字节/高低位工具
|
|
||||||
hex.js # hex/utf8 转换
|
|
||||||
uuid.js # UUID 标准化与比较
|
|
||||||
index.js # 统一出口
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 公共方法列表
|
|
||||||
- 蓝牙核心
|
|
||||||
- `createBleCore(options)`
|
|
||||||
- `openAdapter/getAdapterState/closeAdapter`
|
|
||||||
- `startDiscovery/stopDiscovery`
|
|
||||||
- `connect/disconnect`
|
|
||||||
- `discoverServices/discoverCharacteristics`
|
|
||||||
- `readCharacteristic/writeCharacteristic/notifyCharacteristic`
|
|
||||||
- 流程编排
|
|
||||||
- `createUnifiedBleController(options)`
|
|
||||||
- `standardConnectFlow({ deviceId, key })`
|
|
||||||
- `scanAndPickDevice/connectAndDiscover/cleanup`
|
|
||||||
- 业务模块
|
|
||||||
- `createAuthModule(ble).ensureAuthorized(key)`
|
|
||||||
- `createAuthModule(ble).ensureAuthorizedByMac({ suffix: '0000' })`
|
|
||||||
- `createWifiModule(ble).configureWifi(ssid, password)`
|
|
||||||
- `createRadarModule(ble).startRadarStream()/stopRadarStream()/writeFallParams()`
|
|
||||||
- 工具
|
|
||||||
- `buildA7Packets(payload, packetConfig)`
|
|
||||||
- `bytesToHex/hexToBytes/utf8ToBytes/bytesToUtf8`
|
|
||||||
- `splitToLowHigh/joinLowHigh/readUint16LE/readUint32LE`
|
|
||||||
- `normalizeUuid/toFullUuid/uuidEquals`
|
|
||||||
|
|
||||||
## 4. 核心流程代码示例
|
|
||||||
```js
|
|
||||||
import {
|
|
||||||
createUnifiedBleController,
|
|
||||||
DEVICE_PROFILE
|
|
||||||
} from '@/utils/ble'
|
|
||||||
|
|
||||||
const bleController = createUnifiedBleController({
|
|
||||||
profileId: DEVICE_PROFILE.UNKNOWN,
|
|
||||||
reconnect: { enabled: true, retries: 2, delay: 1000 }
|
|
||||||
})
|
|
||||||
|
|
||||||
async function runStandardFlow({ key, ssid, password }) {
|
|
||||||
try {
|
|
||||||
// 1) 连接 + 服务发现 + 自动订阅F301/F302 + 鉴权
|
|
||||||
const session = await bleController.standardConnectFlow({ key })
|
|
||||||
|
|
||||||
// 2) 读取设备信息(如需要可再次读取)
|
|
||||||
const info = await bleController.auth.readDeviceInfo()
|
|
||||||
|
|
||||||
// 3) WiFi 配置
|
|
||||||
const wifiResult = await bleController.wifi.configureWifi(ssid, password)
|
|
||||||
|
|
||||||
// 4) 雷达控制
|
|
||||||
await bleController.radar.startRadarStream()
|
|
||||||
|
|
||||||
return {
|
|
||||||
session,
|
|
||||||
info,
|
|
||||||
wifiResult
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// 页面退出或流程结束时清理
|
|
||||||
await bleController.cleanup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. ED713/ED719 兼容策略
|
|
||||||
- 相同 UUID 与鉴权主流程统一抽象。
|
|
||||||
- 差异通过 `profiles.js` 配置:
|
|
||||||
- `radar.notifySignature`: ED713=`0x7c`,ED719=`0x67`
|
|
||||||
- `cmd.supportsNarrowMode`: ED713=true
|
|
||||||
- `cmd.supportsFallParam67`: ED719=true
|
|
||||||
- 未知设备先用 `UNKNOWN`,收到 `F302` 数据后按签名字节自动识别 profile。
|
|
||||||
|
|
||||||
|
|
||||||
## 6. 修改记录
|
|
||||||
|
|
||||||
### 2026-04-30 对照协议文档第二轮修复
|
|
||||||
|
|
||||||
#### commonParser.js
|
|
||||||
- `parseDeviceInfo(payload, profileId)`: 重写为按 ED713/ED719 分路解析
|
|
||||||
- ED713:MAC[0:6] + bindStatus[6] + 其它[7:10] + productId[10:12] + baseBoard[12] + coreBoard[13] + 其它[14:16] + deviceType[16] + wifiStatus[17] + cellularStatus[18]
|
|
||||||
- ED719:布局与 ED713 一致(bindStatus/productId 偏移相同,文档仅省略标注),复用 `parseEd713DeviceInfo`
|
|
||||||
- `parseEd719Radar`: 修正 `shieldNum` 从 bytes[27] → bytes[24](按文档结构体偏移累加 header[4]+event+people+num+nodata+height+mode+beeper+left+right+front+back+sensitive+nodata1+stateDelay=24)
|
|
||||||
- 新增 `wallOption`、`peopleHaveSwitch`、`peopleActivitySwitch` 字段解析
|
|
||||||
|
|
||||||
#### radar.js
|
|
||||||
- `setNarrowMode(enabled)`: 修正命令格式,从单字节 `0x7C`/`0x7D` 改为两字节 `[0x7C, flag]`,flag 0=关 1=开(符合协议文档 CMD=0x7C + 1byte参数)
|
|
||||||
|
|
||||||
#### auth.js
|
|
||||||
- `readDeviceInfo(options)`: 新增 `msgTimeout` 参数与 4 秒超时保护,防止 UUID_MSG 无回包时页面挂死
|
|
||||||
- `readDeviceInfo()`: 调用 `parseDeviceInfo` 时传入 `ble.getProfile().id`
|
|
||||||
- `ensureAuthorized`/`ensureAuthorizedByMac`: 透传 `options` 到 `readDeviceInfo`
|
|
||||||
- `writeKeyAndExpectStates`: `rejectOnUnexpected` 从 `true` 改为 `false`,避免设备中间态(-1/0)直接 reject 阻断鉴权
|
|
||||||
- `ensureAuthorizedWithDeviceInfo`: 移除 `bindStatus === null` 分支(ED719 实际布局与 ED713 一致,bindStatus 可正常读取)
|
|
||||||
|
|
||||||
#### controller.js
|
|
||||||
- 特征分发器 `createCharacteristicDispatcher` 中 `parseDeviceInfo` 调用传入 `profile.id`
|
|
||||||
|
|
||||||
#### bleCore.js
|
|
||||||
- `closeAdapter()`: 新增重置 `adapterStateHandler`/`connectionStateHandler`/`characteristicValueHandler`/`deviceFoundHandler` 为 `null`,确保 `openAdapter` 后 `init()` 可重新注册所有原生监听
|
|
||||||
|
|
||||||
#### pages/device/config.vue
|
|
||||||
- `openWifiPanel`: `prepareWifiConfig` 超时从 `800000` 改为 `8000`,新增 `msgTimeout: 4000`
|
|
||||||
- `wifiPanelVisible = true` 后加 `await this.$nextTick()` 确保 Vue DOM 更新后再调原生 toast
|
|
||||||
|
|
||||||
#### hooks/useBluetoothDiscovery.js
|
|
||||||
- 新增 `SCAN_DURATION_MS = 30000`,蓝牙搜索 30 秒后自动停止
|
|
||||||
- `startSearch`: 搜索开始前同步设置 `isSearching = true`(避免异步导致无法暂停);先 `stopDiscoverySilently()` 再 `resetDeviceMap()`(新搜索刷新数据);启动 30 秒自动停止定时器
|
|
||||||
- `startSearch`: `stopDiscoverySilently()` 后执行完整适配器重置(`unwatch*` 注销所有原生监听 → `closeAdapter` 关闭适配器 → `initialized = false`),由 `ensureInitialized` 重新打开并注册全部监听,确保每次搜索都从干净适配器状态开始,修复二次搜索找不到设备的问题
|
|
||||||
- `stopSearch`: 移除 `resetDeviceMap()` 调用,暂停搜索时保留已发现设备数据;清除自动停止定时器
|
|
||||||
|
|
||||||
### 2026-04-29 对照协议文档修复
|
|
||||||
|
|
||||||
(初次修复,记录同上,已被 2026-04-30 条目覆盖)
|
|
||||||
|
|
@ -1,335 +1,91 @@
|
||||||
import {
|
import {
|
||||||
BLUETOOTH_ERROR_MESSAGES,
|
BLUETOOTH_ERROR_MESSAGES,
|
||||||
DEFAULT_BLUETOOTH_ERROR_MESSAGE,
|
DEFAULT_BLUETOOTH_ERROR_MESSAGE,
|
||||||
DISCOVERY_FILTER_SERVICE_UUIDS,
|
TARGET_SERVICE_UUID,
|
||||||
TARGET_SERVICE_UUID_FULL,
|
TARGET_SHORT_UUID
|
||||||
TARGET_SERVICE_UUID_ALT_FULL,
|
|
||||||
TARGET_SERVICE_UUID_COMPAT_FULL,
|
|
||||||
TARGET_SHORT_UUID_CANDIDATES
|
|
||||||
} from '@/constants/bluetooth'
|
} from '@/constants/bluetooth'
|
||||||
import {DEVICE_PROFILE, createUnifiedBleController, shortUuid, uuidInList} from '@/utils/ble'
|
|
||||||
import { showToast } from '@/utils/toast'
|
import { showToast } from '@/utils/toast'
|
||||||
|
|
||||||
const FILTER_SCAN_TIMEOUT_MS = 4000
|
|
||||||
const RESTART_SCAN_DELAY_MS = 120
|
|
||||||
const DISCOVERY_POLL_INTERVAL_MS = 1200
|
|
||||||
const SCAN_DURATION_MS = 30000
|
|
||||||
const TARGET_NAME_KEYWORDS = Object.freeze(['ED713', '713WQ', 'ED719', 'EP832'])
|
|
||||||
|
|
||||||
function promisifyUniApi(apiName, params = {}) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
uni[apiName]({
|
|
||||||
...params,
|
|
||||||
success: (res) => resolve(res),
|
|
||||||
fail: (err) => reject(err)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一蓝牙接入层(ED713/ED719):
|
* 蓝牙设备搜索逻辑封装。
|
||||||
* 1) 搜索(过滤、去重、可停止)
|
* 通过回调把状态同步给页面,减少页面文件复杂度。
|
||||||
* 2) 连接 + 服务/特征发现 + 订阅
|
|
||||||
* 3) 鉴权、读取设备信息、WiFi 配置、雷达控制
|
|
||||||
*/
|
*/
|
||||||
export function createBluetoothDiscovery(options = {}) {
|
export function createBluetoothDiscovery(options = {}) {
|
||||||
const {
|
const {
|
||||||
onSearchingChange = () => {
|
onSearchingChange = () => {},
|
||||||
},
|
onDeviceMapChange = () => {},
|
||||||
onDeviceMapChange = () => {
|
onDeviceListChange = () => {}
|
||||||
},
|
|
||||||
onDeviceListChange = () => {
|
|
||||||
},
|
|
||||||
onConnectedChange = () => {
|
|
||||||
},
|
|
||||||
onProfileChange = () => {
|
|
||||||
},
|
|
||||||
onStageChange = () => {
|
|
||||||
},
|
|
||||||
onProtocolState = () => {
|
|
||||||
},
|
|
||||||
onProtocolMsg = () => {
|
|
||||||
},
|
|
||||||
onRadarData = () => {
|
|
||||||
},
|
|
||||||
onDeviceInfoChange = () => {
|
|
||||||
},
|
|
||||||
onAuthChange = () => {
|
|
||||||
},
|
|
||||||
onError = () => {
|
|
||||||
}
|
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const controller = createUnifiedBleController({
|
|
||||||
profileId: DEVICE_PROFILE.UNKNOWN
|
|
||||||
})
|
|
||||||
|
|
||||||
let initialized = false
|
|
||||||
let isSearching = false
|
let isSearching = false
|
||||||
let isConnected = false
|
|
||||||
let deviceMap = {}
|
let deviceMap = {}
|
||||||
|
let deviceFoundHandler = null
|
||||||
let currentDeviceId = ''
|
|
||||||
let currentProfileId = DEVICE_PROFILE.UNKNOWN
|
|
||||||
let lastDeviceInfo = null
|
|
||||||
let lastAuthResult = null
|
|
||||||
|
|
||||||
let searchSessionId = 0
|
|
||||||
let acceptingDeviceEvents = false
|
|
||||||
let filterFallbackTimer = null
|
|
||||||
let discoveryPollTimer = null
|
|
||||||
let autoStopTimer = null
|
|
||||||
|
|
||||||
let offDeviceFound = null
|
|
||||||
let offAdapterState = null
|
|
||||||
let offConnectionChange = null
|
|
||||||
let offStateChange = null
|
|
||||||
let offProtocolState = null
|
|
||||||
let offProtocolMsg = null
|
|
||||||
let offProtocolRadar = null
|
|
||||||
let offError = null
|
|
||||||
|
|
||||||
function setSearching(value) {
|
function setSearching(value) {
|
||||||
isSearching = Boolean(value)
|
isSearching = Boolean(value)
|
||||||
onSearchingChange(isSearching)
|
onSearchingChange(isSearching)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setConnected(value) {
|
|
||||||
isConnected = Boolean(value)
|
|
||||||
onConnectedChange(isConnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSortedDeviceList(mapValue = deviceMap) {
|
|
||||||
return Object.values(mapValue).sort((left, right) => {
|
|
||||||
if (left.hasTargetService !== right.hasTargetService) {
|
|
||||||
return left.hasTargetService ? -1 : 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const leftSignal = typeof left.RSSI === 'number' ? left.RSSI : -999
|
|
||||||
const rightSignal = typeof right.RSSI === 'number' ? right.RSSI : -999
|
|
||||||
return rightSignal - leftSignal
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitDeviceList() {
|
|
||||||
onDeviceListChange(getSortedDeviceList())
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDeviceMap(nextMap) {
|
function setDeviceMap(nextMap) {
|
||||||
deviceMap = nextMap
|
deviceMap = nextMap
|
||||||
onDeviceMapChange({ ...deviceMap })
|
onDeviceMapChange({ ...deviceMap })
|
||||||
emitDeviceList()
|
emitDeviceList()
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetDeviceMap() {
|
function emitDeviceList() {
|
||||||
setDeviceMap({})
|
const list = Object.values(deviceMap).sort((a, b) => {
|
||||||
|
if (a.hasTargetService !== b.hasTargetService) {
|
||||||
|
return a.hasTargetService ? -1 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const aSignal = typeof a.RSSI === 'number' ? a.RSSI : -999
|
||||||
|
const bSignal = typeof b.RSSI === 'number' ? b.RSSI : -999
|
||||||
|
return bSignal - aSignal
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeviceListChange(list)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUuid(uuid) {
|
function normalizeUuid(uuid) {
|
||||||
return String(uuid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
|
return String(uuid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferProfileByName(device) {
|
|
||||||
const text = `${device?.name || ''} ${device?.localName || ''}`.toUpperCase()
|
|
||||||
|
|
||||||
if (text.includes('ED719')) {
|
|
||||||
return DEVICE_PROFILE.ED719
|
|
||||||
}
|
|
||||||
|
|
||||||
if (text.includes('ED713') || text.includes('713WQ')) {
|
|
||||||
return DEVICE_PROFILE.ED713
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEVICE_PROFILE.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从广播包中解析 16-bit service UUID(AD Type 0x02/0x03)。
|
|
||||||
*/
|
|
||||||
function extractShortUuidsFromAdvertisData(advertisData) {
|
|
||||||
if (!advertisData) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = advertisData instanceof Uint8Array ? advertisData : new Uint8Array(advertisData)
|
|
||||||
const shortUuids = []
|
|
||||||
|
|
||||||
let offset = 0
|
|
||||||
while (offset < bytes.length) {
|
|
||||||
const len = bytes[offset]
|
|
||||||
if (!len) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeIndex = offset + 1
|
|
||||||
const dataStart = offset + 2
|
|
||||||
const dataEnd = offset + 1 + len
|
|
||||||
|
|
||||||
if (dataEnd > bytes.length) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = bytes[typeIndex]
|
|
||||||
if (type === 0x02 || type === 0x03) {
|
|
||||||
for (let index = dataStart; index + 1 < dataEnd; index += 2) {
|
|
||||||
const value = ((bytes[index + 1] << 8) | bytes[index]).toString(16).toUpperCase().padStart(4, '0')
|
|
||||||
shortUuids.push(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += len + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return shortUuids
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTargetService(device) {
|
function hasTargetService(device) {
|
||||||
const serviceList = Array.isArray(device?.advertisServiceUUIDs) ? device.advertisServiceUUIDs : []
|
if (!Array.isArray(device.advertisServiceUUIDs)) {
|
||||||
const adShortUuids = extractShortUuidsFromAdvertisData(device?.advertisData)
|
|
||||||
|
|
||||||
if (!serviceList.length && !adShortUuids.length) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const serviceShortList = serviceList.map((item) => shortUuid(item))
|
const targetUuid = normalizeUuid(TARGET_SERVICE_UUID)
|
||||||
|
return device.advertisServiceUUIDs.some((uuid) => {
|
||||||
return uuidInList(TARGET_SERVICE_UUID_COMPAT_FULL, serviceList)
|
const value = normalizeUuid(uuid)
|
||||||
|| uuidInList(TARGET_SERVICE_UUID_FULL, serviceList)
|
return value === targetUuid || value.endsWith(TARGET_SHORT_UUID)
|
||||||
|| uuidInList(TARGET_SERVICE_UUID_ALT_FULL, serviceList)
|
|
||||||
|| TARGET_SHORT_UUID_CANDIDATES.some((uuid) => serviceShortList.includes(uuid))
|
|
||||||
|| TARGET_SHORT_UUID_CANDIDATES.some((uuid) => adShortUuids.includes(uuid))
|
|
||||||
}
|
|
||||||
|
|
||||||
function bufferToPrintableText(buffer) {
|
|
||||||
if (!buffer) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
|
|
||||||
return Array.from(bytes)
|
|
||||||
.map((value) => (value >= 32 && value <= 126 ? String.fromCharCode(value) : ' '))
|
|
||||||
.join(' ')
|
|
||||||
.toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTargetKeywordText(text) {
|
|
||||||
return TARGET_NAME_KEYWORDS.some((keyword) => text.includes(keyword))
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTargetName(device) {
|
|
||||||
const text = `${String(device?.name || '')} ${String(device?.localName || '')}`.toUpperCase()
|
|
||||||
return hasTargetKeywordText(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTargetKeywordInPayload(device) {
|
|
||||||
const adText = bufferToPrintableText(device?.advertisData)
|
|
||||||
const manufacturerText = bufferToPrintableText(device?.manufacturerData)
|
|
||||||
return hasTargetKeywordText(`${adText} ${manufacturerText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchTargetDevice(device) {
|
|
||||||
const byService = hasTargetService(device)
|
|
||||||
const byName = hasTargetName(device)
|
|
||||||
const byPayload = hasTargetKeywordInPayload(device)
|
|
||||||
|
|
||||||
return {
|
|
||||||
isTarget: byService || byName || byPayload,
|
|
||||||
byService,
|
|
||||||
byName,
|
|
||||||
byPayload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTargetDeviceInMap() {
|
|
||||||
return Object.values(deviceMap).some((item) => item.isTargetDevice)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDeviceStableSignature(device = {}) {
|
|
||||||
return [
|
|
||||||
device.deviceId || '',
|
|
||||||
device.RSSI ?? '',
|
|
||||||
device.name || '',
|
|
||||||
device.localName || '',
|
|
||||||
Array.isArray(device.advertisServiceUUIDs)
|
|
||||||
? device.advertisServiceUUIDs.map((item) => normalizeUuid(item)).sort().join('|')
|
|
||||||
: '',
|
|
||||||
device.advertisData ? JSON.stringify(Array.from(new Uint8Array(device.advertisData))) : ''
|
|
||||||
].join('#')
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearFallbackTimer() {
|
|
||||||
if (!filterFallbackTimer) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(filterFallbackTimer)
|
|
||||||
filterFallbackTimer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearDiscoveryPollTimer() {
|
|
||||||
if (!discoveryPollTimer) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(discoveryPollTimer)
|
|
||||||
discoveryPollTimer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAutoStopTimer() {
|
|
||||||
if (!autoStopTimer) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(autoStopTimer)
|
|
||||||
autoStopTimer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function delay(ms) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, ms)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureAndroidLocationPermission() {
|
function openBluetoothAdapter() {
|
||||||
let systemInfo = {}
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.openBluetoothAdapter({
|
||||||
try {
|
|
||||||
systemInfo = uni.getSystemInfoSync() || {}
|
|
||||||
} catch (error) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const platform = String(systemInfo.platform || '').toLowerCase()
|
|
||||||
if (platform !== 'android') {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const settings = await new Promise((resolve, reject) => {
|
|
||||||
uni.getSetting({
|
|
||||||
success: resolve,
|
success: resolve,
|
||||||
fail: reject
|
fail: reject
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const locationSetting = settings?.authSetting?.['scope.userLocation']
|
|
||||||
if (locationSetting === true) {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
function startDiscoveryWithServices(services = []) {
|
||||||
uni.authorize({
|
return new Promise((resolve, reject) => {
|
||||||
scope: 'scope.userLocation',
|
const options = {
|
||||||
|
allowDuplicatesKey: true,
|
||||||
|
interval: 0,
|
||||||
success: resolve,
|
success: resolve,
|
||||||
fail: reject
|
fail: reject
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
showToast('Android建议开启定位权限/定位开关(已继续尝试搜索)')
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (services.length) {
|
||||||
|
options.services = services
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.startBluetoothDevicesDiscovery(options)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertDevice(rawDevice) {
|
function upsertDevice(rawDevice) {
|
||||||
|
|
@ -337,28 +93,12 @@ export function createBluetoothDiscovery(options = {}) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const matched = matchTargetDevice(rawDevice)
|
|
||||||
if (!matched.isTarget) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = deviceMap[rawDevice.deviceId] || {}
|
const existing = deviceMap[rawDevice.deviceId] || {}
|
||||||
const merged = {
|
const merged = {
|
||||||
...existing,
|
...existing,
|
||||||
...rawDevice,
|
...rawDevice,
|
||||||
showName: rawDevice.name || rawDevice.localName || existing.showName || '未命名设备',
|
showName: rawDevice.name || rawDevice.localName || existing.showName || '未命名设备',
|
||||||
isTargetDevice: true,
|
hasTargetService: hasTargetService(rawDevice) || existing.hasTargetService
|
||||||
hasTargetService: matched.byService,
|
|
||||||
matchedByName: matched.byName,
|
|
||||||
matchedByPayload: matched.byPayload,
|
|
||||||
profileHint: inferProfileByName(rawDevice)
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevSignature = getDeviceStableSignature(existing)
|
|
||||||
const nextSignature = getDeviceStableSignature(merged)
|
|
||||||
|
|
||||||
if (prevSignature === nextSignature) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeviceMap({
|
setDeviceMap({
|
||||||
|
|
@ -367,556 +107,80 @@ export function createBluetoothDiscovery(options = {}) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBluetoothErrorText(error) {
|
function registerDeviceFoundHandler() {
|
||||||
const errCode = error?.errCode ?? error?.code
|
if (deviceFoundHandler) {
|
||||||
const text = BLUETOOTH_ERROR_MESSAGES[errCode] || DEFAULT_BLUETOOTH_ERROR_MESSAGE
|
|
||||||
|
|
||||||
if (errCode === undefined || errCode === null) {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${text}(${errCode})`
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindControllerEvents() {
|
|
||||||
if (offStateChange) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
offStateChange = controller.ble.on('state:change', (state) => {
|
deviceFoundHandler = (res) => {
|
||||||
// console.debug('state:change', state)
|
const devices = Array.isArray(res.devices) ? res.devices : [res]
|
||||||
onStageChange(state.stage)
|
|
||||||
|
|
||||||
if (state.profileId && state.profileId !== currentProfileId) {
|
|
||||||
currentProfileId = state.profileId
|
|
||||||
onProfileChange(currentProfileId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
offDeviceFound = controller.ble.on('device:found', (devices) => {
|
|
||||||
if (!acceptingDeviceEvents) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSession = searchSessionId
|
|
||||||
devices.forEach((device) => {
|
devices.forEach((device) => {
|
||||||
if (!acceptingDeviceEvents || currentSession !== searchSessionId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
upsertDevice(device)
|
upsertDevice(device)
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
offAdapterState = controller.ble.on('adapter:state', (state) => {
|
|
||||||
if (state?.available !== false) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
acceptingDeviceEvents = false
|
uni.onBluetoothDeviceFound(deviceFoundHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSearch(showStopToast = true) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
uni.stopBluetoothDevicesDiscovery({
|
||||||
|
complete: () => {
|
||||||
setSearching(false)
|
setSearching(false)
|
||||||
setConnected(false)
|
if (showStopToast) {
|
||||||
showToast('蓝牙已关闭,请先开启手机蓝牙')
|
showToast('已停止搜索')
|
||||||
})
|
}
|
||||||
|
resolve()
|
||||||
offConnectionChange = controller.ble.on('connection:change', (payload) => {
|
|
||||||
setConnected(Boolean(payload?.connected))
|
|
||||||
|
|
||||||
if (!payload?.connected) {
|
|
||||||
currentDeviceId = ''
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
offProtocolState = controller.ble.on('protocol:state', (payload) => {
|
|
||||||
try {
|
|
||||||
console.debug('[BLE][STATE]', 'code=', payload?.stateCode, 'char=', payload?.characteristicId)
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
onProtocolState(payload)
|
|
||||||
})
|
|
||||||
|
|
||||||
offProtocolMsg = controller.ble.on('protocol:msg', (payload) => {
|
|
||||||
try {
|
|
||||||
console.debug('[BLE][MSG]', 'len=', payload || 0)
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
onProtocolMsg(payload)
|
|
||||||
if (payload?.deviceInfo) {
|
|
||||||
lastDeviceInfo = payload.deviceInfo
|
|
||||||
onDeviceInfoChange(lastDeviceInfo)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
offProtocolRadar = controller.ble.on('protocol:radar', (payload) => {
|
|
||||||
const parsed = controller.radar.parseNotifyPayload(payload.value)
|
|
||||||
onRadarData({
|
|
||||||
...payload,
|
|
||||||
parsed
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
offError = controller.ble.on('error', (error) => {
|
|
||||||
try {
|
|
||||||
console.debug('[BLE][ERR]', error?.message || error?.errMsg || String(error))
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
onError(error)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function unbindControllerEvents() {
|
function cleanupDiscovery() {
|
||||||
if (offDeviceFound) {
|
|
||||||
offDeviceFound()
|
|
||||||
offDeviceFound = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offAdapterState) {
|
|
||||||
offAdapterState()
|
|
||||||
offAdapterState = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offConnectionChange) {
|
|
||||||
offConnectionChange()
|
|
||||||
offConnectionChange = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offStateChange) {
|
|
||||||
offStateChange()
|
|
||||||
offStateChange = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offProtocolState) {
|
|
||||||
offProtocolState()
|
|
||||||
offProtocolState = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offProtocolMsg) {
|
|
||||||
offProtocolMsg()
|
|
||||||
offProtocolMsg = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offProtocolRadar) {
|
|
||||||
offProtocolRadar()
|
|
||||||
offProtocolRadar = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offError) {
|
|
||||||
offError()
|
|
||||||
offError = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureInitialized() {
|
|
||||||
if (initialized) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await controller.init()
|
|
||||||
controller.ble.watchDeviceFound()
|
|
||||||
|
|
||||||
bindControllerEvents()
|
|
||||||
initialized = true
|
|
||||||
|
|
||||||
currentProfileId = controller.ble.getProfile().id
|
|
||||||
onProfileChange(currentProfileId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startDiscoveryWithServices(services = []) {
|
|
||||||
await controller.ble.startDiscovery({
|
|
||||||
services,
|
|
||||||
allowDuplicatesKey: true,
|
|
||||||
interval: 0,
|
|
||||||
// 兼容低版本客户端:不强制传 powerLevel。
|
|
||||||
powerLevel: null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopDiscoverySilently() {
|
|
||||||
try {
|
|
||||||
await controller.ble.stopDiscovery()
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pullDiscoveredDevicesOnce(sessionId) {
|
|
||||||
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await promisifyUniApi('getBluetoothDevices')
|
|
||||||
const list = Array.isArray(result?.devices) ? result.devices : []
|
|
||||||
list.forEach((device) => {
|
|
||||||
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
upsertDevice(device)
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startDiscoveryPolling(sessionId) {
|
|
||||||
clearDiscoveryPollTimer()
|
|
||||||
|
|
||||||
const run = async () => {
|
|
||||||
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await pullDiscoveredDevicesOnce(sessionId)
|
|
||||||
|
|
||||||
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
discoveryPollTimer = setTimeout(run, DISCOVERY_POLL_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
discoveryPollTimer = setTimeout(run, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fallbackToUnfilteredDiscoveryIfNeeded(sessionId) {
|
|
||||||
clearFallbackTimer()
|
|
||||||
|
|
||||||
filterFallbackTimer = setTimeout(async () => {
|
|
||||||
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId || hasTargetDeviceInMap()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await stopDiscoverySilently()
|
|
||||||
await delay(RESTART_SCAN_DELAY_MS)
|
|
||||||
|
|
||||||
if (!isSearching || !acceptingDeviceEvents || sessionId !== searchSessionId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await startDiscoveryWithServices([])
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}, FILTER_SCAN_TIMEOUT_MS)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备搜索:
|
|
||||||
* 优先按 0xFFF4/0x00F4/0xF400 过滤;若无结果自动切到兜底扫描并轮询 getBluetoothDevices。
|
|
||||||
*/
|
|
||||||
async function startSearch() {
|
|
||||||
if (isSearching) {
|
if (isSearching) {
|
||||||
return
|
uni.stopBluetoothDevicesDiscovery({
|
||||||
|
complete: () => {}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 立即标记搜索中,防止 await 期间用户重复点击或 stopSearch 被覆盖
|
setSearching(false)
|
||||||
isSearching = true
|
|
||||||
onSearchingChange(true)
|
|
||||||
clearFallbackTimer()
|
|
||||||
clearDiscoveryPollTimer()
|
|
||||||
clearAutoStopTimer()
|
|
||||||
|
|
||||||
// 重置 BLE 适配器:关闭并重开,确保每次搜索都是干净的初始状态
|
if (deviceFoundHandler && uni.offBluetoothDeviceFound) {
|
||||||
if (initialized) {
|
uni.offBluetoothDeviceFound(deviceFoundHandler)
|
||||||
await stopDiscoverySilently()
|
|
||||||
unbindControllerEvents()
|
|
||||||
await controller.ble.closeAdapter()
|
|
||||||
await delay(RESTART_SCAN_DELAY_MS)
|
|
||||||
initialized = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
searchSessionId += 1
|
deviceFoundHandler = null
|
||||||
const sessionId = searchSessionId
|
}
|
||||||
|
|
||||||
resetDeviceMap()
|
function getBluetoothErrorText(error) {
|
||||||
|
const errCode = error && error.errCode
|
||||||
|
return BLUETOOTH_ERROR_MESSAGES[errCode] || DEFAULT_BLUETOOTH_ERROR_MESSAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSearch() {
|
||||||
|
setDeviceMap({})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ensureAndroidLocationPermission()
|
await openBluetoothAdapter()
|
||||||
await ensureInitialized()
|
registerDeviceFoundHandler()
|
||||||
|
|
||||||
const adapterState = await controller.ble.getAdapterState()
|
|
||||||
if (adapterState?.available === false) {
|
|
||||||
throw {errCode: 10001}
|
|
||||||
}
|
|
||||||
|
|
||||||
acceptingDeviceEvents = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await startDiscoveryWithServices(DISCOVERY_FILTER_SERVICE_UUIDS)
|
await startDiscoveryWithServices([TARGET_SERVICE_UUID])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await startDiscoveryWithServices([])
|
await startDiscoveryWithServices([])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionId !== searchSessionId) {
|
setSearching(true)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
startDiscoveryPolling(sessionId)
|
|
||||||
await fallbackToUnfilteredDiscoveryIfNeeded(sessionId)
|
|
||||||
|
|
||||||
// 30s 自动停止搜索
|
|
||||||
autoStopTimer = setTimeout(() => {
|
|
||||||
if (searchSessionId === sessionId) {
|
|
||||||
stopSearch(false)
|
|
||||||
}
|
|
||||||
}, SCAN_DURATION_MS)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
acceptingDeviceEvents = false
|
setSearching(false)
|
||||||
isSearching = false
|
|
||||||
onSearchingChange(false)
|
|
||||||
showToast(getBluetoothErrorText(error))
|
showToast(getBluetoothErrorText(error))
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopSearch(showStopToast = true) {
|
|
||||||
clearFallbackTimer()
|
|
||||||
clearDiscoveryPollTimer()
|
|
||||||
clearAutoStopTimer()
|
|
||||||
|
|
||||||
searchSessionId += 1
|
|
||||||
acceptingDeviceEvents = false
|
|
||||||
|
|
||||||
await stopDiscoverySilently()
|
|
||||||
|
|
||||||
// 暂停搜索保留已发现的设备数据,不做 resetDeviceMap
|
|
||||||
setSearching(false)
|
|
||||||
if (showStopToast) {
|
|
||||||
showToast('已停止搜索')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickPreferredDeviceId(deviceId) {
|
|
||||||
if (deviceId) {
|
|
||||||
return deviceId
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = getSortedDeviceList()
|
|
||||||
return list[0]?.deviceId || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyProfileHintByDevice(deviceId) {
|
|
||||||
if (!deviceId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const hint = deviceMap[deviceId]?.profileHint
|
|
||||||
if (!hint || hint === DEVICE_PROFILE.UNKNOWN) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentProfile = controller.ble.getProfile().id
|
|
||||||
if (currentProfile === DEVICE_PROFILE.UNKNOWN) {
|
|
||||||
controller.ble.setProfile(hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 仅连接与发现阶段:连接 -> 发现服务0x00F4 -> 发现特征 -> 订阅 F301/F302。
|
|
||||||
*/
|
|
||||||
async function connectDevice(deviceId) {
|
|
||||||
await ensureInitialized()
|
|
||||||
|
|
||||||
await stopSearch(false)
|
|
||||||
|
|
||||||
const targetDeviceId = pickPreferredDeviceId(deviceId)
|
|
||||||
if (!targetDeviceId) {
|
|
||||||
throw new Error('未找到可连接设备')
|
|
||||||
}
|
|
||||||
|
|
||||||
applyProfileHintByDevice(targetDeviceId)
|
|
||||||
|
|
||||||
const characteristics = await controller.connectAndDiscover(targetDeviceId)
|
|
||||||
currentDeviceId = targetDeviceId
|
|
||||||
setConnected(true)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deviceId: targetDeviceId,
|
|
||||||
characteristics,
|
|
||||||
profile: controller.ble.getProfile(),
|
|
||||||
state: controller.ble.getState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 鉴权流程:严格遵循协议绑定逻辑(读取 UUID_MSG 的 bindStatus 后写 KEY)。
|
|
||||||
*/
|
|
||||||
async function authorizeDevice(key, options = {}) {
|
|
||||||
if (!currentDeviceId) {
|
|
||||||
throw new Error('请先连接设备后再鉴权')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await controller.auth.ensureAuthorized(key, options)
|
|
||||||
lastAuthResult = result
|
|
||||||
onAuthChange(result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MAC固定规则鉴权:密钥=MAC(去分隔符)+0000。
|
|
||||||
*/
|
|
||||||
async function authorizeDeviceByMac(options = {}) {
|
|
||||||
if (!currentDeviceId) {
|
|
||||||
throw new Error('请先连接设备后再鉴权')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await controller.auth.ensureAuthorizedByMac(options)
|
|
||||||
lastAuthResult = result
|
|
||||||
onAuthChange(result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 标准连接流程:连接 -> 发现 -> 订阅 -> 鉴权。
|
|
||||||
*/
|
|
||||||
async function standardConnectFlow(params = {}) {
|
|
||||||
await ensureInitialized()
|
|
||||||
|
|
||||||
await stopSearch(false)
|
|
||||||
|
|
||||||
const targetDeviceId = pickPreferredDeviceId(params.deviceId)
|
|
||||||
if (!targetDeviceId) {
|
|
||||||
throw new Error('未找到可连接设备')
|
|
||||||
}
|
|
||||||
|
|
||||||
applyProfileHintByDevice(targetDeviceId)
|
|
||||||
|
|
||||||
const result = await controller.standardConnectFlow({
|
|
||||||
...params,
|
|
||||||
deviceId: targetDeviceId
|
|
||||||
})
|
|
||||||
|
|
||||||
currentDeviceId = result.deviceId
|
|
||||||
setConnected(true)
|
|
||||||
|
|
||||||
lastAuthResult = result.authResult
|
|
||||||
onAuthChange(result.authResult)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WiFi配置专用流程:连接 -> 发现 -> 订阅 -> 按 MAC+0000 完成鉴权。
|
|
||||||
*/
|
|
||||||
async function prepareWifiConfig(deviceId, options = {}) {
|
|
||||||
await connectDevice(deviceId)
|
|
||||||
|
|
||||||
// 厂家确认:密钥流程已不启用,写 KEY 后设备不回 STATE,鉴权必然超时。
|
|
||||||
// 这里尽力尝试鉴权,失败则忽略并继续,由 WiFi 配网直接走(写 F401 后等 STATE=3)。
|
|
||||||
try {
|
|
||||||
return await authorizeDeviceByMac(options)
|
|
||||||
} catch (error) {
|
|
||||||
console.debug('[BLE][AUTH]', '鉴权失败已忽略(密钥流程已废弃)', error?.message || error)
|
|
||||||
return { mode: 'AUTH_SKIPPED', error: error?.message || String(error) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readDeviceInfo() {
|
|
||||||
const info = await controller.auth.readDeviceInfo()
|
|
||||||
lastDeviceInfo = info
|
|
||||||
onDeviceInfoChange(info)
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
async function configureWifi(ssid, password, options = {}) {
|
|
||||||
const profileId = controller.ble.getProfile().id
|
|
||||||
const preDelayMs = options.preDelayMs ?? (profileId === DEVICE_PROFILE.ED713 ? 120 : 0)
|
|
||||||
try {
|
|
||||||
console.debug('[BLE][WiFi]', 'configureWifi called', {profileId, preDelayMs})
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
return controller.wifi.configureWifi(ssid, password, {...options, preDelayMs})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startRadar() {
|
|
||||||
return controller.radar.startRadarStream()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopRadar() {
|
|
||||||
return controller.radar.stopRadarStream()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setNarrowMode(enabled) {
|
|
||||||
return controller.radar.setNarrowMode(Boolean(enabled))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeFallParams(rawParams) {
|
|
||||||
return controller.radar.writeFallParams(rawParams)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendRadarCommand(commandByte) {
|
|
||||||
return controller.radar.sendCommand(commandByte)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disconnectDevice() {
|
|
||||||
await controller.ble.disconnect(currentDeviceId)
|
|
||||||
currentDeviceId = ''
|
|
||||||
setConnected(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getState() {
|
|
||||||
const bleState = controller.ble.getState()
|
|
||||||
|
|
||||||
return {
|
|
||||||
isSearching,
|
|
||||||
isConnected,
|
|
||||||
currentDeviceId,
|
|
||||||
currentProfileId: controller.ble.getProfile().id,
|
|
||||||
deviceMap: {...deviceMap},
|
|
||||||
deviceList: getSortedDeviceList(),
|
|
||||||
lastDeviceInfo,
|
|
||||||
lastAuthResult,
|
|
||||||
bleState
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupDiscovery() {
|
|
||||||
clearFallbackTimer()
|
|
||||||
clearDiscoveryPollTimer()
|
|
||||||
|
|
||||||
searchSessionId += 1
|
|
||||||
acceptingDeviceEvents = false
|
|
||||||
setSearching(false)
|
|
||||||
setConnected(false)
|
|
||||||
|
|
||||||
currentDeviceId = ''
|
|
||||||
lastAuthResult = null
|
|
||||||
lastDeviceInfo = null
|
|
||||||
|
|
||||||
unbindControllerEvents()
|
|
||||||
|
|
||||||
if (!initialized) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
initialized = false
|
|
||||||
controller.cleanup().catch(() => {
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 搜索能力(兼容原页面入口)。
|
|
||||||
startSearch,
|
startSearch,
|
||||||
stopSearch,
|
stopSearch,
|
||||||
cleanupDiscovery,
|
cleanupDiscovery
|
||||||
|
|
||||||
// 连接与协议流程能力。
|
|
||||||
connectDevice,
|
|
||||||
authorizeDevice,
|
|
||||||
authorizeDeviceByMac,
|
|
||||||
standardConnectFlow,
|
|
||||||
prepareWifiConfig,
|
|
||||||
disconnectDevice,
|
|
||||||
|
|
||||||
// 协议业务能力。
|
|
||||||
readDeviceInfo,
|
|
||||||
configureWifi,
|
|
||||||
startRadar,
|
|
||||||
stopRadar,
|
|
||||||
setNarrowMode,
|
|
||||||
writeFallParams,
|
|
||||||
sendRadarCommand,
|
|
||||||
|
|
||||||
// 调试/状态读取。
|
|
||||||
getState
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
main.js
7
main.js
|
|
@ -1,7 +1,6 @@
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { setupAuthInterceptors } from './utils/auth'
|
import { setupAuthInterceptors } from './utils/auth'
|
||||||
|
|
||||||
// 应用启动时注册统一路由鉴权拦截器(内部有幂等保护)。
|
|
||||||
setupAuthInterceptors()
|
setupAuthInterceptors()
|
||||||
|
|
||||||
// #ifndef VUE3
|
// #ifndef VUE3
|
||||||
|
|
@ -17,14 +16,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,7 @@
|
||||||
"setting" : {
|
"setting" : {
|
||||||
"urlCheck" : false
|
"urlCheck" : false
|
||||||
},
|
},
|
||||||
"usingComponents" : true,
|
"usingComponents" : true
|
||||||
"libVersion" : "latest"
|
|
||||||
},
|
},
|
||||||
"mp-alipay" : {
|
"mp-alipay" : {
|
||||||
"usingComponents" : true
|
"usingComponents" : true
|
||||||
|
|
|
||||||
|
|
@ -1,435 +0,0 @@
|
||||||
{
|
|
||||||
"name": "smarthome",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"dependencies": {
|
|
||||||
"sass": "^1.99.0",
|
|
||||||
"uview-plus": "^3.7.36"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.3",
|
|
||||||
"is-glob": "^4.0.3",
|
|
||||||
"node-addon-api": "^7.0.0",
|
|
||||||
"picomatch": "^4.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@parcel/watcher-android-arm64": "2.5.6",
|
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.6",
|
|
||||||
"@parcel/watcher-darwin-x64": "2.5.6",
|
|
||||||
"@parcel/watcher-freebsd-x64": "2.5.6",
|
|
||||||
"@parcel/watcher-linux-arm-glibc": "2.5.6",
|
|
||||||
"@parcel/watcher-linux-arm-musl": "2.5.6",
|
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
|
|
||||||
"@parcel/watcher-linux-arm64-musl": "2.5.6",
|
|
||||||
"@parcel/watcher-linux-x64-glibc": "2.5.6",
|
|
||||||
"@parcel/watcher-linux-x64-musl": "2.5.6",
|
|
||||||
"@parcel/watcher-win32-arm64": "2.5.6",
|
|
||||||
"@parcel/watcher-win32-ia32": "2.5.6",
|
|
||||||
"@parcel/watcher-win32-x64": "2.5.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-android-arm64": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-darwin-arm64": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-darwin-x64": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-freebsd-x64": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-linux-arm-musl": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-linux-x64-musl": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-win32-arm64": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-win32-ia32": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@parcel/watcher-win32-x64": {
|
|
||||||
"version": "2.5.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
|
|
||||||
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/parcel"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chokidar": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"readdirp": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.16.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/immutable": {
|
|
||||||
"version": "5.1.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
|
||||||
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/is-extglob": {
|
|
||||||
"version": "2.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-glob": {
|
|
||||||
"version": "4.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"is-extglob": "^2.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-addon-api": {
|
|
||||||
"version": "7.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
|
||||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/picomatch": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readdirp": {
|
|
||||||
"version": "4.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
|
||||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.18.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sass": {
|
|
||||||
"version": "1.99.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz",
|
|
||||||
"integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chokidar": "^4.0.0",
|
|
||||||
"immutable": "^5.1.5",
|
|
||||||
"source-map-js": ">=0.6.2 <2.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"sass": "sass.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@parcel/watcher": "^2.4.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/source-map-js": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/uview-plus": {
|
|
||||||
"version": "3.7.36",
|
|
||||||
"resolved": "https://registry.npmjs.org/uview-plus/-/uview-plus-3.7.36.tgz",
|
|
||||||
"integrity": "sha512-44pW0oKKatYxCHhXPljaNv5E+CESSJlhsz0FRZRNpMv1ms02m5Agk+CNpgDnv/XNHt9YxHdYSYerN+xnNP5orw==",
|
|
||||||
"engines": {
|
|
||||||
"HBuilderX": "^3.1.0",
|
|
||||||
"uni-app": "^4.66",
|
|
||||||
"uni-app-x": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"sass": "^1.99.0",
|
|
||||||
"uview-plus": "^3.7.36"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -63,12 +63,5 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"uniIdRouter": {},
|
"uniIdRouter": {}
|
||||||
"easycom": {
|
|
||||||
"autoscan": true,
|
|
||||||
"custom": {
|
|
||||||
"^u-(.*)": "uview-plus/components/u-$1/u-$1.vue",
|
|
||||||
"^up-(.*)": "uview-plus/components/u-$1/u-$1.vue"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
<!-- 设备添加页:用于承接后续蓝牙搜索/连接配网流程 -->
|
|
||||||
<template>
|
<template>
|
||||||
<view class="page page-base">
|
<view class="page page-base">
|
||||||
<view class="card card-base">
|
<view class="card card-base">
|
||||||
|
|
@ -9,7 +8,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 当前仅提供页面壳,实际流程由蓝牙控制模块接入。
|
|
||||||
export default {}
|
export default {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,122 +27,22 @@
|
||||||
<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" :class="{ disabled: isDevicePreparing(device.deviceId) }"
|
<view class="action-btn">网络配置</view>
|
||||||
@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() {
|
||||||
|
|
@ -150,40 +50,17 @@ 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) => {
|
||||||
|
|
@ -204,76 +81,11 @@ 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()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -296,229 +108,6 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -684,11 +273,6 @@ 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;
|
||||||
|
|
@ -711,169 +295,4 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,46 @@
|
||||||
<!-- 事件页:使用 uview-plus 筛选与空状态组件 -->
|
|
||||||
<template>
|
<template>
|
||||||
<view class="page page-base">
|
<view class="page page-base">
|
||||||
<up-navbar title="事件" :fixed="false" :safe-area-inset-top="true" :border="false" left-icon="" />
|
<view class="top-nav top-nav-base">
|
||||||
|
<view class="title top-nav-title-base">事件</view>
|
||||||
<view class="filters">
|
|
||||||
<view><up-button size="mini" type="primary" plain text="全部场所" /></view>
|
|
||||||
<view><up-button size="mini" type="primary" plain text="全部状态" /></view>
|
|
||||||
<view><up-button size="mini" type="primary" plain text="时间" /></view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="empty-area">
|
<view class="filters">
|
||||||
<up-empty mode="message" text="暂无事件数据" />
|
<view class="filter-item">全部场所</view>
|
||||||
|
<view class="filter-item">全部状态</view>
|
||||||
|
<view class="filter-item">时间</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="empty-area empty-state">
|
||||||
|
<view class="empty-box empty-state-box"></view>
|
||||||
|
<text class="empty-text empty-state-text">暂无数据</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 当前为静态页占位,不包含交互逻辑。
|
|
||||||
export default {}
|
export default {}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style>
|
||||||
@import '@/styles/common.css';
|
@import '@/styles/common.css';
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
> view + view {
|
height: 92rpx;
|
||||||
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: 880rpx;
|
min-height: 980rpx;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<!-- 首页:切换为 uview-plus 组件化结构,保留原有设备添加与配置入口 -->
|
|
||||||
<template>
|
<template>
|
||||||
<view class="page page-base">
|
<view class="page page-base">
|
||||||
<up-navbar title="智慧养老" :fixed="false" :safe-area-inset-top="true" :border="false" left-icon="" />
|
<view class="top-nav top-nav-base">
|
||||||
|
<view class="title">智慧养老</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="banner">
|
<view class="banner">
|
||||||
<view class="banner-left">
|
<view class="banner-left">
|
||||||
|
|
@ -9,33 +10,22 @@
|
||||||
<text class="banner-sub">一键开启智慧好生活</text>
|
<text class="banner-sub">一键开启智慧好生活</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="banner-illus">
|
<view class="banner-illus">
|
||||||
<up-icon name="home" color="#ffffff" size="52" />
|
<view class="phone"></view>
|
||||||
</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">
|
||||||
<view class="site-left">
|
<text class="menu">☰</text>
|
||||||
<up-icon name="list" size="18" color="#26c996" />
|
|
||||||
<text class="site-text">请选择场所</text>
|
<text class="site-text">请选择场所</text>
|
||||||
</view>
|
<text class="down">⌄</text>
|
||||||
<up-icon name="arrow-right" size="14" color="#26c996" />
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="empty-area">
|
<view class="empty-area empty-state">
|
||||||
<up-empty mode="list" text="暂无数据" />
|
<view class="empty-box empty-state-box"></view>
|
||||||
|
<text class="empty-text empty-state-text">暂无数据</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="fab-wrap">
|
<view class="fab" @click="goAdd">+</view>
|
||||||
<up-button type="success" shape="circle" text="添加设备" @click="goAdd" />
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -44,17 +34,10 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -62,19 +45,25 @@ export default {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style>
|
||||||
@import '@/styles/common.css';
|
@import '@/styles/common.css';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-bottom: 180rpx;
|
padding-bottom: 160rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #222;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner {
|
.banner {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 360rpx;
|
height: 380rpx;
|
||||||
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
|
background: #28cda0;
|
||||||
padding: 48rpx 44rpx;
|
padding: 56rpx 52rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -86,61 +75,75 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-title {
|
.banner-title {
|
||||||
font-size: 52rpx;
|
font-size: 58rpx;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 20rpx;
|
margin-bottom: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-sub {
|
.banner-sub {
|
||||||
font-size: 34rpx;
|
font-size: 46rpx;
|
||||||
color: #e9fff8;
|
color: #e9fff8;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-illus {
|
.banner-illus {
|
||||||
width: 180rpx;
|
width: 220rpx;
|
||||||
height: 180rpx;
|
height: 220rpx;
|
||||||
border-radius: 24rpx;
|
border-radius: 16rpx;
|
||||||
background: rgba(255, 255, 255, 0.18);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
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 32rpx;
|
padding: 0 36rpx;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
font-size: 46rpx;
|
||||||
|
color: #222;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-left {
|
.menu {
|
||||||
display: flex;
|
margin-right: 22rpx;
|
||||||
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: 680rpx;
|
min-height: 760rpx;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fab-wrap {
|
.fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 32rpx;
|
right: 38rpx;
|
||||||
bottom: 220rpx;
|
bottom: 220rpx;
|
||||||
width: 240rpx;
|
width: 118rpx;
|
||||||
|
height: 118rpx;
|
||||||
|
border-radius: 59rpx;
|
||||||
|
background: #2cc998;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 88rpx;
|
||||||
|
line-height: 110rpx;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 12rpx 24rpx rgba(29, 177, 130, 0.3);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -6,47 +6,31 @@
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="card">
|
<view class="card">
|
||||||
<up-form :model="form" label-position="top" :border-bottom="false">
|
<view class="form-row">
|
||||||
<up-form-item label="手机号" :required="true">
|
|
||||||
<view class="mobile-wrap">
|
|
||||||
<text class="prefix">+86</text>
|
<text class="prefix">+86</text>
|
||||||
<up-input
|
<input
|
||||||
v-model="form.mobile"
|
v-model="mobile"
|
||||||
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
maxlength="11"
|
maxlength="11"
|
||||||
clearable
|
|
||||||
placeholder="请输入手机号"
|
placeholder="请输入手机号"
|
||||||
border="none"
|
|
||||||
:custom-style="inputInnerStyle"
|
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
</up-form-item>
|
|
||||||
|
|
||||||
<up-form-item label="验证码" :required="true">
|
<view class="form-row code-row">
|
||||||
<view class="code-wrap">
|
<input
|
||||||
<up-input
|
v-model="code"
|
||||||
v-model="form.code"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
clearable
|
|
||||||
placeholder="请输入验证码"
|
placeholder="请输入验证码"
|
||||||
border="none"
|
|
||||||
:custom-style="inputInnerStyle"
|
|
||||||
/>
|
|
||||||
<up-button
|
|
||||||
class="code-btn"
|
|
||||||
size="mini"
|
|
||||||
type="primary"
|
|
||||||
:plain="countdown > 0"
|
|
||||||
:disabled="countdown > 0"
|
|
||||||
:text="codeBtnText"
|
|
||||||
@click="sendCode"
|
|
||||||
/>
|
/>
|
||||||
|
<button class="code-btn" :disabled="countdown > 0" @click="sendCode">
|
||||||
|
{{ codeBtnText }}
|
||||||
|
</button>
|
||||||
</view>
|
</view>
|
||||||
</up-form-item>
|
|
||||||
</up-form>
|
|
||||||
|
|
||||||
<up-button class="login-btn" type="success" shape="circle" text="手机号验证码登录" @click="loginByMobileSubmit" />
|
<button class="login-btn" @click="loginByMobileSubmit">手机号验证码登录</button>
|
||||||
|
|
||||||
<view class="split">
|
<view class="split">
|
||||||
<view class="line"></view>
|
<view class="line"></view>
|
||||||
|
|
@ -54,7 +38,7 @@
|
||||||
<view class="line"></view>
|
<view class="line"></view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<up-button class="wx-btn" type="success" shape="circle" text="微信一键登录" @click="loginByWechatSubmit" />
|
<button class="wx-btn" @click="loginByWechatSubmit">微信一键登录</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -69,16 +53,11 @@ import { isValidMobile, isValidSmsCode } from '@/utils/validators'
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
form: {
|
|
||||||
mobile: '',
|
mobile: '',
|
||||||
code: ''
|
code: '',
|
||||||
},
|
|
||||||
countdown: 0,
|
countdown: 0,
|
||||||
redirect: '',
|
redirect: '',
|
||||||
countdownController: null,
|
countdownController: null
|
||||||
inputInnerStyle: {
|
|
||||||
padding: '0'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -114,7 +93,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
validateMobileOrToast() {
|
validateMobileOrToast() {
|
||||||
if (isValidMobile(this.form.mobile)) {
|
if (isValidMobile(this.mobile)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,7 +119,7 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendSmsCode(this.form.mobile)
|
await sendSmsCode(this.mobile)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +133,7 @@ export default {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidSmsCode(this.form.code)) {
|
if (!isValidSmsCode(this.code)) {
|
||||||
showToast('请输入6位验证码')
|
showToast('请输入6位验证码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -164,12 +143,12 @@ export default {
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await loginByMobile(this.form.mobile, this.form.code)
|
const data = await loginByMobile(this.mobile, this.code)
|
||||||
this.finishLogin({
|
this.finishLogin({
|
||||||
token: data?.token,
|
token: data?.token,
|
||||||
user: data?.user || {
|
user: data?.user || {
|
||||||
name: `用户${this.form.mobile.slice(-4)}`,
|
name: `用户${this.mobile.slice(-4)}`,
|
||||||
mobile: this.form.mobile,
|
mobile: this.mobile,
|
||||||
loginType: 'mobile'
|
loginType: 'mobile'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -235,7 +214,7 @@ export default {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style>
|
||||||
.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%);
|
||||||
|
|
@ -264,27 +243,22 @@ export default {
|
||||||
.card {
|
.card {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
padding: 30rpx;
|
padding: 38rpx 30rpx 42rpx;
|
||||||
box-shadow: 0 16rpx 40rpx rgba(10, 63, 48, 0.08);
|
box-shadow: 0 16rpx 40rpx rgba(10, 63, 48, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-wrap,
|
.form-row {
|
||||||
.code-wrap {
|
|
||||||
min-height: 98rpx;
|
min-height: 98rpx;
|
||||||
border: 1rpx solid #e8eaef;
|
border: 1rpx solid #e8eaef;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
padding: 0 20rpx;
|
padding: 0 24rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-wrap {
|
.form-row + .form-row {
|
||||||
margin-top: 10rpx;
|
margin-top: 22rpx;
|
||||||
}
|
|
||||||
|
|
||||||
.code-wrap {
|
|
||||||
margin-top: 10rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prefix {
|
.prefix {
|
||||||
|
|
@ -294,16 +268,57 @@ 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 {
|
||||||
margin-left: 12rpx;
|
width: 190rpx;
|
||||||
|
height: 70rpx;
|
||||||
|
line-height: 70rpx;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
background: #e9faf4;
|
||||||
|
color: #15b880;
|
||||||
|
font-size: 26rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-btn::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-btn[disabled] {
|
||||||
|
color: #8f98a3;
|
||||||
|
background: #f1f3f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn,
|
||||||
|
.wx-btn {
|
||||||
|
margin-top: 26rpx;
|
||||||
|
width: 100%;
|
||||||
|
height: 92rpx;
|
||||||
|
line-height: 92rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn {
|
.login-btn {
|
||||||
margin-top: 28rpx;
|
background: linear-gradient(90deg, #23c797, #11b880);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wx-btn {
|
.login-btn::after,
|
||||||
margin-top: 22rpx;
|
.wx-btn::after {
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.split {
|
.split {
|
||||||
|
|
@ -323,4 +338,9 @@ export default {
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
color: #9aa2ad;
|
color: #9aa2ad;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wx-btn {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
background: #07c160;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,51 @@
|
||||||
<!-- 我的页:引入 uview-plus 组件重构个人中心菜单 -->
|
|
||||||
<template>
|
<template>
|
||||||
<view class="page page-base">
|
<view class="page page-base">
|
||||||
<up-navbar title="我的" :fixed="false" :safe-area-inset-top="true" :border="false" left-icon="" />
|
<view class="top-nav top-nav-base">
|
||||||
|
<view class="title top-nav-title-base">我的</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="user-card" @click="goLogin">
|
<view class="user-card" @click="goLogin">
|
||||||
<up-avatar size="58" text="U" bg-color="#35c48f" />
|
<view class="avatar"></view>
|
||||||
<view class="user-main">
|
<view>
|
||||||
<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>
|
||||||
|
|
||||||
<up-cell-group :border="false" custom-style="margin: 24rpx; border-radius: 24rpx; overflow: hidden; background: #ffffff;">
|
<view class="menu-card">
|
||||||
<up-cell title="设备配置" is-link @click="goConfig" />
|
<view class="item" @click="goConfig">
|
||||||
<up-cell title="分享的设备" is-link />
|
<text class="left">设备配置</text>
|
||||||
<up-cell title="场所管理" is-link />
|
<text class="right">›</text>
|
||||||
<up-cell title="接警人管理" is-link />
|
</view>
|
||||||
<up-cell title="手机号" :value="phoneText" is-link />
|
<view class="item">
|
||||||
<up-cell title="语音广播" is-link />
|
<text class="left">分享的设备</text>
|
||||||
<up-cell title="当前版本" value="2.8.44" />
|
<text class="right">›</text>
|
||||||
<up-cell title="退出登录" @click="logout" />
|
</view>
|
||||||
</up-cell-group>
|
<view class="item">
|
||||||
|
<text class="left">场所管理</text>
|
||||||
|
<text class="right">›</text>
|
||||||
|
</view>
|
||||||
|
<view class="item">
|
||||||
|
<text class="left">接警人管理</text>
|
||||||
|
<text class="right">›</text>
|
||||||
|
</view>
|
||||||
|
<view class="item">
|
||||||
|
<text class="left">手机号</text>
|
||||||
|
<text class="mid">{{ phoneText }}</text>
|
||||||
|
<text class="right">›</text>
|
||||||
|
</view>
|
||||||
|
<view class="item">
|
||||||
|
<text class="left">语音广播</text>
|
||||||
|
<text class="right">›</text>
|
||||||
|
</view>
|
||||||
|
<view class="item">
|
||||||
|
<text class="left">当前版本</text>
|
||||||
|
<text class="mid">2.8.44</text>
|
||||||
|
</view>
|
||||||
|
<view class="item" @click="logout">
|
||||||
|
<text class="left">退出登录</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -44,20 +68,17 @@ 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
|
||||||
|
|
@ -65,7 +86,6 @@ export default {
|
||||||
|
|
||||||
navigateTo(LOGIN_PAGE)
|
navigateTo(LOGIN_PAGE)
|
||||||
},
|
},
|
||||||
// 退出时清理本地登录态并回到首页。
|
|
||||||
logout() {
|
logout() {
|
||||||
clearAuth()
|
clearAuth()
|
||||||
showToast('已退出登录')
|
showToast('已退出登录')
|
||||||
|
|
@ -75,7 +95,7 @@ export default {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style>
|
||||||
@import '@/styles/common.css';
|
@import '@/styles/common.css';
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
|
|
@ -84,22 +104,25 @@ export default {
|
||||||
|
|
||||||
.user-card {
|
.user-card {
|
||||||
margin: 24rpx;
|
margin: 24rpx;
|
||||||
min-height: 180rpx;
|
min-height: 200rpx;
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 20rpx 28rpx;
|
padding: 20rpx 34rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-main {
|
.avatar {
|
||||||
flex: 1;
|
width: 132rpx;
|
||||||
margin-left: 20rpx;
|
height: 132rpx;
|
||||||
|
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: 42rpx;
|
font-size: 46rpx;
|
||||||
color: #2a2d33;
|
color: #2a2d33;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +131,43 @@ export default {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 12rpx;
|
margin-top: 12rpx;
|
||||||
color: #9ea1a6;
|
color: #9ea1a6;
|
||||||
font-size: 28rpx;
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-card {
|
||||||
|
margin: 24rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
min-height: 94rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 30rpx;
|
||||||
|
border-bottom: 1rpx solid #efefef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 48rpx;
|
||||||
|
color: #2f3135;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mid {
|
||||||
|
margin-right: 16rpx;
|
||||||
|
color: #9ea1a6;
|
||||||
|
font-size: 44rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
color: #9ea1a6;
|
||||||
|
font-size: 54rpx;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
/* 页面通用背景基线:统一浅灰底色,减少各页面重复定义。 */
|
|
||||||
.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;
|
||||||
|
|
@ -21,7 +19,6 @@
|
||||||
color: #222222;
|
color: #222222;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 空状态通用样式,供首页/事件页等无数据场景复用。 */
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -43,7 +40,6 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 卡片外观基类。 */
|
|
||||||
.card-base {
|
.card-base {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius: 24rpx;
|
border-radius: 24rpx;
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,13 @@
|
||||||
// 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((value) => {
|
res.then((res) => {
|
||||||
if (!value) {
|
if (!res) return resolve(res)
|
||||||
return resolve(value)
|
return res[0] ? reject(res[0]) : resolve(res[1])
|
||||||
}
|
});
|
||||||
|
});
|
||||||
// uni API Promise 通常返回 [error, result]。
|
},
|
||||||
return value[0] ? reject(value[0]) : resolve(value[1])
|
});
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
2
uni.scss
2
uni.scss
|
|
@ -6,8 +6,6 @@
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@import 'uview-plus/theme.scss';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
|
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// 统一复用 api/user.js 的用户相关接口,供页面层按 utils 路径导入。
|
|
||||||
export {
|
export {
|
||||||
getUserProfile,
|
getUserProfile,
|
||||||
loginByMobile,
|
loginByMobile,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# BLE 模块说明
|
|
||||||
|
|
||||||
- 核心入口: `utils/ble/index.js`
|
|
||||||
- 推荐使用: `createUnifiedBleController`
|
|
||||||
- 本模块目标: 在不改变业务语义的前提下,抽离连接/鉴权/WiFi/雷达/解析公共能力,兼容 ED713 与 ED719。
|
|
||||||
|
|
@ -1,669 +0,0 @@
|
||||||
import { createEventBus } from './eventBus';
|
|
||||||
import { createBleState, BLE_STAGE } from './state';
|
|
||||||
import { createBleError, getBleErrorText, normalizeBleError } from './errors';
|
|
||||||
import { uint8ArrayToArrayBuffer, toUint8Array } from '../utils/bytes';
|
|
||||||
import { toFullUuid, uuidEquals, uuidInList } from '../utils/uuid';
|
|
||||||
import { runWithRetry } from './retry';
|
|
||||||
import { getBleProfile, DEVICE_PROFILE } from '../protocols/profiles';
|
|
||||||
|
|
||||||
// 把 uni 的 success/fail 回调风格统一成 Promise,便于串联 async 流程。
|
|
||||||
function promisifyUniApi(apiName, params = {}) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
uni[apiName]({
|
|
||||||
...params,
|
|
||||||
success: (res) => resolve(res),
|
|
||||||
fail: (err) => reject(err),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BLE 核心能力封装:
|
|
||||||
* 负责适配器生命周期、扫描、连接、服务/特征发现、读写与通知监听。
|
|
||||||
*/
|
|
||||||
export function createBleCore(options = {}) {
|
|
||||||
const {
|
|
||||||
reconnect = {
|
|
||||||
enabled: true,
|
|
||||||
retries: 2,
|
|
||||||
delay: 1200,
|
|
||||||
},
|
|
||||||
logger = console,
|
|
||||||
profileId = DEVICE_PROFILE.UNKNOWN,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const eventBus = createEventBus();
|
|
||||||
const state = createBleState();
|
|
||||||
let currentProfile = getBleProfile(profileId);
|
|
||||||
let deviceFoundHandler = null;
|
|
||||||
let adapterStateHandler = null;
|
|
||||||
let connectionStateHandler = null;
|
|
||||||
let characteristicValueHandler = null;
|
|
||||||
|
|
||||||
// 返回状态快照,避免调用方直接改写内部状态对象。
|
|
||||||
function snapshotState() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
auth: { ...state.auth },
|
|
||||||
deviceMap: { ...state.deviceMap },
|
|
||||||
characteristics: { ...state.characteristics },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统一 patch 状态并广播 state:change,保证 UI 状态来源单一。
|
|
||||||
function patchState(partialState) {
|
|
||||||
Object.assign(state, partialState);
|
|
||||||
eventBus.emit('state:change', snapshotState());
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStage(stage) {
|
|
||||||
patchState({ stage });
|
|
||||||
}
|
|
||||||
|
|
||||||
function setProfile(nextProfileId) {
|
|
||||||
currentProfile = getBleProfile(nextProfileId);
|
|
||||||
patchState({ profileId: currentProfile.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProfile() {
|
|
||||||
return currentProfile;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setAuthState(partialAuth = {}) {
|
|
||||||
patchState({
|
|
||||||
auth: {
|
|
||||||
...state.auth,
|
|
||||||
...partialAuth,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function emit(eventName, payload) {
|
|
||||||
eventBus.emit(eventName, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitError(error) {
|
|
||||||
const normalized = normalizeBleError(error);
|
|
||||||
patchState({ lastError: normalized });
|
|
||||||
eventBus.emit('error', normalized);
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 打开蓝牙适配器,成功后标记可用并进入 ADAPTER_OPENED 阶段。 */
|
|
||||||
async function openAdapter() {
|
|
||||||
try {
|
|
||||||
await promisifyUniApi('openBluetoothAdapter');
|
|
||||||
patchState({ available: true });
|
|
||||||
setStage(BLE_STAGE.ADAPTER_OPENED);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
throw emitError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 关闭蓝牙适配器并重置全部运行时状态。 */
|
|
||||||
async function closeAdapter() {
|
|
||||||
try {
|
|
||||||
await promisifyUniApi('closeBluetoothAdapter');
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('[BLE] close adapter failed', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置原生 handler 引用,以便 openAdapter 后 init() 可重新注册
|
|
||||||
adapterStateHandler = null;
|
|
||||||
connectionStateHandler = null;
|
|
||||||
characteristicValueHandler = null;
|
|
||||||
deviceFoundHandler = null;
|
|
||||||
|
|
||||||
patchState({
|
|
||||||
stage: BLE_STAGE.IDLE,
|
|
||||||
available: false,
|
|
||||||
discovering: false,
|
|
||||||
connected: false,
|
|
||||||
deviceId: '',
|
|
||||||
serviceId: '',
|
|
||||||
characteristics: {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取当前适配器状态(可用/搜索中),同步到内部状态。 */
|
|
||||||
async function getAdapterState() {
|
|
||||||
try {
|
|
||||||
const result = await promisifyUniApi('getBluetoothAdapterState');
|
|
||||||
patchState({
|
|
||||||
available: Boolean(result.available),
|
|
||||||
discovering: Boolean(result.discovering),
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
throw emitError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 注册适配器状态变更监听,蓝牙关闭时自动切换到 DISCONNECTED。 */
|
|
||||||
function watchAdapterState() {
|
|
||||||
if (adapterStateHandler) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
adapterStateHandler = (result) => {
|
|
||||||
patchState({
|
|
||||||
available: Boolean(result.available),
|
|
||||||
discovering: Boolean(result.discovering),
|
|
||||||
});
|
|
||||||
eventBus.emit('adapter:state', result);
|
|
||||||
|
|
||||||
if (!result.available) {
|
|
||||||
setStage(BLE_STAGE.DISCONNECTED);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
uni.onBluetoothAdapterStateChange(adapterStateHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 移除适配器状态变更监听。 */
|
|
||||||
function unwatchAdapterState() {
|
|
||||||
if (!adapterStateHandler) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uni.offBluetoothAdapterStateChange) {
|
|
||||||
uni.offBluetoothAdapterStateChange(adapterStateHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
adapterStateHandler = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动设备扫描。
|
|
||||||
* services 传入时会做 UUID 标准化,用于只扫描目标服务设备。
|
|
||||||
*/
|
|
||||||
async function startDiscovery(params = {}) {
|
|
||||||
const {
|
|
||||||
services = [],
|
|
||||||
allowDuplicatesKey = true,
|
|
||||||
interval = 0,
|
|
||||||
powerLevel = 'high',
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!state.available) {
|
|
||||||
await openAdapter();
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
allowDuplicatesKey,
|
|
||||||
interval,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (services.length) {
|
|
||||||
options.services = services.map((uuid) => toFullUuid(uuid) || uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (powerLevel) {
|
|
||||||
options.powerLevel = powerLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
await promisifyUniApi('startBluetoothDevicesDiscovery', options);
|
|
||||||
patchState({ discovering: true, deviceMap: {} });
|
|
||||||
setStage(BLE_STAGE.DISCOVERING);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
throw emitError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 停止设备扫描。 */
|
|
||||||
async function stopDiscovery() {
|
|
||||||
try {
|
|
||||||
await promisifyUniApi('stopBluetoothDevicesDiscovery');
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('[BLE] stop discovery failed', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
patchState({ discovering: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 注册设备发现监听,按 deviceId 去重合并后广播 device:found。 */
|
|
||||||
function watchDeviceFound() {
|
|
||||||
if (deviceFoundHandler) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceFoundHandler = (result) => {
|
|
||||||
const list = Array.isArray(result.devices) ? result.devices : [result];
|
|
||||||
const nextMap = { ...state.deviceMap };
|
|
||||||
|
|
||||||
// 基于 deviceId 做去重与增量更新,避免列表无限叠加。
|
|
||||||
list.forEach((device) => {
|
|
||||||
if (!device || !device.deviceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previous = nextMap[device.deviceId] || {};
|
|
||||||
nextMap[device.deviceId] = {
|
|
||||||
...previous,
|
|
||||||
...device,
|
|
||||||
showName:
|
|
||||||
device.name ||
|
|
||||||
device.localName ||
|
|
||||||
previous.showName ||
|
|
||||||
'未命名设备',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
patchState({ deviceMap: nextMap });
|
|
||||||
eventBus.emit('device:found', Object.values(nextMap));
|
|
||||||
};
|
|
||||||
|
|
||||||
uni.onBluetoothDeviceFound(deviceFoundHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 移除设备发现监听。 */
|
|
||||||
function unwatchDeviceFound() {
|
|
||||||
if (!deviceFoundHandler) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uni.offBluetoothDeviceFound) {
|
|
||||||
uni.offBluetoothDeviceFound(deviceFoundHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceFoundHandler = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 建立BLE连接。
|
|
||||||
* @param {string} deviceId 设备ID
|
|
||||||
* @param {object} options
|
|
||||||
* @param {number} options.timeout 连接超时(ms),默认15000
|
|
||||||
* @param {boolean} options.autoReconnect 是否自动重试,默认跟随初始化配置
|
|
||||||
*/
|
|
||||||
async function connect(deviceId, options = {}) {
|
|
||||||
if (!deviceId) {
|
|
||||||
throw emitError(createBleError(10013, 'deviceId 不能为空'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const { timeout = 15000, autoReconnect = reconnect.enabled } = options;
|
|
||||||
|
|
||||||
const runConnect = async () => {
|
|
||||||
setStage(BLE_STAGE.CONNECTING);
|
|
||||||
await promisifyUniApi('createBLEConnection', { deviceId, timeout });
|
|
||||||
patchState({ connected: true, deviceId });
|
|
||||||
setStage(BLE_STAGE.CONNECTED);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!autoReconnect) {
|
|
||||||
return await runConnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
return await runWithRetry(runConnect, {
|
|
||||||
retries: reconnect.retries,
|
|
||||||
delay: reconnect.delay,
|
|
||||||
shouldRetry: (error) => {
|
|
||||||
const normalized = normalizeBleError(error);
|
|
||||||
return [10003, 10006, 10012].includes(normalized.code);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
throw emitError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 断开BLE连接并重置连接相关状态。 */
|
|
||||||
async function disconnect(deviceId = state.deviceId) {
|
|
||||||
if (!deviceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await promisifyUniApi('closeBLEConnection', { deviceId });
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('[BLE] close connection failed', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
patchState({
|
|
||||||
connected: false,
|
|
||||||
deviceId: '',
|
|
||||||
serviceId: '',
|
|
||||||
characteristics: {},
|
|
||||||
});
|
|
||||||
setStage(BLE_STAGE.DISCONNECTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 注册连接状态变更监听,断开时自动切换到 DISCONNECTED。 */
|
|
||||||
function watchConnectionChange() {
|
|
||||||
if (connectionStateHandler) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionStateHandler = (result) => {
|
|
||||||
if (state.deviceId && result.deviceId !== state.deviceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
patchState({ connected: Boolean(result.connected) });
|
|
||||||
eventBus.emit('connection:change', result);
|
|
||||||
|
|
||||||
if (!result.connected) {
|
|
||||||
setStage(BLE_STAGE.DISCONNECTED);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
uni.onBLEConnectionStateChange(connectionStateHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 移除连接状态变更监听。 */
|
|
||||||
function unwatchConnectionChange() {
|
|
||||||
if (!connectionStateHandler) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uni.offBLEConnectionStateChange) {
|
|
||||||
uni.offBLEConnectionStateChange(connectionStateHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
connectionStateHandler = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取候选服务UUID列表:profile主服务 + 兼容固件的 00F4/F400/FFF4。 */
|
|
||||||
function getServiceCandidates() {
|
|
||||||
const profileService = currentProfile?.uuids?.service;
|
|
||||||
const candidates = [
|
|
||||||
profileService,
|
|
||||||
toFullUuid('00F4'),
|
|
||||||
toFullUuid('F400'),
|
|
||||||
toFullUuid('FFF4'),
|
|
||||||
];
|
|
||||||
|
|
||||||
return candidates.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发现并锁定目标服务。
|
|
||||||
* 文档主服务为 0x00F4,同时兼容部分固件 0xF400 / 0xFFF4。
|
|
||||||
*/
|
|
||||||
async function discoverServices(deviceId = state.deviceId) {
|
|
||||||
if (!deviceId) {
|
|
||||||
throw emitError(createBleError(10013, 'discoverServices 缺少 deviceId'));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await promisifyUniApi('getBLEDeviceServices', {
|
|
||||||
deviceId,
|
|
||||||
});
|
|
||||||
const services = result.services || [];
|
|
||||||
const candidates = getServiceCandidates();
|
|
||||||
const targetService = services.find((service) =>
|
|
||||||
candidates.some((uuid) => uuidEquals(service.uuid, uuid)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!targetService) {
|
|
||||||
throw createBleError(10004, '设备未暴露目标服务(00F4/F400/FFF4)');
|
|
||||||
}
|
|
||||||
|
|
||||||
patchState({ serviceId: targetService.uuid });
|
|
||||||
setStage(BLE_STAGE.SERVICE_DISCOVERED);
|
|
||||||
return {
|
|
||||||
services,
|
|
||||||
targetService,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw emitError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发现特征值并按业务用途缓存(wifi/key/cmd/msg/state/radar)。
|
|
||||||
* @param {object} options 可选覆盖 deviceId/serviceId
|
|
||||||
* @returns {object} characteristicMap 各业务特征 + all 全量列表
|
|
||||||
*/
|
|
||||||
async function discoverCharacteristics(options = {}) {
|
|
||||||
const deviceId = options.deviceId || state.deviceId;
|
|
||||||
const serviceId = options.serviceId || state.serviceId;
|
|
||||||
|
|
||||||
if (!deviceId || !serviceId) {
|
|
||||||
throw emitError(
|
|
||||||
createBleError(
|
|
||||||
10013,
|
|
||||||
'discoverCharacteristics 缺少 deviceId/serviceId',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await promisifyUniApi('getBLEDeviceCharacteristics', {
|
|
||||||
deviceId,
|
|
||||||
serviceId,
|
|
||||||
});
|
|
||||||
const list = result.characteristics || [];
|
|
||||||
const profile = getProfile();
|
|
||||||
|
|
||||||
// 缓存业务侧常用特征,减少后续每次操作时的查找成本。
|
|
||||||
const characteristicMap = {
|
|
||||||
wifi:
|
|
||||||
list.find((item) => uuidEquals(item.uuid, profile.uuids.wifi)) ||
|
|
||||||
null,
|
|
||||||
key:
|
|
||||||
list.find((item) => uuidEquals(item.uuid, profile.uuids.key)) || null,
|
|
||||||
cmd:
|
|
||||||
list.find((item) => uuidEquals(item.uuid, profile.uuids.cmd)) || null,
|
|
||||||
msg:
|
|
||||||
list.find((item) => uuidEquals(item.uuid, profile.uuids.msg)) || null,
|
|
||||||
state:
|
|
||||||
list.find((item) => uuidEquals(item.uuid, profile.uuids.state)) ||
|
|
||||||
null,
|
|
||||||
radar:
|
|
||||||
list.find((item) => uuidEquals(item.uuid, profile.uuids.radar)) ||
|
|
||||||
null,
|
|
||||||
all: list,
|
|
||||||
};
|
|
||||||
|
|
||||||
patchState({ characteristics: characteristicMap });
|
|
||||||
return characteristicMap;
|
|
||||||
} catch (error) {
|
|
||||||
throw emitError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 读取指定特征值(触发 onBLECharacteristicValueChange 回调)。 */
|
|
||||||
async function readCharacteristic(characteristicId, options = {}) {
|
|
||||||
const deviceId = options.deviceId || state.deviceId;
|
|
||||||
const serviceId = options.serviceId || state.serviceId;
|
|
||||||
|
|
||||||
if (!deviceId || !serviceId || !characteristicId) {
|
|
||||||
throw emitError(createBleError(10013, 'readCharacteristic 参数缺失'));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await promisifyUniApi('readBLECharacteristicValue', {
|
|
||||||
deviceId,
|
|
||||||
serviceId,
|
|
||||||
characteristicId,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
throw emitError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 向指定特征值写入数据,自动将输入转为 ArrayBuffer。 */
|
|
||||||
async function writeCharacteristic(characteristicId, value, options = {}) {
|
|
||||||
const deviceId = options.deviceId || state.deviceId;
|
|
||||||
const serviceId = options.serviceId || state.serviceId;
|
|
||||||
|
|
||||||
if (!deviceId || !serviceId || !characteristicId) {
|
|
||||||
throw emitError(createBleError(10013, 'writeCharacteristic 参数缺失'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取特征 properties,用于诊断写入类型是否匹配,并按需指定 writeType
|
|
||||||
const chars = state.characteristics || {};
|
|
||||||
const matched = Object.values(chars).find(
|
|
||||||
(item) => item && uuidEquals(item.uuid, characteristicId),
|
|
||||||
);
|
|
||||||
const props = matched?.properties || {};
|
|
||||||
const supportsWrite = Boolean(props.write);
|
|
||||||
const supportsWriteNoResp = Boolean(props.writeWithoutResponse);
|
|
||||||
// 默认优先有响应写入(可靠);仅当不支持有响应时退化为无响应写入
|
|
||||||
const writeType =
|
|
||||||
options.writeType ||
|
|
||||||
(supportsWrite
|
|
||||||
? 'write'
|
|
||||||
: supportsWriteNoResp
|
|
||||||
? 'writeWithoutResponse'
|
|
||||||
: undefined);
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.debug('[BLE][WRITE]', characteristicId, {
|
|
||||||
writeType,
|
|
||||||
supportsWrite,
|
|
||||||
supportsWriteNoResp,
|
|
||||||
valueLen: value?.length
|
|
||||||
});
|
|
||||||
const params = {
|
|
||||||
deviceId,
|
|
||||||
serviceId,
|
|
||||||
characteristicId,
|
|
||||||
value: uint8ArrayToArrayBuffer(toUint8Array(value)),
|
|
||||||
};
|
|
||||||
if (writeType) {
|
|
||||||
params.writeType = writeType;
|
|
||||||
}
|
|
||||||
await promisifyUniApi('writeBLECharacteristicValue', params);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
throw emitError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 订阅/取消订阅指定特征值的通知。 */
|
|
||||||
async function notifyCharacteristic(
|
|
||||||
characteristicId,
|
|
||||||
stateFlag = true,
|
|
||||||
options = {},
|
|
||||||
) {
|
|
||||||
const deviceId = options.deviceId || state.deviceId;
|
|
||||||
const serviceId = options.serviceId || state.serviceId;
|
|
||||||
|
|
||||||
if (!deviceId || !serviceId || !characteristicId) {
|
|
||||||
throw emitError(createBleError(10013, 'notifyCharacteristic 参数缺失'));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await promisifyUniApi('notifyBLECharacteristicValueChange', {
|
|
||||||
deviceId,
|
|
||||||
serviceId,
|
|
||||||
characteristicId,
|
|
||||||
state: Boolean(stateFlag),
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
throw emitError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchCharacteristicValue() {
|
|
||||||
if (characteristicValueHandler) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 统一转为 Uint8Array,简化上层解析逻辑。
|
|
||||||
characteristicValueHandler = (result) => {
|
|
||||||
const value = toUint8Array(result.value);
|
|
||||||
// 原始 trace:定位 notify 是否投递时,确认每个特征值变更的来源与内容
|
|
||||||
try {
|
|
||||||
const hex = Array.from(value)
|
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
|
||||||
.join(' ');
|
|
||||||
console.debug('[BLE][RAW]', result.characteristicId, 'len=', value.length, 'hex=', hex);
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
eventBus.emit('characteristic:value', {
|
|
||||||
...result,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
uni.onBLECharacteristicValueChange(characteristicValueHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 移除特征值变更监听。 */
|
|
||||||
function unwatchCharacteristicValue() {
|
|
||||||
if (!characteristicValueHandler) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uni.offBLECharacteristicValueChange) {
|
|
||||||
uni.offBLECharacteristicValueChange(characteristicValueHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
characteristicValueHandler = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 返回已发现设备列表。 */
|
|
||||||
function listDiscoveredDevices() {
|
|
||||||
return Object.values(state.deviceMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从已发现设备中,按广播服务UUID匹配并返回第一个设备。 */
|
|
||||||
function pickDeviceByService(serviceUuid = currentProfile.uuids.service) {
|
|
||||||
const list = listDiscoveredDevices();
|
|
||||||
|
|
||||||
return (
|
|
||||||
list.find((device) =>
|
|
||||||
uuidInList(serviceUuid, device.advertisServiceUUIDs || []),
|
|
||||||
) || null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanup() {
|
|
||||||
// 清理顺序:先停扫描与连接,再移除监听,最后关闭适配器。
|
|
||||||
await stopDiscovery();
|
|
||||||
await disconnect();
|
|
||||||
|
|
||||||
unwatchDeviceFound();
|
|
||||||
unwatchAdapterState();
|
|
||||||
unwatchConnectionChange();
|
|
||||||
unwatchCharacteristicValue();
|
|
||||||
|
|
||||||
await closeAdapter();
|
|
||||||
eventBus.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
openAdapter,
|
|
||||||
closeAdapter,
|
|
||||||
getAdapterState,
|
|
||||||
watchAdapterState,
|
|
||||||
unwatchAdapterState,
|
|
||||||
startDiscovery,
|
|
||||||
stopDiscovery,
|
|
||||||
watchDeviceFound,
|
|
||||||
unwatchDeviceFound,
|
|
||||||
connect,
|
|
||||||
disconnect,
|
|
||||||
watchConnectionChange,
|
|
||||||
unwatchConnectionChange,
|
|
||||||
discoverServices,
|
|
||||||
discoverCharacteristics,
|
|
||||||
readCharacteristic,
|
|
||||||
writeCharacteristic,
|
|
||||||
notifyCharacteristic,
|
|
||||||
watchCharacteristicValue,
|
|
||||||
unwatchCharacteristicValue,
|
|
||||||
pickDeviceByService,
|
|
||||||
listDiscoveredDevices,
|
|
||||||
getState: snapshotState,
|
|
||||||
setStage,
|
|
||||||
setProfile,
|
|
||||||
getProfile,
|
|
||||||
setAuthState,
|
|
||||||
emit,
|
|
||||||
on: eventBus.on,
|
|
||||||
once: eventBus.once,
|
|
||||||
cleanup,
|
|
||||||
getBleErrorText,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,284 +0,0 @@
|
||||||
import { createBleCore } from './bleCore';
|
|
||||||
import { createAuthModule } from '../modules/auth';
|
|
||||||
import { createWifiModule } from '../modules/wifi';
|
|
||||||
import { createRadarModule } from '../modules/radar';
|
|
||||||
import { parseStateCode, parseDeviceInfo } from '../parsers';
|
|
||||||
import {
|
|
||||||
DEVICE_PROFILE,
|
|
||||||
guessProfileByRadarFrame,
|
|
||||||
} from '../protocols/profiles';
|
|
||||||
import { uuidEquals } from '../utils/uuid';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 订阅 F301 前先读一次 UUID_MSG(F501),满足厂家强制时序。
|
|
||||||
* 先注册 protocol:msg 监听再触发读取,避免回调早于监听丢失数据。
|
|
||||||
*/
|
|
||||||
async function readMsgOnce(ble, chars, timeout = 4000) {
|
|
||||||
if (!chars || !chars.msg || !chars.msg.uuid) {
|
|
||||||
console.debug('[BLE][MSG]', 'readMsgOnce skipped: no MSG characteristic');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let timer = null;
|
|
||||||
let off = null;
|
|
||||||
try {
|
|
||||||
const payload = await new Promise((resolve, reject) => {
|
|
||||||
const finish = (cb) => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
if (off) off();
|
|
||||||
cb();
|
|
||||||
};
|
|
||||||
off = ble.on('protocol:msg', (data) => finish(() => resolve(data)));
|
|
||||||
timer = setTimeout(
|
|
||||||
() => finish(() => reject(new Error('读取 UUID_MSG 超时'))),
|
|
||||||
timeout
|
|
||||||
);
|
|
||||||
ble.readCharacteristic(chars.msg.uuid).catch((e) => finish(() => reject(e)));
|
|
||||||
});
|
|
||||||
const info = parseDeviceInfo(payload.value, ble.getProfile().id);
|
|
||||||
console.debug('[BLE][MSG]', 'readMsgOnce ok', JSON.stringify(info));
|
|
||||||
return info;
|
|
||||||
} catch (e) {
|
|
||||||
// 读 MSG 失败不阻断后续(仅作时序前置),但记录日志便于排查
|
|
||||||
console.debug('[BLE][MSG]', 'readMsgOnce failed', e?.message || e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建特征值通知分发器。
|
|
||||||
* 根据特征UUID将通知分发到对应协议事件:state / msg / radar / raw。
|
|
||||||
* 雷达帧还能自动推断设备 profile(ED713/ED719)。
|
|
||||||
*/
|
|
||||||
function createCharacteristicDispatcher(ble) {
|
|
||||||
return (event) => {
|
|
||||||
const state = ble.getState();
|
|
||||||
const profile = ble.getProfile();
|
|
||||||
const current = state.characteristics;
|
|
||||||
|
|
||||||
if (
|
|
||||||
current.state &&
|
|
||||||
uuidEquals(event.characteristicId, current.state.uuid)
|
|
||||||
) {
|
|
||||||
const stateCode = parseStateCode(event.value);
|
|
||||||
ble.setAuthState({
|
|
||||||
lastStateCode: stateCode,
|
|
||||||
keyMatched: stateCode === 7 || stateCode === 5 || state.auth.keyMatched,
|
|
||||||
});
|
|
||||||
|
|
||||||
ble.emit('protocol:state', {
|
|
||||||
...event,
|
|
||||||
profileId: profile.id,
|
|
||||||
stateCode,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.msg && uuidEquals(event.characteristicId, current.msg.uuid)) {
|
|
||||||
ble.emit('protocol:msg', {
|
|
||||||
...event,
|
|
||||||
profileId: profile.id,
|
|
||||||
deviceInfo: parseDeviceInfo(event.value, profile.id),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
current.radar &&
|
|
||||||
uuidEquals(event.characteristicId, current.radar.uuid)
|
|
||||||
) {
|
|
||||||
const guessed = guessProfileByRadarFrame(event.value);
|
|
||||||
if (
|
|
||||||
state.profileId === DEVICE_PROFILE.UNKNOWN &&
|
|
||||||
guessed !== DEVICE_PROFILE.UNKNOWN
|
|
||||||
) {
|
|
||||||
ble.setProfile(guessed);
|
|
||||||
}
|
|
||||||
|
|
||||||
ble.emit('protocol:radar', {
|
|
||||||
...event,
|
|
||||||
profileId: ble.getProfile().id,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ble.emit('protocol:raw', {
|
|
||||||
...event,
|
|
||||||
profileId: profile.id,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一BLE控制器:整合核心能力 + 认证/WiFi/雷达三大业务模块。
|
|
||||||
* 页面层应通过此控制器编排完整业务流程。
|
|
||||||
* @param {object} options 透传给 createBleCore 的配置(重试策略/日志/profileId等)
|
|
||||||
*/
|
|
||||||
export function createUnifiedBleController(options = {}) {
|
|
||||||
const ble = createBleCore(options);
|
|
||||||
const auth = createAuthModule(ble);
|
|
||||||
const wifi = createWifiModule(ble);
|
|
||||||
const radar = createRadarModule(ble);
|
|
||||||
|
|
||||||
const characteristicDispatcher = createCharacteristicDispatcher(ble);
|
|
||||||
let offCharacteristic = null;
|
|
||||||
|
|
||||||
/** 绑定特征值分发器到 ble 事件总线。 */
|
|
||||||
function bindDispatcher() {
|
|
||||||
if (offCharacteristic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
offCharacteristic = ble.on(
|
|
||||||
'characteristic:value',
|
|
||||||
characteristicDispatcher,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解绑特征值分发器。 */
|
|
||||||
function unbindDispatcher() {
|
|
||||||
if (!offCharacteristic) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
offCharacteristic();
|
|
||||||
offCharacteristic = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 初始化蓝牙适配器并注册所有监听(适配器状态/连接/特征值/分发器)。 */
|
|
||||||
async function init() {
|
|
||||||
await ble.openAdapter();
|
|
||||||
ble.watchAdapterState();
|
|
||||||
ble.watchConnectionChange();
|
|
||||||
ble.watchCharacteristicValue();
|
|
||||||
bindDispatcher();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 扫描并选取目标设备。
|
|
||||||
* 先按服务UUID过滤扫描,无结果时可退化为无过滤扫描。
|
|
||||||
* @param {object} params
|
|
||||||
* @param {string} params.serviceUuid 目标服务UUID,默认取 profile 配置
|
|
||||||
* @param {number} params.scanDuration 扫描时长(ms),默认5000
|
|
||||||
* @param {boolean} params.fallbackWithoutFilter 首次无结果时是否退化为无过滤扫描,默认true
|
|
||||||
* @returns {object|null} 匹配到的设备对象,未找到返回 null
|
|
||||||
*/
|
|
||||||
async function scanAndPickDevice({
|
|
||||||
serviceUuid,
|
|
||||||
scanDuration = 5000,
|
|
||||||
fallbackWithoutFilter = true,
|
|
||||||
} = {}) {
|
|
||||||
const profile = ble.getProfile();
|
|
||||||
const targetService = serviceUuid || profile.uuids.service;
|
|
||||||
|
|
||||||
ble.watchDeviceFound();
|
|
||||||
|
|
||||||
await ble.startDiscovery({ services: [targetService] });
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, scanDuration));
|
|
||||||
await ble.stopDiscovery();
|
|
||||||
|
|
||||||
let selected = ble.pickDeviceByService(targetService);
|
|
||||||
|
|
||||||
if (!selected && fallbackWithoutFilter) {
|
|
||||||
await ble.startDiscovery({ services: [] });
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, scanDuration));
|
|
||||||
await ble.stopDiscovery();
|
|
||||||
selected =
|
|
||||||
ble.pickDeviceByService(targetService) ||
|
|
||||||
ble.listDiscoveredDevices()[0] ||
|
|
||||||
null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 连接后严格执行协议前置(厂家要求顺序):
|
|
||||||
* 1) 发现服务和特征
|
|
||||||
* 2) 先读取 UUID_MSG(F501) —— 必须在订阅 F301 之前
|
|
||||||
* 3) 订阅 UUID_STATE(F301) / UUID_RADAR(F302)
|
|
||||||
*
|
|
||||||
* 厂家说明:订阅 F301 前必须先读 F501,否则后续设备不回 STATE 通知,
|
|
||||||
* 表现为写 KEY/WIFI 后 F301 零回执、等待超时。
|
|
||||||
*/
|
|
||||||
async function connectAndDiscover(deviceId) {
|
|
||||||
await ble.connect(deviceId);
|
|
||||||
await ble.discoverServices(deviceId);
|
|
||||||
const chars = await ble.discoverCharacteristics();
|
|
||||||
|
|
||||||
// 先读 UUID_MSG(F501),再订阅 F301 —— 厂家强制顺序
|
|
||||||
await readMsgOnce(ble, chars);
|
|
||||||
|
|
||||||
if (chars.state && chars.state.uuid) {
|
|
||||||
// 打印 STATE 特征属性,区分 notify / indicate,定位通知不投递问题
|
|
||||||
console.debug('[BLE][SUB]', 'STATE props', JSON.stringify(chars.state.properties || {}));
|
|
||||||
// STATE 是鉴权/WiFi 的唯一回执通道,订阅失败必须显式抛出,
|
|
||||||
// 否则后续写 KEY 后设备零回执,表现为"等待 UUID_STATE 超时"难以定位。
|
|
||||||
try {
|
|
||||||
await ble.notifyCharacteristic(chars.state.uuid, true);
|
|
||||||
console.debug('[BLE][SUB]', 'notify STATE ok', chars.state.uuid);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(`订阅 UUID_STATE(F301) 失败:${e?.message || e}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('未发现 UUID_STATE(F301) 特征值,无法接收设备回执');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chars.radar && chars.radar.uuid) {
|
|
||||||
try {
|
|
||||||
await ble.notifyCharacteristic(chars.radar.uuid, true);
|
|
||||||
console.debug('[BLE][SUB]', 'notify RADAR ok', chars.radar.uuid);
|
|
||||||
} catch (e) {
|
|
||||||
console.debug('[BLE][SUB]', 'notify RADAR fail', e?.message || e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return chars;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 标准流程:连接 -> 订阅 -> 鉴权。
|
|
||||||
* 鉴权内部会按 bindStatus 分支处理绑定/匹配。
|
|
||||||
*/
|
|
||||||
async function standardConnectFlow({ deviceId, key }) {
|
|
||||||
await init();
|
|
||||||
|
|
||||||
let targetDeviceId = deviceId;
|
|
||||||
if (!targetDeviceId) {
|
|
||||||
const device = await scanAndPickDevice();
|
|
||||||
if (!device) {
|
|
||||||
throw new Error('未找到可连接设备');
|
|
||||||
}
|
|
||||||
|
|
||||||
targetDeviceId = device.deviceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
await connectAndDiscover(targetDeviceId);
|
|
||||||
const authResult = await auth.ensureAuthorized(key);
|
|
||||||
|
|
||||||
return {
|
|
||||||
deviceId: targetDeviceId,
|
|
||||||
authResult,
|
|
||||||
state: ble.getState(),
|
|
||||||
profile: ble.getProfile(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 释放所有资源:解绑分发器 + 清理核心BLE(停扫描/断连接/移监听/关适配器)。 */
|
|
||||||
async function cleanup() {
|
|
||||||
unbindDispatcher();
|
|
||||||
await ble.cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ble,
|
|
||||||
auth,
|
|
||||||
wifi,
|
|
||||||
radar,
|
|
||||||
init,
|
|
||||||
scanAndPickDevice,
|
|
||||||
connectAndDiscover,
|
|
||||||
standardConnectFlow,
|
|
||||||
cleanup,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
// 微信 BLE 常见 errCode 到可读文案映射。
|
|
||||||
const ERROR_MESSAGES = Object.freeze({
|
|
||||||
10000: '未初始化蓝牙适配器',
|
|
||||||
10001: '当前蓝牙适配器不可用',
|
|
||||||
10002: '没有找到指定设备',
|
|
||||||
10003: '连接失败',
|
|
||||||
10004: '没有找到指定服务',
|
|
||||||
10005: '没有找到指定特征值',
|
|
||||||
10006: '当前连接已断开',
|
|
||||||
10007: '当前特征值不支持此操作',
|
|
||||||
10008: '其余所有系统上报的异常',
|
|
||||||
10009: 'Android 系统特有,系统版本低于 4.3 不支持 BLE',
|
|
||||||
10010: '已连接',
|
|
||||||
10011: '配对设备需要配对码',
|
|
||||||
10012: '连接超时',
|
|
||||||
10013: '连接 deviceId 为空或者是格式不正确',
|
|
||||||
10014: 'writeCharacteristicValue: 找不到指定设备',
|
|
||||||
10015: 'readCharacteristicValue: 找不到指定设备',
|
|
||||||
10016: 'openBluetoothAdapter: 未找到蓝牙适配器',
|
|
||||||
10017: 'startBluetoothDevicesDiscovery: 未找到蓝牙适配器',
|
|
||||||
10018: 'stopBluetoothDevicesDiscovery: 调用失败',
|
|
||||||
10019: 'getBluetoothDevices: 调用失败',
|
|
||||||
10020: 'getConnectedBluetoothDevices: 调用失败',
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构造统一 BLE Error,方便上层直接读取 code/message。
|
|
||||||
*/
|
|
||||||
export function createBleError(code, message, payload = {}) {
|
|
||||||
const err = new Error(message || ERROR_MESSAGES[code] || '蓝牙操作失败');
|
|
||||||
err.name = 'BleError';
|
|
||||||
err.code = code;
|
|
||||||
Object.assign(err, payload);
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 归一化任意错误对象(uni err / 原生 Error / 空值)。
|
|
||||||
*/
|
|
||||||
export function normalizeBleError(error, fallbackMessage = '蓝牙操作失败') {
|
|
||||||
if (!error) {
|
|
||||||
return createBleError(-1, fallbackMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error.name === 'BleError') {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const errCode = typeof error.errCode === 'number' ? error.errCode : -1;
|
|
||||||
const message = error.errMsg || ERROR_MESSAGES[errCode] || fallbackMessage;
|
|
||||||
return createBleError(errCode, message, { raw: error });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 安全获取错误文案,避免页面层处理 null/undefined 分支。
|
|
||||||
*/
|
|
||||||
export function getBleErrorText(error, fallbackText = '蓝牙操作失败') {
|
|
||||||
if (!error) {
|
|
||||||
return fallbackText;
|
|
||||||
}
|
|
||||||
|
|
||||||
return error.message || ERROR_MESSAGES[error.code] || fallbackText;
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
/**
|
|
||||||
* 轻量事件总线:用于 BLE Core 与业务模块之间的事件解耦。
|
|
||||||
*/
|
|
||||||
export function createEventBus() {
|
|
||||||
const listeners = {};
|
|
||||||
|
|
||||||
/** 订阅事件,返回取消订阅函数。 */
|
|
||||||
function on(eventName, handler) {
|
|
||||||
if (!listeners[eventName]) {
|
|
||||||
listeners[eventName] = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
listeners[eventName].add(handler);
|
|
||||||
|
|
||||||
// 返回取消订阅函数,方便调用方在页面卸载时回收监听。
|
|
||||||
return () => {
|
|
||||||
off(eventName, handler);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 单次订阅:触发一次后自动取消。 */
|
|
||||||
function once(eventName, handler) {
|
|
||||||
const unsubscribe = on(eventName, (payload) => {
|
|
||||||
unsubscribe();
|
|
||||||
handler(payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
return unsubscribe;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取消指定事件的某个监听。 */
|
|
||||||
function off(eventName, handler) {
|
|
||||||
if (!listeners[eventName]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
listeners[eventName].delete(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 广播事件,所有监听器同步执行(内部已 try-catch 隔离)。 */
|
|
||||||
function emit(eventName, payload) {
|
|
||||||
if (!listeners[eventName]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
listeners[eventName].forEach((handler) => {
|
|
||||||
try {
|
|
||||||
handler(payload);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[BLE event handler error]', eventName, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清空所有事件监听。 */
|
|
||||||
function clear() {
|
|
||||||
Object.keys(listeners).forEach((eventName) => {
|
|
||||||
listeners[eventName].clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
on,
|
|
||||||
once,
|
|
||||||
off,
|
|
||||||
emit,
|
|
||||||
clear,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import {splitUint8Array, toUint8Array} from '../utils/bytes'
|
|
||||||
import {utf8ToBytes} from '../utils/hex'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按协议将 payload 拆成 A7 分包:
|
|
||||||
* 第 1 字节固定包头 0xA7,第 2 字节高4位为总包数、低4位为包序号。
|
|
||||||
*/
|
|
||||||
export function buildA7Packets(payload, packetConfig = {}) {
|
|
||||||
const {
|
|
||||||
head = 0xA7,
|
|
||||||
chunkSize = 18,
|
|
||||||
maxChunk = 15
|
|
||||||
} = packetConfig
|
|
||||||
|
|
||||||
const bytes = typeof payload === 'string' ? utf8ToBytes(payload) : toUint8Array(payload)
|
|
||||||
const chunks = splitUint8Array(bytes, chunkSize)
|
|
||||||
|
|
||||||
if (chunks.length > maxChunk) {
|
|
||||||
throw new Error(`分包数量超过上限: ${chunks.length} > ${maxChunk}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks.map((chunk, index) => {
|
|
||||||
const packetIndex = index + 1
|
|
||||||
const lenByte = ((chunks.length & 0x0F) << 4) | (packetIndex & 0x0F)
|
|
||||||
const packet = new Uint8Array(2 + chunk.length)
|
|
||||||
|
|
||||||
packet[0] = head
|
|
||||||
packet[1] = lenByte
|
|
||||||
packet.set(chunk, 2)
|
|
||||||
|
|
||||||
return packet
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将 payload 按 A7 规则分包后顺序写入目标特征。
|
|
||||||
* 统一 auth/wifi 分包发送逻辑,避免多处重复实现。
|
|
||||||
*/
|
|
||||||
export async function writeA7Payload(ble, characteristicId, payload, packetConfig = {}, options = {}) {
|
|
||||||
if (!ble || typeof ble.writeCharacteristic !== 'function') {
|
|
||||||
throw new Error('writeA7Payload 缺少有效的 ble 实例')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!characteristicId) {
|
|
||||||
throw new Error('writeA7Payload 缺少 characteristicId')
|
|
||||||
}
|
|
||||||
|
|
||||||
const { packetInterval = 0 } = options
|
|
||||||
const packets = buildA7Packets(payload, packetConfig)
|
|
||||||
|
|
||||||
for (const packet of packets) {
|
|
||||||
await ble.writeCharacteristic(characteristicId, packet)
|
|
||||||
|
|
||||||
if (packetInterval > 0) {
|
|
||||||
// 部分固件在连续写入时需要极短间隔,默认 0 兼容原行为。
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, packetInterval))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return packets.length
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 合并多个 A7 分包数据体(去掉前两字节头信息后拼接)。
|
|
||||||
*/
|
|
||||||
export function concatA7PacketData(packets = []) {
|
|
||||||
const sorted = [...packets].sort((a, b) => {
|
|
||||||
const indexA = a[1] & 0x0F
|
|
||||||
const indexB = b[1] & 0x0F
|
|
||||||
return indexA - indexB
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalLength = sorted.reduce((sum, packet) => sum + Math.max(packet.length - 2, 0), 0)
|
|
||||||
const merged = new Uint8Array(totalLength)
|
|
||||||
let offset = 0
|
|
||||||
|
|
||||||
sorted.forEach((packet) => {
|
|
||||||
const body = packet.slice(2)
|
|
||||||
merged.set(body, offset)
|
|
||||||
offset += body.length
|
|
||||||
})
|
|
||||||
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
// 简单 sleep 工具,供重试间隔等待使用。
|
|
||||||
export function sleep(ms) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, ms)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用重试执行器。
|
|
||||||
* @param {(retryIndex:number)=>Promise<any>} fn 被执行函数
|
|
||||||
* @param {object} options
|
|
||||||
* @param {number} options.retries 失败后最大重试次数
|
|
||||||
* @param {number} options.delay 每次重试前等待毫秒
|
|
||||||
* @param {(error:any,retryIndex:number)=>boolean} options.shouldRetry 是否重试
|
|
||||||
*/
|
|
||||||
export async function runWithRetry(fn, options = {}) {
|
|
||||||
const {
|
|
||||||
retries = 2,
|
|
||||||
delay = 500,
|
|
||||||
shouldRetry = () => true
|
|
||||||
} = options
|
|
||||||
|
|
||||||
let lastError = null
|
|
||||||
|
|
||||||
for (let index = 0; index <= retries; index += 1) {
|
|
||||||
try {
|
|
||||||
return await fn(index)
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error
|
|
||||||
|
|
||||||
if (index >= retries || !shouldRetry(error, index)) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(delay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
// BLE 状态机阶段定义,便于页面层按阶段展示文案/按钮状态。
|
|
||||||
export const BLE_STAGE = Object.freeze({
|
|
||||||
IDLE: 'IDLE',
|
|
||||||
ADAPTER_OPENED: 'ADAPTER_OPENED',
|
|
||||||
DISCOVERING: 'DISCOVERING',
|
|
||||||
CONNECTING: 'CONNECTING',
|
|
||||||
CONNECTED: 'CONNECTED',
|
|
||||||
SERVICE_DISCOVERED: 'SERVICE_DISCOVERED',
|
|
||||||
AUTHORIZING: 'AUTHORIZING',
|
|
||||||
AUTHORIZED: 'AUTHORIZED',
|
|
||||||
OPERATING: 'OPERATING',
|
|
||||||
DISCONNECTED: 'DISCONNECTED'
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建 BLE 运行时状态。
|
|
||||||
* 注意:返回的是可变对象,由 bleCore 内部统一 patch。
|
|
||||||
*/
|
|
||||||
export function createBleState() {
|
|
||||||
return {
|
|
||||||
stage: BLE_STAGE.IDLE,
|
|
||||||
available: false,
|
|
||||||
discovering: false,
|
|
||||||
connected: false,
|
|
||||||
deviceId: '',
|
|
||||||
serviceId: '',
|
|
||||||
profileId: '',
|
|
||||||
reconnectCount: 0,
|
|
||||||
lastError: null,
|
|
||||||
deviceMap: {},
|
|
||||||
characteristics: {},
|
|
||||||
auth: {
|
|
||||||
bound: null,
|
|
||||||
keyMatched: false,
|
|
||||||
lastStateCode: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
/**
|
|
||||||
* 统一等待 UUID_STATE 通知。
|
|
||||||
*
|
|
||||||
* @param {object} ble createBleCore 实例
|
|
||||||
* @param {object} options 等待配置
|
|
||||||
* @param {number[]} options.allowedCodes 允许通过的状态码;为空则接收任意状态码
|
|
||||||
* @param {boolean} options.rejectOnUnexpected 收到非 allowedCodes 状态码时是否立刻失败
|
|
||||||
* @param {number} options.timeout 超时时间(ms)
|
|
||||||
* @param {string} options.timeoutMessage 超时提示
|
|
||||||
* @param {string} options.unexpectedMessage 非预期状态提示前缀
|
|
||||||
* @param {number[]} options.refreshOnCodes 收到这些中间态状态码时重置超时计时器
|
|
||||||
* @param {number} options.refreshTimeout 重置后的窗口时长(ms),未传则回退到 timeout
|
|
||||||
*
|
|
||||||
* 中间态刷新机制:设备连接 WiFi 时会先发 STATE=4(连接中) 再发最终态,
|
|
||||||
* 若总超时短于真实 DHCP 时长会误判超时。收到 refreshOnCodes 内的中间态时
|
|
||||||
* 重置倒计时,最多延长至 refreshTimeout 指定的单次窗口时长。
|
|
||||||
*/
|
|
||||||
export function waitForProtocolState(ble, options = {}) {
|
|
||||||
const {
|
|
||||||
allowedCodes = [],
|
|
||||||
rejectOnUnexpected = false,
|
|
||||||
timeout = 6000,
|
|
||||||
timeoutMessage = '等待 UUID_STATE 超时',
|
|
||||||
unexpectedMessage = '收到非预期 UUID_STATE 状态码',
|
|
||||||
refreshOnCodes = [],
|
|
||||||
refreshTimeout = timeout
|
|
||||||
} = options
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let timer = null
|
|
||||||
let off = null
|
|
||||||
try {
|
|
||||||
console.debug('[BLE][STATEWAIT]', 'start', {
|
|
||||||
allowed: allowedCodes.join(',') || 'ANY',
|
|
||||||
timeout,
|
|
||||||
refreshOn: refreshOnCodes.join(',') || 'NONE'
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
|
|
||||||
const finish = (callback) => {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (off) {
|
|
||||||
off()
|
|
||||||
}
|
|
||||||
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
|
|
||||||
const armTimer = (duration) => {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer)
|
|
||||||
}
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
try {
|
|
||||||
console.debug('[BLE][STATEWAIT]', 'timeout')
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
finish(() => reject(new Error(timeoutMessage)))
|
|
||||||
}, duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
armTimer(timeout)
|
|
||||||
|
|
||||||
off = ble.on('protocol:state', (payload) => {
|
|
||||||
const stateCode = Number(payload?.stateCode)
|
|
||||||
|
|
||||||
if (!allowedCodes.length || allowedCodes.includes(stateCode)) {
|
|
||||||
try {
|
|
||||||
console.debug('[BLE][STATEWAIT]', 'accept', stateCode)
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
finish(() => resolve(payload))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 命中中间态刷新:重置超时窗口,继续等待后续最终态
|
|
||||||
if (refreshOnCodes.length && refreshOnCodes.includes(stateCode)) {
|
|
||||||
try {
|
|
||||||
console.debug('[BLE][STATEWAIT]', 'refresh', stateCode)
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
armTimer(refreshTimeout)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rejectOnUnexpected) {
|
|
||||||
try {
|
|
||||||
console.debug('[BLE][STATEWAIT]', 'reject', stateCode)
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
finish(() => reject(new Error(`${unexpectedMessage}: ${stateCode}`)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
/**
|
|
||||||
* BLE 对外统一出口。
|
|
||||||
* 页面层/业务层应优先从此文件导入,避免直接依赖子模块内部路径。
|
|
||||||
*/
|
|
||||||
export { createBleCore } from './core/bleCore'
|
|
||||||
export { createUnifiedBleController } from './core/controller'
|
|
||||||
|
|
||||||
export { createAuthModule } from './modules/auth'
|
|
||||||
export { createWifiModule } from './modules/wifi'
|
|
||||||
export { createRadarModule } from './modules/radar'
|
|
||||||
|
|
||||||
export { BLE_STAGE } from './core/state'
|
|
||||||
export { DEVICE_PROFILE, getBleProfile, guessProfileByRadarFrame } from './protocols/profiles'
|
|
||||||
|
|
||||||
export { buildA7Packets, writeA7Payload } from './core/packet'
|
|
||||||
export { waitForProtocolState } from './core/stateWait'
|
|
||||||
|
|
||||||
export { bytesToHex, hexToBytes, utf8ToBytes, bytesToUtf8, bytesToMac } from './utils/hex'
|
|
||||||
export { splitToLowHigh, joinLowHigh, readUint16LE, readUint32LE, toSignedInt8 } from './utils/bytes'
|
|
||||||
export { normalizeUuid, shortUuid, toFullUuid, uuidEquals, uuidInList } from './utils/uuid'
|
|
||||||
|
|
||||||
export { parseStateCode, parseDeviceInfo, parseRadarData } from './parsers'
|
|
||||||
|
|
@ -1,312 +0,0 @@
|
||||||
import { writeA7Payload } from '../core/packet';
|
|
||||||
import { waitForProtocolState } from '../core/stateWait';
|
|
||||||
import { parseDeviceInfo } from '../parsers';
|
|
||||||
import { BLE_STAGE } from '../core/state';
|
|
||||||
|
|
||||||
/** 生成指定长度的随机密钥(排除易混淆字符 0/O/1/I/l)。 */
|
|
||||||
function generateRandomKey(length = 16) {
|
|
||||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
|
|
||||||
let result = '';
|
|
||||||
|
|
||||||
for (let index = 0; index < length; index += 1) {
|
|
||||||
const random = Math.floor(Math.random() * chars.length);
|
|
||||||
result += chars[random];
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authLog = (...args) => {
|
|
||||||
try {
|
|
||||||
console.debug('[BLE][AUTH]', ...args);
|
|
||||||
} catch (e) {}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 将 MAC 地址去除非十六进制字符并转大写,用于密钥拼接。 */
|
|
||||||
function normalizeMacForKey(macText = '') {
|
|
||||||
return String(macText || '')
|
|
||||||
.replace(/[^0-9a-fA-F]/g, '')
|
|
||||||
.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按协议约定生成设备密钥:MAC(去分隔符) + 固定后缀。
|
|
||||||
* 默认后缀为 "0000",对应 12 位 MAC + 4 位后缀 = 16 位密钥。
|
|
||||||
*/
|
|
||||||
export function buildKeyFromMac(macText, suffix = '0000', expectedLength = 16) {
|
|
||||||
const normalizedMac = normalizeMacForKey(macText);
|
|
||||||
const normalizedSuffix = String(suffix || '').trim();
|
|
||||||
|
|
||||||
if (!normalizedSuffix) {
|
|
||||||
throw new Error('密钥后缀不能为空');
|
|
||||||
}
|
|
||||||
|
|
||||||
const requiredMacLength = expectedLength - normalizedSuffix.length;
|
|
||||||
if (requiredMacLength <= 0) {
|
|
||||||
throw new Error('密钥长度配置异常');
|
|
||||||
}
|
|
||||||
|
|
||||||
// if (normalizedMac.length !== requiredMacLength) {
|
|
||||||
// throw new Error(`设备 MAC 长度异常,期望 ${requiredMacLength} 位,实际 ${normalizedMac.length} 位`)
|
|
||||||
// }
|
|
||||||
|
|
||||||
return `${normalizedMac}${normalizedSuffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将密钥通过A7分包写入 UUID_KEY 特征值。
|
|
||||||
* @param {object} ble createBleCore 实例
|
|
||||||
* @param {string} keyText 密钥文本
|
|
||||||
* @returns {string} 实际写入的密钥值
|
|
||||||
*/
|
|
||||||
async function writePacketsToKeyCharacteristic(ble, keyText) {
|
|
||||||
const { key } = ble.getState().characteristics;
|
|
||||||
|
|
||||||
if (!key || !key.uuid) {
|
|
||||||
throw new Error('未发现 UUID_KEY 特征值');
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = ble.getProfile();
|
|
||||||
const keyValue = String(keyText || '').trim();
|
|
||||||
|
|
||||||
// if (keyValue.length !== profile.auth.keyLength) {
|
|
||||||
// throw new Error(`密钥长度必须为 ${profile.auth.keyLength}`)
|
|
||||||
// }
|
|
||||||
|
|
||||||
await writeA7Payload(ble, key.uuid, keyValue, profile.packet);
|
|
||||||
|
|
||||||
return keyValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入密钥并等待 UUID_STATE 返回预期状态码。
|
|
||||||
* 成功后自动设置 AUTHORIZED 阶段和 keyMatched 状态。
|
|
||||||
* @param {object} ble createBleCore 实例
|
|
||||||
* @param {string} keyText 密钥文本
|
|
||||||
* @param {number[]} expectedStateCodes 期望的状态码列表
|
|
||||||
* @param {object} options 超时等配置
|
|
||||||
*/
|
|
||||||
async function writeKeyAndExpectStates(
|
|
||||||
ble,
|
|
||||||
keyText,
|
|
||||||
expectedStateCodes,
|
|
||||||
options = {},
|
|
||||||
) {
|
|
||||||
// 设备订阅 STATE 后可能先发送 -1(未写密钥)/0(空闲) 等中间态,
|
|
||||||
// 若 rejectOnUnexpected:true 会导致中间态直接 reject,阻断鉴权流程。
|
|
||||||
const waitPromise = waitForProtocolState(ble, {
|
|
||||||
allowedCodes: expectedStateCodes,
|
|
||||||
rejectOnUnexpected: false,
|
|
||||||
timeout: options.timeout || 6000,
|
|
||||||
timeoutMessage: '等待 UUID_STATE 超时',
|
|
||||||
unexpectedMessage: '密钥流程状态异常',
|
|
||||||
});
|
|
||||||
authLog('STATE wait before writeKey, expect=', expectedStateCodes);
|
|
||||||
const keyValue = await writePacketsToKeyCharacteristic(ble, keyText);
|
|
||||||
authLog('key written, length=', keyValue);
|
|
||||||
try {
|
|
||||||
const stateCode = Number((await waitPromise).stateCode);
|
|
||||||
authLog('STATE received for KEY=', stateCode);
|
|
||||||
ble.setStage(BLE_STAGE.AUTHORIZED);
|
|
||||||
ble.setAuthState({ keyMatched: true, lastStateCode: stateCode });
|
|
||||||
return { success: true, stateCode, key: keyValue };
|
|
||||||
} catch (error) {
|
|
||||||
// 鉴权超时多为黑盒:密钥不匹配(STATE=8)、F301 订阅失败、或写入丢失均表现为超时。
|
|
||||||
// 这里带上绑定状态与设备最后上报的 STATE,区分根因方向。
|
|
||||||
const authState = ble.getState().auth || {};
|
|
||||||
const lastState = authState.lastStateCode;
|
|
||||||
const bound = authState.bound;
|
|
||||||
const expectedStr = expectedStateCodes.join('/');
|
|
||||||
const boundHint = (bound === null || bound === undefined)
|
|
||||||
? '绑定状态未知'
|
|
||||||
: `绑定状态=${bound}`;
|
|
||||||
const lastHint = (lastState === null || lastState === undefined)
|
|
||||||
? '设备未上报任何 STATE(可能 F301 订阅失败或密钥写入丢失)'
|
|
||||||
: `设备最后上报 STATE=${lastState}(期望 ${expectedStr},可能密钥不匹配)`;
|
|
||||||
throw new Error(`密钥鉴权超时(${boundHint},${lastHint})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据设备绑定状态执行鉴权流程:
|
|
||||||
* - bindStatus=0(未绑定):生成随机密钥写入,等待 STATE=5
|
|
||||||
* - bindStatus=1(已绑定):使用已知密钥匹配,等待 STATE=7
|
|
||||||
* - 其他状态:尝试用提供的密钥匹配,等待 STATE=7 或 5
|
|
||||||
*/
|
|
||||||
async function ensureAuthorizedWithDeviceInfo(
|
|
||||||
ble,
|
|
||||||
info,
|
|
||||||
keyText,
|
|
||||||
options = {},
|
|
||||||
) {
|
|
||||||
const bindStatus = info.bindStatus !== null ? Number(info.bindStatus) : null;
|
|
||||||
const profile = ble.getProfile();
|
|
||||||
|
|
||||||
ble.setAuthState({
|
|
||||||
bound: Number.isNaN(bindStatus) ? null : bindStatus,
|
|
||||||
keyMatched: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (bindStatus === 0) {
|
|
||||||
const keyToBind =
|
|
||||||
String(keyText || '').trim() || generateRandomKey(profile.auth.keyLength);
|
|
||||||
const authResult = await writeKeyAndExpectStates(
|
|
||||||
ble,
|
|
||||||
keyToBind,
|
|
||||||
[5],
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: 'BIND',
|
|
||||||
deviceInfo: info,
|
|
||||||
authResult,
|
|
||||||
key: keyToBind,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bindStatus === 1) {
|
|
||||||
const keyToMatch = String(keyText || '').trim();
|
|
||||||
if (!keyToMatch) {
|
|
||||||
throw new Error('设备已绑定,必须提供已绑定密钥用于匹配');
|
|
||||||
}
|
|
||||||
|
|
||||||
const authResult = await writeKeyAndExpectStates(
|
|
||||||
ble,
|
|
||||||
keyToMatch,
|
|
||||||
[7],
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: 'MATCH',
|
|
||||||
deviceInfo: info,
|
|
||||||
authResult,
|
|
||||||
key: keyToMatch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackKey = String(keyText || '').trim();
|
|
||||||
if (!fallbackKey) {
|
|
||||||
throw new Error(`无法识别的绑定状态: ${info.bindStatus},且未提供密钥`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackResult = await writeKeyAndExpectStates(
|
|
||||||
ble,
|
|
||||||
fallbackKey,
|
|
||||||
[7, 5],
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: 'UNKNOWN_BIND_STATE_FALLBACK',
|
|
||||||
deviceInfo: info,
|
|
||||||
authResult: fallbackResult,
|
|
||||||
key: fallbackKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 认证模块工厂:封装设备绑定/密钥匹配全流程。
|
|
||||||
* @param {object} ble createBleCore 实例
|
|
||||||
*/
|
|
||||||
export function createAuthModule(ble) {
|
|
||||||
/**
|
|
||||||
* 读取 UUID_MSG 获取设备信息(MAC/绑定状态/产品ID等)。
|
|
||||||
* 先订阅 protocol:msg 事件,再触发 read,保证不丢通知。
|
|
||||||
*/
|
|
||||||
async function readDeviceInfo(options = {}) {
|
|
||||||
const { msg } = ble.getState().characteristics;
|
|
||||||
|
|
||||||
if (!msg || !msg.uuid) {
|
|
||||||
throw new Error('未发现 UUID_MSG 特征值');
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = Number(options.msgTimeout) || 4000;
|
|
||||||
let timer = null;
|
|
||||||
let off = null;
|
|
||||||
|
|
||||||
const valuePromise = new Promise((resolve, reject) => {
|
|
||||||
const finish = (callback) => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
if (off) off();
|
|
||||||
callback();
|
|
||||||
};
|
|
||||||
|
|
||||||
off = ble.on('protocol:msg', (payload) => finish(() => resolve(payload)));
|
|
||||||
timer = setTimeout(
|
|
||||||
() => finish(() => reject(new Error('读取 UUID_MSG 超时'))),
|
|
||||||
timeout,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ble.readCharacteristic(msg.uuid);
|
|
||||||
const payload = await valuePromise;
|
|
||||||
const info = parseDeviceInfo(payload.value, ble.getProfile().id);
|
|
||||||
// 打印 MSG 原始字节与解析结果,验证 bindStatus/MAC 是否解析正确
|
|
||||||
try {
|
|
||||||
const rawBytes = Array.from(payload.value || []);
|
|
||||||
const hex = rawBytes
|
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
|
||||||
.join(' ');
|
|
||||||
console.debug('[BLE][AUTH]', 'MSG raw hex=', hex, 'parsed=', JSON.stringify(info));
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
return info;
|
|
||||||
} catch (error) {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
if (off) off();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 严格遵循协议1.2绑定流程:
|
|
||||||
* 1) 读取 UUID_MSG 的 bindStatus;
|
|
||||||
* 2) bindStatus=0 写随机/指定密钥并等待 STATE=5;
|
|
||||||
* 3) bindStatus=1 写已知密钥并等待 STATE=7。
|
|
||||||
*/
|
|
||||||
async function ensureAuthorized(keyText, options = {}) {
|
|
||||||
ble.setStage(BLE_STAGE.AUTHORIZING);
|
|
||||||
const info = await readDeviceInfo(options);
|
|
||||||
return ensureAuthorizedWithDeviceInfo(ble, info, keyText, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 固定规则鉴权:密钥 = 设备MAC(去分隔符) + “0000”。
|
|
||||||
* 用于”先鉴权再进行 WiFi 配置”的页面流程。
|
|
||||||
*/
|
|
||||||
async function ensureAuthorizedByMac(options = {}) {
|
|
||||||
ble.setStage(BLE_STAGE.AUTHORIZING);
|
|
||||||
|
|
||||||
const info = await readDeviceInfo(options);
|
|
||||||
const profile = ble.getProfile();
|
|
||||||
const keyByMac = buildKeyFromMac(
|
|
||||||
info.mac,
|
|
||||||
options.suffix || '0000',
|
|
||||||
profile.auth.keyLength,
|
|
||||||
);
|
|
||||||
|
|
||||||
const authResult = await ensureAuthorizedWithDeviceInfo(
|
|
||||||
ble,
|
|
||||||
info,
|
|
||||||
keyByMac,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...authResult,
|
|
||||||
key: keyByMac,
|
|
||||||
keyRule: 'MAC+0000',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
readDeviceInfo,
|
|
||||||
ensureAuthorized,
|
|
||||||
ensureAuthorizedByMac,
|
|
||||||
buildKeyFromMac,
|
|
||||||
generateRandomKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
import { parseRadarData } from '../parsers';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 雷达控制模块:管理雷达启停、窄床模式、跌倒参数写入。
|
|
||||||
* 所有命令发送前均校验鉴权状态。
|
|
||||||
* @param {object} ble createBleCore 实例
|
|
||||||
*/
|
|
||||||
export function createRadarModule(ble) {
|
|
||||||
function requireAuthorized() {
|
|
||||||
const state = ble.getState();
|
|
||||||
|
|
||||||
// 协议要求:获取雷达校准数据前必须先完成密钥匹配。
|
|
||||||
if (!state.auth || state.auth.keyMatched !== true) {
|
|
||||||
throw new Error('请先完成密钥匹配,再执行雷达命令');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 向 UUID_CMD 写入单字节指令。
|
|
||||||
* @param {number} commandByte 指令字节(如 0xA1 启动、0xA2 停止)
|
|
||||||
*/
|
|
||||||
async function sendCommand(commandByte) {
|
|
||||||
const { cmd } = ble.getState().characteristics;
|
|
||||||
|
|
||||||
if (!cmd || !cmd.uuid) {
|
|
||||||
throw new Error('未发现 UUID_CMD 特征值');
|
|
||||||
}
|
|
||||||
|
|
||||||
await ble.writeCharacteristic(cmd.uuid, new Uint8Array([commandByte]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 启动雷达数据流(写入 CMD = 0xA1)。 */
|
|
||||||
async function startRadarStream() {
|
|
||||||
const profile = ble.getProfile();
|
|
||||||
|
|
||||||
// 协议1.2.2:先密钥匹配,再向 UUID_CMD 写 0xA1。
|
|
||||||
requireAuthorized();
|
|
||||||
await sendCommand(profile.radar.startCommand);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 停止雷达数据流(写入 CMD = 0xA2)。 */
|
|
||||||
async function stopRadarStream() {
|
|
||||||
const profile = ble.getProfile();
|
|
||||||
|
|
||||||
// 停止同样要求处于鉴权通过态,避免误发到未鉴权连接。
|
|
||||||
requireAuthorized();
|
|
||||||
await sendCommand(profile.radar.stopCommand);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置窄床模式(仅 ED713 支持)。
|
|
||||||
* 协议:CMD=0x7C,参数1byte(0=关, 1=开),设置后雷达重启。
|
|
||||||
* @param {boolean} enabled true=开启,false=关闭
|
|
||||||
*/
|
|
||||||
async function setNarrowMode(enabled) {
|
|
||||||
const profile = ble.getProfile();
|
|
||||||
|
|
||||||
if (!profile.cmd.supportsNarrowMode) {
|
|
||||||
throw new Error('当前设备不支持窄床模式设置');
|
|
||||||
}
|
|
||||||
|
|
||||||
requireAuthorized();
|
|
||||||
|
|
||||||
// 协议规定:写入 [0x7C, flag] 两字节
|
|
||||||
const { cmd } = ble.getState().characteristics;
|
|
||||||
if (!cmd || !cmd.uuid) {
|
|
||||||
throw new Error('未发现 UUID_CMD 特征值');
|
|
||||||
}
|
|
||||||
await ble.writeCharacteristic(
|
|
||||||
cmd.uuid,
|
|
||||||
new Uint8Array([0x7c, enabled ? 1 : 0]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入跌倒参数(仅 ED719 支持,指令前缀 0x67)。
|
|
||||||
* @param {Uint8Array|number[]} rawParams 跌倒参数字节
|
|
||||||
*/
|
|
||||||
async function writeFallParams(rawParams) {
|
|
||||||
const profile = ble.getProfile();
|
|
||||||
|
|
||||||
if (!profile.cmd.supportsFallParam67) {
|
|
||||||
throw new Error('当前设备不支持 0x67 跌倒参数写入');
|
|
||||||
}
|
|
||||||
|
|
||||||
requireAuthorized();
|
|
||||||
|
|
||||||
const payload =
|
|
||||||
rawParams instanceof Uint8Array ? rawParams : new Uint8Array(rawParams);
|
|
||||||
const packet = new Uint8Array(1 + payload.length);
|
|
||||||
packet[0] = 0x67;
|
|
||||||
packet.set(payload, 1);
|
|
||||||
|
|
||||||
const { cmd } = ble.getState().characteristics;
|
|
||||||
if (!cmd || !cmd.uuid) {
|
|
||||||
throw new Error('未发现 UUID_CMD 特征值');
|
|
||||||
}
|
|
||||||
|
|
||||||
await ble.writeCharacteristic(cmd.uuid, packet);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按当前 profile 解析雷达通知数据。 */
|
|
||||||
function parseNotifyPayload(payload) {
|
|
||||||
const profile = ble.getProfile();
|
|
||||||
return parseRadarData(payload, profile.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sendCommand,
|
|
||||||
startRadarStream,
|
|
||||||
stopRadarStream,
|
|
||||||
setNarrowMode,
|
|
||||||
writeFallParams,
|
|
||||||
parseNotifyPayload,
|
|
||||||
requireAuthorized,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
import { writeA7Payload } from '../core/packet'
|
|
||||||
import { waitForProtocolState } from '../core/stateWait'
|
|
||||||
|
|
||||||
const WIFI_FINAL_STATE_CODES = Object.freeze([1, 2, 3])
|
|
||||||
|
|
||||||
// STATE=4 为 WiFi 连接中中间态,收到后用于刷新等待窗口
|
|
||||||
const WIFI_CONNECTING_STATE_CODES = Object.freeze([4])
|
|
||||||
|
|
||||||
// 总等待上限:覆盖弱信号/5G/企业级路由真实 DHCP 时长
|
|
||||||
const WIFI_TOTAL_TIMEOUT_MS = 60000
|
|
||||||
// 收到 STATE=4 后的单次刷新窗口
|
|
||||||
const WIFI_REFRESH_TIMEOUT_MS = 30000
|
|
||||||
// ED713 分包写入间隔:连续 writeWithoutResponse 易丢包,固件需留处理时间
|
|
||||||
const ED713_PACKET_INTERVAL_MS = 50
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验 WiFi 入参,规则来源于 profile.wifi。
|
|
||||||
*/
|
|
||||||
function validateWifi(profile, ssid, password) {
|
|
||||||
const errors = []
|
|
||||||
|
|
||||||
if (!ssid || ssid.length > profile.wifi.maxSsidLength) {
|
|
||||||
errors.push(`SSID 长度需在 1-${profile.wifi.maxSsidLength} 之间`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < profile.wifi.minPasswordLength || password.length > profile.wifi.maxPasswordLength) {
|
|
||||||
errors.push(`密码长度需在 ${profile.wifi.minPasswordLength}-${profile.wifi.maxPasswordLength} 之间`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const invalidChars = profile.wifi.invalidChars || []
|
|
||||||
if (invalidChars.some((char) => ssid.includes(char))) {
|
|
||||||
errors.push(`SSID 不能包含特殊字符: ${invalidChars.join(' ')}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errors.length) {
|
|
||||||
throw new Error(errors.join(';'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验鉴权状态。
|
|
||||||
* 厂家确认密钥流程已不启用,此处仅记录未鉴权状态,不阻断 WiFi 配置。
|
|
||||||
*/
|
|
||||||
function requireAuthorized(ble) {
|
|
||||||
const state = ble.getState()
|
|
||||||
|
|
||||||
if (!state.auth || state.auth.keyMatched !== true) {
|
|
||||||
console.debug('[BLE][WiFi]', '密钥未匹配(密钥流程已废弃,继续配网)')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WiFi 配网模块:负责打包发送 ssid|password,并等待 STATE 回执。
|
|
||||||
* 使用前必须先完成密钥鉴权。
|
|
||||||
* @param {object} ble createBleCore 实例
|
|
||||||
*/
|
|
||||||
export function createWifiModule(ble) {
|
|
||||||
function delay(ms) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, Number(ms) || 0))
|
|
||||||
}
|
|
||||||
const log = (...args) => { try { console.debug('[BLE][WiFi]', ...args) } catch (e) {} }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送WiFi配网指令。
|
|
||||||
* 将 ssid|password 按 A7 分包写入 UUID_WIFI,等待 STATE 回执判定结果。
|
|
||||||
* @param {string} ssid WiFi名称
|
|
||||||
* @param {string} password WiFi密码
|
|
||||||
* @param {object} options
|
|
||||||
* @param {number} options.timeout 等待状态回执总超时(ms),默认60000
|
|
||||||
* @param {number} options.preDelayMs 写入前延迟(ms),ED713 默认120ms
|
|
||||||
* @param {number} options.packetInterval 分包写入间隔(ms)
|
|
||||||
* @returns {{ success: boolean, stateCode: number }} stateCode=3 表示连接成功
|
|
||||||
*/
|
|
||||||
async function configureWifi(ssid, password, options = {}) {
|
|
||||||
const profile = ble.getProfile()
|
|
||||||
const s = String(ssid || '').trim()
|
|
||||||
const p = String(password || '').trim()
|
|
||||||
validateWifi(profile, s, p)
|
|
||||||
requireAuthorized(ble)
|
|
||||||
|
|
||||||
const { wifi } = ble.getState().characteristics
|
|
||||||
if (!wifi || !wifi.uuid) {
|
|
||||||
throw new Error('未发现 UUID_WIFI 特征值')
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalTimeout = options.timeout || WIFI_TOTAL_TIMEOUT_MS
|
|
||||||
// 先注册 STATE 等待,再写入,确保不漏回执
|
|
||||||
// 收到 STATE=4(连接中) 时刷新窗口,避免固定上限短于真实 DHCP 时长误判超时
|
|
||||||
const waitPromise = waitForProtocolState(ble, {
|
|
||||||
allowedCodes: WIFI_FINAL_STATE_CODES,
|
|
||||||
rejectOnUnexpected: false,
|
|
||||||
timeout: totalTimeout,
|
|
||||||
timeoutMessage: '等待 WiFi 状态超时',
|
|
||||||
refreshOnCodes: WIFI_CONNECTING_STATE_CODES,
|
|
||||||
refreshTimeout: WIFI_REFRESH_TIMEOUT_MS
|
|
||||||
})
|
|
||||||
|
|
||||||
// ED713 固件需要写入前延迟,否则可能丢包
|
|
||||||
const pre = options.preDelayMs ?? (profile.id === 'ED713' ? 120 : 0)
|
|
||||||
if (pre) {
|
|
||||||
log('preDelay before WiFi write(ms)=', pre)
|
|
||||||
await delay(pre)
|
|
||||||
}
|
|
||||||
|
|
||||||
log('write WiFi start', { service: ble.getState().serviceId, char: wifi.uuid })
|
|
||||||
const packetInterval = Number(options.packetInterval) || (profile.id === 'ED713' ? ED713_PACKET_INTERVAL_MS : 0)
|
|
||||||
const sent = await writeA7Payload(ble, wifi.uuid, `${s}|${p}`, profile.packet, {
|
|
||||||
packetInterval
|
|
||||||
})
|
|
||||||
log('write WiFi done, packets=', sent, 'interval=', packetInterval)
|
|
||||||
|
|
||||||
let code
|
|
||||||
try {
|
|
||||||
code = Number((await waitPromise).stateCode)
|
|
||||||
} catch (error) {
|
|
||||||
// 超时时带上设备最后上报的 STATE 码,便于区分"设备未响应"与"设备回了非预期态"
|
|
||||||
const lastState = ble.getState().auth?.lastStateCode
|
|
||||||
const hint = (lastState === undefined || lastState === null)
|
|
||||||
? '(设备未上报任何 STATE,可能 WiFi 分包写入丢失或固件未处理)'
|
|
||||||
: `(设备最后上报 STATE=${lastState},未收到最终态 1/2/3)`
|
|
||||||
throw new Error(`${error.message}${hint}`)
|
|
||||||
}
|
|
||||||
log('STATE(final) received for WiFi=', code)
|
|
||||||
|
|
||||||
// STATE=3 表示 WiFi 连接成功
|
|
||||||
return { success: code === 3, stateCode: code }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { configureWifi, requireAuthorized }
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
import {bytesToHex, bytesToMac} from '../utils/hex'
|
|
||||||
import {joinLowHigh, readUint16LE, toUint8Array} from '../utils/bytes'
|
|
||||||
import {DEVICE_PROFILE} from '../protocols/profiles'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 UUID_STATE 的状态字节,转为有符号 8bit。
|
|
||||||
*/
|
|
||||||
export function parseStateCode(payload) {
|
|
||||||
const bytes = toUint8Array(payload)
|
|
||||||
if (!bytes.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytes[0] > 127 ? bytes[0] - 256 : bytes[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ED713 MSG 解析。
|
|
||||||
* 文档布局:MAC(6) + bindStatus(1) + 其它(3) + productId_H(1) + productId_L(1)
|
|
||||||
* + baseBoard(1) + coreBoard(1) + 其它(2) + deviceType(1)
|
|
||||||
* + wifiStatus(1) + cellularStatus(1) + 其它(1)
|
|
||||||
*/
|
|
||||||
function parseEd713DeviceInfo(bytes) {
|
|
||||||
if (!bytes.length) {
|
|
||||||
return {
|
|
||||||
raw: bytes, mac: '', bindStatus: null, productId: null,
|
|
||||||
deviceType: null, wifiStatus: null, cellularStatus: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
raw: bytes,
|
|
||||||
mac: bytesToMac(bytes.slice(0, 6)),
|
|
||||||
bindStatus: bytes.length > 6 ? bytes[6] : null,
|
|
||||||
productId: bytes.length > 11 ? joinLowHigh(bytes[11], bytes[10]) : null,
|
|
||||||
baseBoardVersion: bytes.length > 12 ? bytes[12] : null,
|
|
||||||
coreBoardVersion: bytes.length > 13 ? bytes[13] : null,
|
|
||||||
deviceType: bytes.length > 16 ? bytes[16] : null,
|
|
||||||
wifiStatus: bytes.length > 17 ? bytes[17] : null,
|
|
||||||
cellularStatus: bytes.length > 18 ? bytes[18] : null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ED719 MSG 解析。
|
|
||||||
* 实际偏移与 ED713 一致(文档省略了 bindStatus/productId 但底板数据布局相同):
|
|
||||||
* MAC(6) + bindStatus(1) + 其它(3) + productId_H(1) + productId_L(1)
|
|
||||||
* + baseBoard(1) + coreBoard(1) + 其它(2) + deviceType(1)
|
|
||||||
* + wifiStatus(1) + cellularStatus(1) + 其它(1)
|
|
||||||
*/
|
|
||||||
function parseEd719DeviceInfo(bytes) {
|
|
||||||
return parseEd713DeviceInfo(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析 UUID_MSG 设备信息(按 profile 区分 ED713/ED719)。
|
|
||||||
*/
|
|
||||||
export function parseDeviceInfo(payload, profileId = DEVICE_PROFILE.UNKNOWN) {
|
|
||||||
const bytes = toUint8Array(payload)
|
|
||||||
|
|
||||||
if (!bytes.length) {
|
|
||||||
return {
|
|
||||||
raw: bytes, mac: '', bindStatus: null, productId: null,
|
|
||||||
deviceType: null, wifiStatus: null, cellularStatus: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profileId === DEVICE_PROFILE.ED713) return parseEd713DeviceInfo(bytes)
|
|
||||||
if (profileId === DEVICE_PROFILE.ED719) return parseEd719DeviceInfo(bytes)
|
|
||||||
|
|
||||||
// UNKNOWN:按 ED713 布局兼容
|
|
||||||
return parseEd713DeviceInfo(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ED713 雷达数据解析。
|
|
||||||
* 协议帧长度不足时返回 valid:false,交由上层决定是否忽略。
|
|
||||||
*/
|
|
||||||
export function parseEd713Radar(payload) {
|
|
||||||
const bytes = toUint8Array(payload)
|
|
||||||
|
|
||||||
if (bytes.length < 60) {
|
|
||||||
return {
|
|
||||||
type: 'ED713',
|
|
||||||
valid: false,
|
|
||||||
reason: 'LENGTH_LT_60',
|
|
||||||
raw: bytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'ED713',
|
|
||||||
valid: true,
|
|
||||||
raw: bytes,
|
|
||||||
peopleCount: bytes[24],
|
|
||||||
peopleLocalDecimeter: bytes[25],
|
|
||||||
peopleState: bytes[26],
|
|
||||||
heartRate: bytes[28],
|
|
||||||
respRate: bytes[29],
|
|
||||||
activityState: bytes[30],
|
|
||||||
moveIndex: bytes[35],
|
|
||||||
waveNum: bytes[37],
|
|
||||||
respWave: Array.from(bytes.slice(38, 58)),
|
|
||||||
narrowFlag: bytes[59]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ED719 雷达数据解析。
|
|
||||||
* 按文档结构体定义:
|
|
||||||
* header[4] + event(1) + people(1) + num(1) + nodata(1)
|
|
||||||
* + height(2) + mode(1) + beeper(1) + left(2) + right(2) + front(2) + back(2)
|
|
||||||
* + sensitive(1) + nodata1(1) + stateDelay(2) = 24 bytes
|
|
||||||
* shield_num 在 offset 24,屏蔽区在 offset 25
|
|
||||||
* 屏蔽区之后:wallOption + peopleHaveSwitch + peopleActivitySwitch
|
|
||||||
*/
|
|
||||||
export function parseEd719Radar(payload) {
|
|
||||||
const bytes = toUint8Array(payload)
|
|
||||||
|
|
||||||
if (bytes.length < 24) {
|
|
||||||
return {
|
|
||||||
type: 'ED719',
|
|
||||||
valid: false,
|
|
||||||
reason: 'LENGTH_LT_24',
|
|
||||||
raw: bytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shieldNum = bytes[24] || 0
|
|
||||||
const shieldStart = 25
|
|
||||||
const shieldBlockLength = Math.min(Math.max(shieldNum, 0), 4) * 9
|
|
||||||
|
|
||||||
const wallOffset = shieldStart + shieldBlockLength
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'ED719',
|
|
||||||
valid: true,
|
|
||||||
raw: bytes,
|
|
||||||
eventState: bytes[4],
|
|
||||||
peopleState: bytes[5],
|
|
||||||
peopleNum: bytes[6],
|
|
||||||
installHeight: readUint16LE(bytes, 8),
|
|
||||||
installMode: bytes[10],
|
|
||||||
beeperOn: bytes[11],
|
|
||||||
leftDist: readUint16LE(bytes, 12),
|
|
||||||
rightDist: readUint16LE(bytes, 14),
|
|
||||||
frontDist: readUint16LE(bytes, 16),
|
|
||||||
backDist: readUint16LE(bytes, 18),
|
|
||||||
sensitive: bytes[20],
|
|
||||||
stateDelay: readUint16LE(bytes, 22),
|
|
||||||
shieldNum,
|
|
||||||
shieldZoneRaw: Array.from(bytes.slice(shieldStart, shieldStart + shieldBlockLength)),
|
|
||||||
wallOption: bytes[wallOffset] ?? null,
|
|
||||||
peopleHaveSwitch: bytes[wallOffset + 1] ?? null,
|
|
||||||
peopleActivitySwitch: bytes[wallOffset + 2] ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
/**
|
|
||||||
* 解析器统一出口。
|
|
||||||
* 对外暴露 parseStateCode / parseDeviceInfo / parseRadarData 三个顶层函数。
|
|
||||||
*/
|
|
||||||
import { parseDeviceInfo, parseEd713Radar, parseEd719Radar, parseStateCode } from './commonParser'
|
|
||||||
import { DEVICE_PROFILE } from '../protocols/profiles'
|
|
||||||
|
|
||||||
export { parseDeviceInfo, parseStateCode }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 雷达数据统一解析入口:
|
|
||||||
* 优先按已识别 profile 解析,未知时再根据签名字节推断。
|
|
||||||
*/
|
|
||||||
export function parseRadarData(payload, profileId = DEVICE_PROFILE.UNKNOWN) {
|
|
||||||
if (profileId === DEVICE_PROFILE.ED713) {
|
|
||||||
return parseEd713Radar(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profileId === DEVICE_PROFILE.ED719) {
|
|
||||||
return parseEd719Radar(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = payload instanceof Uint8Array ? payload : new Uint8Array(payload)
|
|
||||||
|
|
||||||
if (bytes[3] === 0x7C) {
|
|
||||||
return parseEd713Radar(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bytes[3] === 0x67) {
|
|
||||||
return parseEd719Radar(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'UNKNOWN',
|
|
||||||
valid: false,
|
|
||||||
reason: 'UNKNOWN_SIGNATURE',
|
|
||||||
raw: bytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { toFullUuid } from '../utils/uuid'
|
|
||||||
|
|
||||||
// ED713 / ED719 当前协议共用的服务与特征 UUID。
|
|
||||||
const COMMON_UUIDS = Object.freeze({
|
|
||||||
SERVICE: '00F4',
|
|
||||||
WIFI: 'F401',
|
|
||||||
KEY: 'F402',
|
|
||||||
CMD: 'F403',
|
|
||||||
MSG: 'F501',
|
|
||||||
STATE: 'F301',
|
|
||||||
RADAR: 'F302'
|
|
||||||
})
|
|
||||||
|
|
||||||
// UUID_STATE 常用状态码语义映射。
|
|
||||||
const COMMON_STATE_MAP = Object.freeze({
|
|
||||||
1: 'WIFI_PARAM_INVALID',
|
|
||||||
2: 'WIFI_CONNECT_FAILED',
|
|
||||||
3: 'WIFI_CONNECT_SUCCESS',
|
|
||||||
4: 'WIFI_CONNECTING',
|
|
||||||
5: 'BIND_SUCCESS',
|
|
||||||
6: 'BIND_FAILED',
|
|
||||||
7: 'KEY_MATCH_SUCCESS',
|
|
||||||
8: 'KEY_MATCH_FAILED'
|
|
||||||
})
|
|
||||||
|
|
||||||
export const DEVICE_PROFILE = Object.freeze({
|
|
||||||
UNKNOWN: 'UNKNOWN',
|
|
||||||
ED713: 'ED713',
|
|
||||||
ED719: 'ED719'
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成设备 profile 模板:
|
|
||||||
* 包含 UUID、分包规则、鉴权策略、WiFi 校验、雷达命令能力。
|
|
||||||
*/
|
|
||||||
function buildCommonProfile(id) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
uuids: {
|
|
||||||
service: toFullUuid(COMMON_UUIDS.SERVICE),
|
|
||||||
wifi: toFullUuid(COMMON_UUIDS.WIFI),
|
|
||||||
key: toFullUuid(COMMON_UUIDS.KEY),
|
|
||||||
cmd: toFullUuid(COMMON_UUIDS.CMD),
|
|
||||||
msg: toFullUuid(COMMON_UUIDS.MSG),
|
|
||||||
state: toFullUuid(COMMON_UUIDS.STATE),
|
|
||||||
radar: toFullUuid(COMMON_UUIDS.RADAR)
|
|
||||||
},
|
|
||||||
stateMap: { ...COMMON_STATE_MAP },
|
|
||||||
packet: {
|
|
||||||
head: 0xA7,
|
|
||||||
chunkSize: 18,
|
|
||||||
maxChunk: 15
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
keyLength: 16,
|
|
||||||
supportsUnboundState: false,
|
|
||||||
unboundStateCode: null
|
|
||||||
},
|
|
||||||
wifi: {
|
|
||||||
maxSsidLength: 32,
|
|
||||||
minPasswordLength: 8,
|
|
||||||
maxPasswordLength: 63,
|
|
||||||
invalidChars: ['+', '#', '&', '=', '|', '<', '>', '^', '"', '\\']
|
|
||||||
},
|
|
||||||
radar: {
|
|
||||||
notifySignature: null,
|
|
||||||
startCommand: 0xA1,
|
|
||||||
stopCommand: 0xA2,
|
|
||||||
parseMode: 'GENERIC'
|
|
||||||
},
|
|
||||||
cmd: {
|
|
||||||
supportsNarrowMode: false,
|
|
||||||
supportsFallParam67: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const unknownProfile = buildCommonProfile(DEVICE_PROFILE.UNKNOWN)
|
|
||||||
|
|
||||||
const ed713Profile = {
|
|
||||||
...buildCommonProfile(DEVICE_PROFILE.ED713),
|
|
||||||
auth: {
|
|
||||||
keyLength: 16,
|
|
||||||
supportsUnboundState: true,
|
|
||||||
unboundStateCode: -1
|
|
||||||
},
|
|
||||||
radar: {
|
|
||||||
notifySignature: 0x7C,
|
|
||||||
startCommand: 0xA1,
|
|
||||||
stopCommand: 0xA2,
|
|
||||||
parseMode: 'ED713'
|
|
||||||
},
|
|
||||||
cmd: {
|
|
||||||
supportsNarrowMode: true,
|
|
||||||
supportsFallParam67: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ed719Profile = {
|
|
||||||
...buildCommonProfile(DEVICE_PROFILE.ED719),
|
|
||||||
radar: {
|
|
||||||
notifySignature: 0x67,
|
|
||||||
startCommand: 0xA1,
|
|
||||||
stopCommand: 0xA2,
|
|
||||||
parseMode: 'ED719'
|
|
||||||
},
|
|
||||||
cmd: {
|
|
||||||
supportsNarrowMode: false,
|
|
||||||
supportsFallParam67: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BLE_PROFILE_MAP = Object.freeze({
|
|
||||||
[DEVICE_PROFILE.UNKNOWN]: unknownProfile,
|
|
||||||
[DEVICE_PROFILE.ED713]: ed713Profile,
|
|
||||||
[DEVICE_PROFILE.ED719]: ed719Profile
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据设备型号获取对应 BLE Profile 配置。
|
|
||||||
* @param {string} profileId 设备型号ID,默认 UNKNOWN
|
|
||||||
* @returns {object} profile 配置对象
|
|
||||||
*/
|
|
||||||
export function getBleProfile(profileId = DEVICE_PROFILE.UNKNOWN) {
|
|
||||||
return BLE_PROFILE_MAP[profileId] || BLE_PROFILE_MAP[DEVICE_PROFILE.UNKNOWN]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据雷达帧签名字节粗略推断设备类型。
|
|
||||||
*/
|
|
||||||
export function guessProfileByRadarFrame(frame = new Uint8Array()) {
|
|
||||||
const signature = frame[3]
|
|
||||||
|
|
||||||
if (signature === 0x7C) {
|
|
||||||
return DEVICE_PROFILE.ED713
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signature === 0x67) {
|
|
||||||
return DEVICE_PROFILE.ED719
|
|
||||||
}
|
|
||||||
|
|
||||||
return DEVICE_PROFILE.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
/**
|
|
||||||
* 把常见二进制输入统一转换成 Uint8Array。
|
|
||||||
* 支持:Uint8Array / ArrayBuffer / TypedArray / number[]。
|
|
||||||
*/
|
|
||||||
export function toUint8Array(value) {
|
|
||||||
if (value instanceof Uint8Array) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value instanceof ArrayBuffer) {
|
|
||||||
return new Uint8Array(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ArrayBuffer.isView(value)) {
|
|
||||||
return new Uint8Array(value.buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return Uint8Array.from(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Uint8Array()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成“刚好覆盖有效字节范围”的 ArrayBuffer。
|
|
||||||
* 避免把 TypedArray 背后多余 buffer 区域一并透传。
|
|
||||||
*/
|
|
||||||
export function uint8ArrayToArrayBuffer(value) {
|
|
||||||
const bytes = toUint8Array(value)
|
|
||||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把多个字节数组按顺序拼接。
|
|
||||||
*/
|
|
||||||
export function concatUint8Arrays(list = []) {
|
|
||||||
const bytesList = list.map((item) => toUint8Array(item))
|
|
||||||
const total = bytesList.reduce((sum, item) => sum + item.length, 0)
|
|
||||||
|
|
||||||
const merged = new Uint8Array(total)
|
|
||||||
let offset = 0
|
|
||||||
|
|
||||||
bytesList.forEach((item) => {
|
|
||||||
merged.set(item, offset)
|
|
||||||
offset += item.length
|
|
||||||
})
|
|
||||||
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按 chunkSize 对字节流分片。
|
|
||||||
* chunkSize 无效时返回原数据单片。
|
|
||||||
*/
|
|
||||||
export function splitUint8Array(value, chunkSize) {
|
|
||||||
const bytes = toUint8Array(value)
|
|
||||||
|
|
||||||
if (!chunkSize || chunkSize <= 0) {
|
|
||||||
return [bytes]
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks = []
|
|
||||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
|
||||||
chunks.push(bytes.slice(index, index + chunkSize))
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按小端序读取 16 位无符号整数。
|
|
||||||
*/
|
|
||||||
export function readUint16LE(bytes, offset) {
|
|
||||||
const value = toUint8Array(bytes)
|
|
||||||
const low = value[offset] || 0
|
|
||||||
const high = value[offset + 1] || 0
|
|
||||||
return (high << 8) | low
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按小端序读取 32 位无符号整数。
|
|
||||||
*/
|
|
||||||
export function readUint32LE(bytes, offset) {
|
|
||||||
const value = toUint8Array(bytes)
|
|
||||||
return ((value[offset + 3] || 0) << 24) >>> 0
|
|
||||||
| ((value[offset + 2] || 0) << 16)
|
|
||||||
| ((value[offset + 1] || 0) << 8)
|
|
||||||
| (value[offset] || 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把 16 位整数拆成低位/高位两个字节(小端常用)。
|
|
||||||
*/
|
|
||||||
export function splitToLowHigh(value) {
|
|
||||||
const normalized = Number(value) || 0
|
|
||||||
return {
|
|
||||||
low: normalized & 0xFF,
|
|
||||||
high: (normalized >> 8) & 0xFF
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把低位/高位字节还原成 16 位整数。
|
|
||||||
*/
|
|
||||||
export function joinLowHigh(low, high) {
|
|
||||||
return ((Number(high) || 0) << 8) | (Number(low) || 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把 0~255 转成有符号 int8(-128~127)。
|
|
||||||
*/
|
|
||||||
export function toSignedInt8(value) {
|
|
||||||
const number = Number(value) || 0
|
|
||||||
return number > 127 ? number - 256 : number
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { toUint8Array } from './bytes'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 字节数组转十六进制字符串。
|
|
||||||
* withSpace=true 时输出 "aa bb cc",否则输出 "aabbcc"。
|
|
||||||
*/
|
|
||||||
export function bytesToHex(input, withSpace = false) {
|
|
||||||
const bytes = toUint8Array(input)
|
|
||||||
const parts = Array.from(bytes).map((byte) => byte.toString(16).padStart(2, '0'))
|
|
||||||
return withSpace ? parts.join(' ') : parts.join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 十六进制字符串转 Uint8Array。
|
|
||||||
* 会自动忽略分隔符,并兼容奇数字符长度(自动左补0)。
|
|
||||||
*/
|
|
||||||
export function hexToBytes(hexText = '') {
|
|
||||||
const cleaned = String(hexText || '').replace(/[^0-9a-fA-F]/g, '')
|
|
||||||
|
|
||||||
if (!cleaned) {
|
|
||||||
return new Uint8Array()
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = cleaned.length % 2 === 0 ? cleaned : `0${cleaned}`
|
|
||||||
const result = new Uint8Array(normalized.length / 2)
|
|
||||||
|
|
||||||
for (let index = 0; index < normalized.length; index += 2) {
|
|
||||||
result[index / 2] = parseInt(normalized.slice(index, index + 2), 16)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UTF-8 字符串转字节数组。
|
|
||||||
* 优先使用 TextEncoder,低版本环境走兼容路径。
|
|
||||||
*/
|
|
||||||
export function utf8ToBytes(text = '') {
|
|
||||||
if (typeof TextEncoder !== 'undefined') {
|
|
||||||
return new TextEncoder().encode(String(text || ''))
|
|
||||||
}
|
|
||||||
|
|
||||||
const encoded = unescape(encodeURIComponent(String(text || '')))
|
|
||||||
return Uint8Array.from(Array.from(encoded).map((char) => char.charCodeAt(0)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 字节数组转 UTF-8 字符串。
|
|
||||||
* 优先使用 TextDecoder,低版本环境走兼容路径。
|
|
||||||
*/
|
|
||||||
export function bytesToUtf8(input) {
|
|
||||||
const bytes = toUint8Array(input)
|
|
||||||
|
|
||||||
if (!bytes.length) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof TextDecoder !== 'undefined') {
|
|
||||||
return new TextDecoder('utf-8').decode(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
const latin = Array.from(bytes).map((byte) => String.fromCharCode(byte)).join('')
|
|
||||||
return decodeURIComponent(escape(latin))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把 MAC 字节转为 "aa:bb:cc:dd:ee:ff" 形式。
|
|
||||||
*/
|
|
||||||
export function bytesToMac(input) {
|
|
||||||
return bytesToHex(input, true).replace(/ /g, ':')
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
const BASE_UUID_SUFFIX = '0000-1000-8000-00805F9B34FB'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 规范化 UUID:移除非16进制字符并转大写。
|
|
||||||
*/
|
|
||||||
export function normalizeUuid(uuid) {
|
|
||||||
return String(uuid || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提取短 UUID(16-bit)用于不同写法间比较。
|
|
||||||
*/
|
|
||||||
export function shortUuid(uuid) {
|
|
||||||
const normalized = normalizeUuid(uuid)
|
|
||||||
|
|
||||||
if (!normalized) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.length === 4) {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.length === 8) {
|
|
||||||
return normalized.slice(4)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.length === 32) {
|
|
||||||
return normalized.slice(4, 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized.slice(-4)
|
|
||||||
}
|
|
||||||
|
|
||||||
function format32Uuid(normalized32) {
|
|
||||||
return [
|
|
||||||
normalized32.slice(0, 8),
|
|
||||||
normalized32.slice(8, 12),
|
|
||||||
normalized32.slice(12, 16),
|
|
||||||
normalized32.slice(16, 20),
|
|
||||||
normalized32.slice(20, 32)
|
|
||||||
].join('-')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把 16/32/128 位 UUID 统一转成标准 128 位字符串。
|
|
||||||
* 无法识别时返回空串。
|
|
||||||
*/
|
|
||||||
export function toFullUuid(uuid) {
|
|
||||||
const normalized = normalizeUuid(uuid)
|
|
||||||
|
|
||||||
if (!normalized) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.length === 32) {
|
|
||||||
return format32Uuid(normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.length === 8) {
|
|
||||||
return `${normalized}-${BASE_UUID_SUFFIX}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalized.length === 4) {
|
|
||||||
return `${`0000${normalized}`}-${BASE_UUID_SUFFIX}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UUID 宽松比较:支持完整格式一致或短 UUID 一致。
|
|
||||||
*/
|
|
||||||
export function uuidEquals(left, right) {
|
|
||||||
const leftNormalized = normalizeUuid(left)
|
|
||||||
const rightNormalized = normalizeUuid(right)
|
|
||||||
|
|
||||||
if (!leftNormalized || !rightNormalized) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return leftNormalized === rightNormalized || shortUuid(leftNormalized) === shortUuid(rightNormalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断目标 UUID 是否在列表中(使用 uuidEquals 宽松匹配)。
|
|
||||||
*/
|
|
||||||
export function uuidInList(targetUuid, uuidList = []) {
|
|
||||||
return uuidList.some((uuid) => uuidEquals(targetUuid, uuid))
|
|
||||||
}
|
|
||||||
|
|
@ -2,6 +2,5 @@ import request from '@/api/http/client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 兼容层:保留旧导入路径 `@/utils/request`。
|
* 兼容层:保留旧导入路径 `@/utils/request`。
|
||||||
* 新代码建议直接使用 `@/api/http/client`。
|
|
||||||
*/
|
*/
|
||||||
export default request
|
export default request
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue