refactor(ble): BLE 模块重构与协议时序修复

- 从 git 追踪中移除 .idea、.claude/rules、CLAUDE.md、AGENTS.md
- 更新 .gitignore,添加 prettier 配置
- BLE 核心模块重构(bleCore/controller/auth/radar/wifi/errors/eventBus/stateWait)
- 修复协议时序:订阅 F301 前先读 F501(readMsgOnce)
- 设备配置页优化:鉴权失败改用模态弹窗,WiFi 错误持久展示
- 密钥流程已废弃,鉴权失败不阻断后续配网流程
- pages.json 格式化(tab → 空格)
This commit is contained in:
ozh 2026-06-24 17:09:25 +08:00
parent cc02eb5998
commit 82b287dd92
23 changed files with 862 additions and 661 deletions

View File

@ -1,67 +0,0 @@
# Project Overview
This is a uni-app Vue3 smart home (智慧养老) application using uview-plus component library.
- Use the new features of es6
- Use the uniapp framework for small program development
- Added uview-plus component as UI component
# Build & Run Commands
Install dependencies with `npm install`.
This repository does not define npm scripts; builds and previews are normally run through HBuilderX. Use HBuilderX for the active target platform:
```bash
npm install
```
Typical local workflow:
- Open the project in HBuilderX and run to WeChat Mini Program or Android emulator/device.
- Use the built-in packaging flow for release builds.
# Architecture
## Module Organization
App entry files live at the repository root: `main.js`, `App.vue`, `pages.json`, and `manifest.json`. Feature pages are under `pages/` (`login`, `index`, `event`, `mine`, `device`). Shared business code is split across `api/`, `constants/`, `hooks/`, and `utils/`. Bluetooth logic is centralized in `utils/ble/`, with subfolders for `core/`, `modules/`, `parsers/`, `protocols/`, and `utils/`. Static assets go in `static/`; design and protocol notes live in `docs/`.
## BLE Module (`utils/ble/`)
If you are unclear about the functions of the BLE Module, please refer to the `docs/unified-ble-flow.md` file.
Unified Bluetooth Low Energy module for ED713/ED719 devices. Key files:
- `core/bleCore.js` - Bluetooth core capabilities (connect/discover/read/write/notify/reconnect)
- `core/controller.js` - Standard flow orchestration (scan→connect→discover→auth)
- `core/packet.js` - A7 packet fragmentation
- `modules/auth.js` - Authentication flow (read MSG + write KEY)
- `modules/wifi.js` - WiFi configuration
- `modules/radar.js` - Radar control (start/stop/params)
- `protocols/profiles.js` - ED713/ED719 device profile differences
The unified flow: `standardConnectFlow({ deviceId, key })``wifi.configureWifi(ssid, password)``radar.startRadarStream()`
Profile differences:
- ED713: radar signature `0x7C`, supports narrow mode
- ED719: radar signature `0x67`, supports fall param 67
If modifications are made to the BLE Module, they shall be added to the `docs/unified-ble-flow.md` file
## Authentication (`utils/auth.js`)
Route guard system with `setupAuthInterceptors()`. Currently `AUTH_ENABLED = false` for development. Routes are defined in `constants/routes.js`.
## Pages
- `pages/login/index.vue` - Login page
- `pages/index/index.vue` - Home (tab)
- `pages/event/index.vue` - Events (tab)
- `pages/mine/index.vue` - Profile (tab)
- `pages/device/add.vue` - Add device
- `pages/device/config.vue` - Device config with BLE search + WiFi panel
## Component Auto-import
uview-plus components auto-imported via `pages.json` easycom config: `^u-(.*)``uview-plus/components/u-$1/u-$1.vue`
## Coding Style & Naming Conventions
Follow the existing Vue SFC structure: `<template>`, `<script>`, `<style>`. JavaScript and Vue blocks use 2-space indentation; keep generated JSON files (`pages.json`, `manifest.json`) consistent with the existing HBuilderX formatting. Use `camelCase` for variables and functions, `UPPER_SNAKE_CASE` for constants, and keep page paths aligned with route names such as `pages/device/config.vue`. Prefer small reusable utilities in `utils/` or `hooks/` over duplicating BLE or navigation logic.

View File

@ -1,5 +0,0 @@
# Commit & Pull Request Guidelines
Pull requests should include a short summary, impacted pages or modules, manual test steps, linked issues when available, and screenshots for UI changes. For BLE-related changes, call out protocol assumptions, permissions touched, and any platform-specific risk.
# Security & Configuration Tips
Do not commit production secrets, API tokens, or real device credentials. Review `manifest.json` permission changes carefully, since they affect Android packaging and device capabilities.

View File

@ -1,2 +0,0 @@
# Testing Guidelines
No automated test framework is configured yet. Validate changes with manual testing on the affected target, especially BLE discovery, authentication, and Wi-Fi configuration flows in `pages/device/config.vue` and `utils/ble/`. When submitting changes, include clear reproduction and verification steps, plus device model notes if behavior differs between ED713 and ED719.

22
.gitignore vendored
View File

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

8
.idea/.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +0,0 @@
# Repository Guidelines
This file provides guidance to Codex when working with code in this repository.
@.claude/rules/frontend.md
@.claude/rules/git-workflow.md
@.claude/rules/test.md

View File

@ -1,7 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@.claude/rules/frontend.md
@.claude/rules/git-workflow.md
@.claude/rules/test.md

View File

@ -799,7 +799,15 @@ export function createBluetoothDiscovery(options = {}) {
*/ */
async function prepareWifiConfig(deviceId, options = {}) { async function prepareWifiConfig(deviceId, options = {}) {
await connectDevice(deviceId) await connectDevice(deviceId)
return authorizeDeviceByMac(options)
// 厂家确认:密钥流程已不启用,写 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() { async function readDeviceInfo() {

View File

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

View File

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

View File

@ -91,6 +91,10 @@
一键导入上次 WiFi 配置 一键导入上次 WiFi 配置
</view> </view>
<view v-if="wifiErrorTip" class="wifi-error-tip">
{{ wifiErrorTip }}
</view>
<view class="wifi-submit-btn" :class="{ disabled: wifiSending }" @click="confirmWifiSettings"> <view class="wifi-submit-btn" :class="{ disabled: wifiSending }" @click="confirmWifiSettings">
{{ wifiSending ? '发送中...' : '发送' }} {{ wifiSending ? '发送中...' : '发送' }}
</view> </view>
@ -155,6 +159,7 @@ export default {
wifiSending: false, wifiSending: false,
fetchingCurrentWifi: false, fetchingCurrentWifi: false,
wifiPrepared: false, wifiPrepared: false,
wifiErrorTip: '',
devicePreparingMap: {}, devicePreparingMap: {},
selectedWifiDevice: null, selectedWifiDevice: null,
wifiForm: { wifiForm: {
@ -216,6 +221,7 @@ export default {
this.wifiPreparing = false this.wifiPreparing = false
this.activePreparingDeviceId = '' this.activePreparingDeviceId = ''
this.fetchingCurrentWifi = false this.fetchingCurrentWifi = false
this.wifiErrorTip = ''
this.selectedWifiDevice = null this.selectedWifiDevice = null
this.resetWifiForm() this.resetWifiForm()
}, },
@ -329,6 +335,7 @@ export default {
mask: true mask: true
}) })
let authError = null
try { try {
/** /**
* 先按项目固定已知密钥规则MAC+0000完成鉴权成功后再允许进入 WiFi 弹层 * 先按项目固定已知密钥规则MAC+0000完成鉴权成功后再允许进入 WiFi 弹层
@ -343,16 +350,29 @@ export default {
this.wifiPanelVisible = true this.wifiPanelVisible = true
// Vue DOM toast Vue // Vue DOM toast Vue
await this.$nextTick() await this.$nextTick()
showToast('密钥鉴权成功请填写WiFi信息')
} catch (error) { } catch (error) {
showToast(getErrorMessage(error)) authError = error
await this.safeDisconnect() await this.safeDisconnect()
} finally { } finally {
this.setDevicePreparing(targetDeviceId, false) this.setDevicePreparing(targetDeviceId, false)
this.activePreparingDeviceId = '' this.activePreparingDeviceId = ''
this.wifiPreparing = false this.wifiPreparing = false
// showLoading showToast hideLoading
// hideLoading catch toast
uni.hideLoading() uni.hideLoading()
} }
if (authError) {
//
uni.showModal({
title: '密钥鉴权失败',
content: getErrorMessage(authError),
showCancel: false,
confirmText: '知道了'
})
} else if (this.wifiPanelVisible) {
showToast('密钥鉴权成功请填写WiFi信息')
}
}, },
async closeWifiPanel(force = false) { async closeWifiPanel(force = false) {
@ -461,6 +481,7 @@ export default {
} }
this.wifiSending = true this.wifiSending = true
this.wifiErrorTip = ''
let needAutoClose = false let needAutoClose = false
let needCacheWifi = false let needCacheWifi = false
@ -472,7 +493,7 @@ export default {
* 才进行提示并自动关闭弹层 * 才进行提示并自动关闭弹层
*/ */
const wifiResult = await this.discoveryController.configureWifi(ssid, password, { const wifiResult = await this.discoveryController.configureWifi(ssid, password, {
timeout: 12000 timeout: 60000
}) })
const stateMessage = this.getWifiStateMessage(wifiResult.stateCode) const stateMessage = this.getWifiStateMessage(wifiResult.stateCode)
@ -480,8 +501,9 @@ export default {
needAutoClose = true needAutoClose = true
needCacheWifi = Boolean(wifiResult.success) needCacheWifi = Boolean(wifiResult.success)
} catch (error) { } catch (error) {
// / // / toast
tipMessage = getErrorMessage(error) tipMessage = getErrorMessage(error)
this.wifiErrorTip = tipMessage
} finally { } finally {
this.wifiSending = false this.wifiSending = false
@ -830,6 +852,16 @@ export default {
justify-content: 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 { .wifi-submit-btn {
height: 100rpx; height: 100rpx;
background: #11c462; background: #11c462;

View File

@ -1,10 +1,10 @@
import { createEventBus } from './eventBus' import { createEventBus } from './eventBus';
import { createBleState, BLE_STAGE } from './state' import { createBleState, BLE_STAGE } from './state';
import { createBleError, getBleErrorText, normalizeBleError } from './errors' import { createBleError, getBleErrorText, normalizeBleError } from './errors';
import { uint8ArrayToArrayBuffer, toUint8Array } from '../utils/bytes' import { uint8ArrayToArrayBuffer, toUint8Array } from '../utils/bytes';
import { toFullUuid, uuidEquals, uuidInList } from '../utils/uuid' import { toFullUuid, uuidEquals, uuidInList } from '../utils/uuid';
import { runWithRetry } from './retry' import { runWithRetry } from './retry';
import { getBleProfile, DEVICE_PROFILE } from '../protocols/profiles' import { getBleProfile, DEVICE_PROFILE } from '../protocols/profiles';
// 把 uni 的 success/fail 回调风格统一成 Promise便于串联 async 流程。 // 把 uni 的 success/fail 回调风格统一成 Promise便于串联 async 流程。
function promisifyUniApi(apiName, params = {}) { function promisifyUniApi(apiName, params = {}) {
@ -12,9 +12,9 @@ function promisifyUniApi(apiName, params = {}) {
uni[apiName]({ uni[apiName]({
...params, ...params,
success: (res) => resolve(res), success: (res) => resolve(res),
fail: (err) => reject(err) fail: (err) => reject(err),
}) });
}) });
} }
/** /**
@ -26,19 +26,19 @@ export function createBleCore(options = {}) {
reconnect = { reconnect = {
enabled: true, enabled: true,
retries: 2, retries: 2,
delay: 1200 delay: 1200,
}, },
logger = console, logger = console,
profileId = DEVICE_PROFILE.UNKNOWN profileId = DEVICE_PROFILE.UNKNOWN,
} = options } = options;
const eventBus = createEventBus() const eventBus = createEventBus();
const state = createBleState() const state = createBleState();
let currentProfile = getBleProfile(profileId) let currentProfile = getBleProfile(profileId);
let deviceFoundHandler = null let deviceFoundHandler = null;
let adapterStateHandler = null let adapterStateHandler = null;
let connectionStateHandler = null let connectionStateHandler = null;
let characteristicValueHandler = null let characteristicValueHandler = null;
// 返回状态快照,避免调用方直接改写内部状态对象。 // 返回状态快照,避免调用方直接改写内部状态对象。
function snapshotState() { function snapshotState() {
@ -46,74 +46,74 @@ export function createBleCore(options = {}) {
...state, ...state,
auth: { ...state.auth }, auth: { ...state.auth },
deviceMap: { ...state.deviceMap }, deviceMap: { ...state.deviceMap },
characteristics: { ...state.characteristics } characteristics: { ...state.characteristics },
} };
} }
// 统一 patch 状态并广播 state:change保证 UI 状态来源单一。 // 统一 patch 状态并广播 state:change保证 UI 状态来源单一。
function patchState(partialState) { function patchState(partialState) {
Object.assign(state, partialState) Object.assign(state, partialState);
eventBus.emit('state:change', snapshotState()) eventBus.emit('state:change', snapshotState());
} }
function setStage(stage) { function setStage(stage) {
patchState({ stage }) patchState({ stage });
} }
function setProfile(nextProfileId) { function setProfile(nextProfileId) {
currentProfile = getBleProfile(nextProfileId) currentProfile = getBleProfile(nextProfileId);
patchState({ profileId: currentProfile.id }) patchState({ profileId: currentProfile.id });
} }
function getProfile() { function getProfile() {
return currentProfile return currentProfile;
} }
function setAuthState(partialAuth = {}) { function setAuthState(partialAuth = {}) {
patchState({ patchState({
auth: { auth: {
...state.auth, ...state.auth,
...partialAuth ...partialAuth,
} },
}) });
} }
function emit(eventName, payload) { function emit(eventName, payload) {
eventBus.emit(eventName, payload) eventBus.emit(eventName, payload);
} }
function emitError(error) { function emitError(error) {
const normalized = normalizeBleError(error) const normalized = normalizeBleError(error);
patchState({ lastError: normalized }) patchState({ lastError: normalized });
eventBus.emit('error', normalized) eventBus.emit('error', normalized);
return normalized return normalized;
} }
/** 打开蓝牙适配器,成功后标记可用并进入 ADAPTER_OPENED 阶段。 */ /** 打开蓝牙适配器,成功后标记可用并进入 ADAPTER_OPENED 阶段。 */
async function openAdapter() { async function openAdapter() {
try { try {
await promisifyUniApi('openBluetoothAdapter') await promisifyUniApi('openBluetoothAdapter');
patchState({ available: true }) patchState({ available: true });
setStage(BLE_STAGE.ADAPTER_OPENED) setStage(BLE_STAGE.ADAPTER_OPENED);
return true return true;
} catch (error) { } catch (error) {
throw emitError(error) throw emitError(error);
} }
} }
/** 关闭蓝牙适配器并重置全部运行时状态。 */ /** 关闭蓝牙适配器并重置全部运行时状态。 */
async function closeAdapter() { async function closeAdapter() {
try { try {
await promisifyUniApi('closeBluetoothAdapter') await promisifyUniApi('closeBluetoothAdapter');
} catch (error) { } catch (error) {
logger.warn('[BLE] close adapter failed', error) logger.warn('[BLE] close adapter failed', error);
} }
// 重置原生 handler 引用,以便 openAdapter 后 init() 可重新注册 // 重置原生 handler 引用,以便 openAdapter 后 init() 可重新注册
adapterStateHandler = null adapterStateHandler = null;
connectionStateHandler = null connectionStateHandler = null;
characteristicValueHandler = null characteristicValueHandler = null;
deviceFoundHandler = null deviceFoundHandler = null;
patchState({ patchState({
stage: BLE_STAGE.IDLE, stage: BLE_STAGE.IDLE,
@ -122,56 +122,56 @@ export function createBleCore(options = {}) {
connected: false, connected: false,
deviceId: '', deviceId: '',
serviceId: '', serviceId: '',
characteristics: {} characteristics: {},
}) });
} }
/** 读取当前适配器状态(可用/搜索中),同步到内部状态。 */ /** 读取当前适配器状态(可用/搜索中),同步到内部状态。 */
async function getAdapterState() { async function getAdapterState() {
try { try {
const result = await promisifyUniApi('getBluetoothAdapterState') const result = await promisifyUniApi('getBluetoothAdapterState');
patchState({ patchState({
available: Boolean(result.available), available: Boolean(result.available),
discovering: Boolean(result.discovering) discovering: Boolean(result.discovering),
}) });
return result return result;
} catch (error) { } catch (error) {
throw emitError(error) throw emitError(error);
} }
} }
/** 注册适配器状态变更监听,蓝牙关闭时自动切换到 DISCONNECTED。 */ /** 注册适配器状态变更监听,蓝牙关闭时自动切换到 DISCONNECTED。 */
function watchAdapterState() { function watchAdapterState() {
if (adapterStateHandler) { if (adapterStateHandler) {
return return;
} }
adapterStateHandler = (result) => { adapterStateHandler = (result) => {
patchState({ patchState({
available: Boolean(result.available), available: Boolean(result.available),
discovering: Boolean(result.discovering) discovering: Boolean(result.discovering),
}) });
eventBus.emit('adapter:state', result) eventBus.emit('adapter:state', result);
if (!result.available) { if (!result.available) {
setStage(BLE_STAGE.DISCONNECTED) setStage(BLE_STAGE.DISCONNECTED);
} }
} };
uni.onBluetoothAdapterStateChange(adapterStateHandler) uni.onBluetoothAdapterStateChange(adapterStateHandler);
} }
/** 移除适配器状态变更监听。 */ /** 移除适配器状态变更监听。 */
function unwatchAdapterState() { function unwatchAdapterState() {
if (!adapterStateHandler) { if (!adapterStateHandler) {
return return;
} }
if (uni.offBluetoothAdapterStateChange) { if (uni.offBluetoothAdapterStateChange) {
uni.offBluetoothAdapterStateChange(adapterStateHandler) uni.offBluetoothAdapterStateChange(adapterStateHandler);
} }
adapterStateHandler = null adapterStateHandler = null;
} }
/** /**
@ -183,89 +183,93 @@ export function createBleCore(options = {}) {
services = [], services = [],
allowDuplicatesKey = true, allowDuplicatesKey = true,
interval = 0, interval = 0,
powerLevel = 'high' powerLevel = 'high',
} = params } = params;
try { try {
if (!state.available) { if (!state.available) {
await openAdapter() await openAdapter();
} }
const options = { const options = {
allowDuplicatesKey, allowDuplicatesKey,
interval interval,
} };
if (services.length) { if (services.length) {
options.services = services.map((uuid) => toFullUuid(uuid) || uuid) options.services = services.map((uuid) => toFullUuid(uuid) || uuid);
} }
if (powerLevel) { if (powerLevel) {
options.powerLevel = powerLevel options.powerLevel = powerLevel;
} }
await promisifyUniApi('startBluetoothDevicesDiscovery', options) await promisifyUniApi('startBluetoothDevicesDiscovery', options);
patchState({ discovering: true, deviceMap: {} }) patchState({ discovering: true, deviceMap: {} });
setStage(BLE_STAGE.DISCOVERING) setStage(BLE_STAGE.DISCOVERING);
return true return true;
} catch (error) { } catch (error) {
throw emitError(error) throw emitError(error);
} }
} }
/** 停止设备扫描。 */ /** 停止设备扫描。 */
async function stopDiscovery() { async function stopDiscovery() {
try { try {
await promisifyUniApi('stopBluetoothDevicesDiscovery') await promisifyUniApi('stopBluetoothDevicesDiscovery');
} catch (error) { } catch (error) {
logger.warn('[BLE] stop discovery failed', error) logger.warn('[BLE] stop discovery failed', error);
} }
patchState({ discovering: false }) patchState({ discovering: false });
} }
/** 注册设备发现监听,按 deviceId 去重合并后广播 device:found。 */ /** 注册设备发现监听,按 deviceId 去重合并后广播 device:found。 */
function watchDeviceFound() { function watchDeviceFound() {
if (deviceFoundHandler) { if (deviceFoundHandler) {
return return;
} }
deviceFoundHandler = (result) => { deviceFoundHandler = (result) => {
const list = Array.isArray(result.devices) ? result.devices : [result] const list = Array.isArray(result.devices) ? result.devices : [result];
const nextMap = { ...state.deviceMap } const nextMap = { ...state.deviceMap };
// 基于 deviceId 做去重与增量更新,避免列表无限叠加。 // 基于 deviceId 做去重与增量更新,避免列表无限叠加。
list.forEach((device) => { list.forEach((device) => {
if (!device || !device.deviceId) { if (!device || !device.deviceId) {
return return;
} }
const previous = nextMap[device.deviceId] || {} const previous = nextMap[device.deviceId] || {};
nextMap[device.deviceId] = { nextMap[device.deviceId] = {
...previous, ...previous,
...device, ...device,
showName: device.name || device.localName || previous.showName || '未命名设备' showName:
} device.name ||
}) device.localName ||
previous.showName ||
'未命名设备',
};
});
patchState({ deviceMap: nextMap }) patchState({ deviceMap: nextMap });
eventBus.emit('device:found', Object.values(nextMap)) eventBus.emit('device:found', Object.values(nextMap));
} };
uni.onBluetoothDeviceFound(deviceFoundHandler) uni.onBluetoothDeviceFound(deviceFoundHandler);
} }
/** 移除设备发现监听。 */ /** 移除设备发现监听。 */
function unwatchDeviceFound() { function unwatchDeviceFound() {
if (!deviceFoundHandler) { if (!deviceFoundHandler) {
return return;
} }
if (uni.offBluetoothDeviceFound) { if (uni.offBluetoothDeviceFound) {
uni.offBluetoothDeviceFound(deviceFoundHandler) uni.offBluetoothDeviceFound(deviceFoundHandler);
} }
deviceFoundHandler = null deviceFoundHandler = null;
} }
/** /**
@ -277,102 +281,104 @@ export function createBleCore(options = {}) {
*/ */
async function connect(deviceId, options = {}) { async function connect(deviceId, options = {}) {
if (!deviceId) { if (!deviceId) {
throw emitError(createBleError(10013, 'deviceId 不能为空')) throw emitError(createBleError(10013, 'deviceId 不能为空'));
} }
const { const { timeout = 15000, autoReconnect = reconnect.enabled } = options;
timeout = 15000,
autoReconnect = reconnect.enabled
} = options
const runConnect = async () => { const runConnect = async () => {
setStage(BLE_STAGE.CONNECTING) setStage(BLE_STAGE.CONNECTING);
await promisifyUniApi('createBLEConnection', { deviceId, timeout }) await promisifyUniApi('createBLEConnection', { deviceId, timeout });
patchState({ connected: true, deviceId }) patchState({ connected: true, deviceId });
setStage(BLE_STAGE.CONNECTED) setStage(BLE_STAGE.CONNECTED);
return true return true;
} };
try { try {
if (!autoReconnect) { if (!autoReconnect) {
return await runConnect() return await runConnect();
} }
return await runWithRetry(runConnect, { return await runWithRetry(runConnect, {
retries: reconnect.retries, retries: reconnect.retries,
delay: reconnect.delay, delay: reconnect.delay,
shouldRetry: (error) => { shouldRetry: (error) => {
const normalized = normalizeBleError(error) const normalized = normalizeBleError(error);
return [10003, 10006, 10012].includes(normalized.code) return [10003, 10006, 10012].includes(normalized.code);
} },
}) });
} catch (error) { } catch (error) {
throw emitError(error) throw emitError(error);
} }
} }
/** 断开BLE连接并重置连接相关状态。 */ /** 断开BLE连接并重置连接相关状态。 */
async function disconnect(deviceId = state.deviceId) { async function disconnect(deviceId = state.deviceId) {
if (!deviceId) { if (!deviceId) {
return return;
} }
try { try {
await promisifyUniApi('closeBLEConnection', { deviceId }) await promisifyUniApi('closeBLEConnection', { deviceId });
} catch (error) { } catch (error) {
logger.warn('[BLE] close connection failed', error) logger.warn('[BLE] close connection failed', error);
} }
patchState({ connected: false, deviceId: '', serviceId: '', characteristics: {} }) patchState({
setStage(BLE_STAGE.DISCONNECTED) connected: false,
deviceId: '',
serviceId: '',
characteristics: {},
});
setStage(BLE_STAGE.DISCONNECTED);
} }
/** 注册连接状态变更监听,断开时自动切换到 DISCONNECTED。 */ /** 注册连接状态变更监听,断开时自动切换到 DISCONNECTED。 */
function watchConnectionChange() { function watchConnectionChange() {
if (connectionStateHandler) { if (connectionStateHandler) {
return return;
} }
connectionStateHandler = (result) => { connectionStateHandler = (result) => {
if (state.deviceId && result.deviceId !== state.deviceId) { if (state.deviceId && result.deviceId !== state.deviceId) {
return return;
} }
patchState({ connected: Boolean(result.connected) }) patchState({ connected: Boolean(result.connected) });
eventBus.emit('connection:change', result) eventBus.emit('connection:change', result);
if (!result.connected) { if (!result.connected) {
setStage(BLE_STAGE.DISCONNECTED) setStage(BLE_STAGE.DISCONNECTED);
} }
} };
uni.onBLEConnectionStateChange(connectionStateHandler) uni.onBLEConnectionStateChange(connectionStateHandler);
} }
/** 移除连接状态变更监听。 */ /** 移除连接状态变更监听。 */
function unwatchConnectionChange() { function unwatchConnectionChange() {
if (!connectionStateHandler) { if (!connectionStateHandler) {
return return;
} }
if (uni.offBLEConnectionStateChange) { if (uni.offBLEConnectionStateChange) {
uni.offBLEConnectionStateChange(connectionStateHandler) uni.offBLEConnectionStateChange(connectionStateHandler);
} }
connectionStateHandler = null connectionStateHandler = null;
} }
/** 获取候选服务UUID列表profile主服务 + 兼容固件的 00F4/F400/FFF4。 */ /** 获取候选服务UUID列表profile主服务 + 兼容固件的 00F4/F400/FFF4。 */
function getServiceCandidates() { function getServiceCandidates() {
const profileService = currentProfile?.uuids?.service const profileService = currentProfile?.uuids?.service;
const candidates = [ const candidates = [
profileService, profileService,
toFullUuid('00F4'), toFullUuid('00F4'),
toFullUuid('F400'), toFullUuid('F400'),
toFullUuid('FFF4') toFullUuid('FFF4'),
] ];
return candidates.filter(Boolean) return candidates.filter(Boolean);
} }
/** /**
@ -381,27 +387,31 @@ export function createBleCore(options = {}) {
*/ */
async function discoverServices(deviceId = state.deviceId) { async function discoverServices(deviceId = state.deviceId) {
if (!deviceId) { if (!deviceId) {
throw emitError(createBleError(10013, 'discoverServices 缺少 deviceId')) throw emitError(createBleError(10013, 'discoverServices 缺少 deviceId'));
} }
try { try {
const result = await promisifyUniApi('getBLEDeviceServices', { deviceId }) const result = await promisifyUniApi('getBLEDeviceServices', {
const services = result.services || [] deviceId,
const candidates = getServiceCandidates() });
const targetService = services.find((service) => candidates.some((uuid) => uuidEquals(service.uuid, uuid))) const services = result.services || [];
const candidates = getServiceCandidates();
const targetService = services.find((service) =>
candidates.some((uuid) => uuidEquals(service.uuid, uuid)),
);
if (!targetService) { if (!targetService) {
throw createBleError(10004, '设备未暴露目标服务(00F4/F400/FFF4)') throw createBleError(10004, '设备未暴露目标服务(00F4/F400/FFF4)');
} }
patchState({ serviceId: targetService.uuid }) patchState({ serviceId: targetService.uuid });
setStage(BLE_STAGE.SERVICE_DISCOVERED) setStage(BLE_STAGE.SERVICE_DISCOVERED);
return { return {
services, services,
targetService targetService,
} };
} catch (error) { } catch (error) {
throw emitError(error) throw emitError(error);
} }
} }
@ -411,86 +421,134 @@ export function createBleCore(options = {}) {
* @returns {object} characteristicMap 各业务特征 + all 全量列表 * @returns {object} characteristicMap 各业务特征 + all 全量列表
*/ */
async function discoverCharacteristics(options = {}) { async function discoverCharacteristics(options = {}) {
const deviceId = options.deviceId || state.deviceId const deviceId = options.deviceId || state.deviceId;
const serviceId = options.serviceId || state.serviceId const serviceId = options.serviceId || state.serviceId;
if (!deviceId || !serviceId) { if (!deviceId || !serviceId) {
throw emitError(createBleError(10013, 'discoverCharacteristics 缺少 deviceId/serviceId')) throw emitError(
createBleError(
10013,
'discoverCharacteristics 缺少 deviceId/serviceId',
),
);
} }
try { try {
const result = await promisifyUniApi('getBLEDeviceCharacteristics', { deviceId, serviceId }) const result = await promisifyUniApi('getBLEDeviceCharacteristics', {
const list = result.characteristics || [] deviceId,
const profile = getProfile() serviceId,
});
const list = result.characteristics || [];
const profile = getProfile();
// 缓存业务侧常用特征,减少后续每次操作时的查找成本。 // 缓存业务侧常用特征,减少后续每次操作时的查找成本。
const characteristicMap = { const characteristicMap = {
wifi: list.find((item) => uuidEquals(item.uuid, profile.uuids.wifi)) || null, wifi:
key: list.find((item) => uuidEquals(item.uuid, profile.uuids.key)) || null, list.find((item) => uuidEquals(item.uuid, profile.uuids.wifi)) ||
cmd: list.find((item) => uuidEquals(item.uuid, profile.uuids.cmd)) || null, null,
msg: list.find((item) => uuidEquals(item.uuid, profile.uuids.msg)) || null, key:
state: list.find((item) => uuidEquals(item.uuid, profile.uuids.state)) || null, list.find((item) => uuidEquals(item.uuid, profile.uuids.key)) || null,
radar: list.find((item) => uuidEquals(item.uuid, profile.uuids.radar)) || null, cmd:
all: list 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 }) patchState({ characteristics: characteristicMap });
return characteristicMap return characteristicMap;
} catch (error) { } catch (error) {
throw emitError(error) throw emitError(error);
} }
} }
/** 读取指定特征值(触发 onBLECharacteristicValueChange 回调)。 */ /** 读取指定特征值(触发 onBLECharacteristicValueChange 回调)。 */
async function readCharacteristic(characteristicId, options = {}) { async function readCharacteristic(characteristicId, options = {}) {
const deviceId = options.deviceId || state.deviceId const deviceId = options.deviceId || state.deviceId;
const serviceId = options.serviceId || state.serviceId const serviceId = options.serviceId || state.serviceId;
if (!deviceId || !serviceId || !characteristicId) { if (!deviceId || !serviceId || !characteristicId) {
throw emitError(createBleError(10013, 'readCharacteristic 参数缺失')) throw emitError(createBleError(10013, 'readCharacteristic 参数缺失'));
} }
try { try {
await promisifyUniApi('readBLECharacteristicValue', { await promisifyUniApi('readBLECharacteristicValue', {
deviceId, deviceId,
serviceId, serviceId,
characteristicId characteristicId,
}) });
return true return true;
} catch (error) { } catch (error) {
throw emitError(error) throw emitError(error);
} }
} }
/** 向指定特征值写入数据,自动将输入转为 ArrayBuffer。 */ /** 向指定特征值写入数据,自动将输入转为 ArrayBuffer。 */
async function writeCharacteristic(characteristicId, value, options = {}) { async function writeCharacteristic(characteristicId, value, options = {}) {
const deviceId = options.deviceId || state.deviceId const deviceId = options.deviceId || state.deviceId;
const serviceId = options.serviceId || state.serviceId const serviceId = options.serviceId || state.serviceId;
if (!deviceId || !serviceId || !characteristicId) { if (!deviceId || !serviceId || !characteristicId) {
throw emitError(createBleError(10013, 'writeCharacteristic 参数缺失')) 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 { try {
await promisifyUniApi('writeBLECharacteristicValue', { console.debug('[BLE][WRITE]', characteristicId, {
writeType,
supportsWrite,
supportsWriteNoResp,
valueLen: value?.length
});
const params = {
deviceId, deviceId,
serviceId, serviceId,
characteristicId, characteristicId,
value: uint8ArrayToArrayBuffer(toUint8Array(value)) value: uint8ArrayToArrayBuffer(toUint8Array(value)),
}) };
return true if (writeType) {
params.writeType = writeType;
}
await promisifyUniApi('writeBLECharacteristicValue', params);
return true;
} catch (error) { } catch (error) {
throw emitError(error) throw emitError(error);
} }
} }
/** 订阅/取消订阅指定特征值的通知。 */ /** 订阅/取消订阅指定特征值的通知。 */
async function notifyCharacteristic(characteristicId, stateFlag = true, options = {}) { async function notifyCharacteristic(
const deviceId = options.deviceId || state.deviceId characteristicId,
const serviceId = options.serviceId || state.serviceId stateFlag = true,
options = {},
) {
const deviceId = options.deviceId || state.deviceId;
const serviceId = options.serviceId || state.serviceId;
if (!deviceId || !serviceId || !characteristicId) { if (!deviceId || !serviceId || !characteristicId) {
throw emitError(createBleError(10013, 'notifyCharacteristic 参数缺失')) throw emitError(createBleError(10013, 'notifyCharacteristic 参数缺失'));
} }
try { try {
@ -498,67 +556,80 @@ export function createBleCore(options = {}) {
deviceId, deviceId,
serviceId, serviceId,
characteristicId, characteristicId,
state: Boolean(stateFlag) state: Boolean(stateFlag),
}) });
return true return true;
} catch (error) { } catch (error) {
throw emitError(error) throw emitError(error);
} }
} }
function watchCharacteristicValue() { function watchCharacteristicValue() {
if (characteristicValueHandler) { if (characteristicValueHandler) {
return return;
} }
// 统一转为 Uint8Array简化上层解析逻辑。 // 统一转为 Uint8Array简化上层解析逻辑。
characteristicValueHandler = (result) => { 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', { eventBus.emit('characteristic:value', {
...result, ...result,
value: toUint8Array(result.value) value,
}) });
} };
uni.onBLECharacteristicValueChange(characteristicValueHandler) uni.onBLECharacteristicValueChange(characteristicValueHandler);
} }
/** 移除特征值变更监听。 */ /** 移除特征值变更监听。 */
function unwatchCharacteristicValue() { function unwatchCharacteristicValue() {
if (!characteristicValueHandler) { if (!characteristicValueHandler) {
return return;
} }
if (uni.offBLECharacteristicValueChange) { if (uni.offBLECharacteristicValueChange) {
uni.offBLECharacteristicValueChange(characteristicValueHandler) uni.offBLECharacteristicValueChange(characteristicValueHandler);
} }
characteristicValueHandler = null characteristicValueHandler = null;
} }
/** 返回已发现设备列表。 */ /** 返回已发现设备列表。 */
function listDiscoveredDevices() { function listDiscoveredDevices() {
return Object.values(state.deviceMap) return Object.values(state.deviceMap);
} }
/** 从已发现设备中按广播服务UUID匹配并返回第一个设备。 */ /** 从已发现设备中按广播服务UUID匹配并返回第一个设备。 */
function pickDeviceByService(serviceUuid = currentProfile.uuids.service) { function pickDeviceByService(serviceUuid = currentProfile.uuids.service) {
const list = listDiscoveredDevices() const list = listDiscoveredDevices();
return list.find((device) => uuidInList(serviceUuid, device.advertisServiceUUIDs || [])) || null return (
list.find((device) =>
uuidInList(serviceUuid, device.advertisServiceUUIDs || []),
) || null
);
} }
async function cleanup() { async function cleanup() {
// 清理顺序:先停扫描与连接,再移除监听,最后关闭适配器。 // 清理顺序:先停扫描与连接,再移除监听,最后关闭适配器。
await stopDiscovery() await stopDiscovery();
await disconnect() await disconnect();
unwatchDeviceFound() unwatchDeviceFound();
unwatchAdapterState() unwatchAdapterState();
unwatchConnectionChange() unwatchConnectionChange();
unwatchCharacteristicValue() unwatchCharacteristicValue();
await closeAdapter() await closeAdapter();
eventBus.clear() eventBus.clear();
} }
return { return {
@ -593,6 +664,6 @@ export function createBleCore(options = {}) {
on: eventBus.on, on: eventBus.on,
once: eventBus.once, once: eventBus.once,
cleanup, cleanup,
getBleErrorText getBleErrorText,
} };
} }

View File

@ -1,10 +1,49 @@
import {createBleCore} from './bleCore' import { createBleCore } from './bleCore';
import {createAuthModule} from '../modules/auth' import { createAuthModule } from '../modules/auth';
import {createWifiModule} from '../modules/wifi' import { createWifiModule } from '../modules/wifi';
import {createRadarModule} from '../modules/radar' import { createRadarModule } from '../modules/radar';
import {parseStateCode, parseDeviceInfo} from '../parsers' import { parseStateCode, parseDeviceInfo } from '../parsers';
import {DEVICE_PROFILE, guessProfileByRadarFrame} from '../protocols/profiles' import {
import {uuidEquals} from '../utils/uuid' 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;
}
}
/** /**
* 创建特征值通知分发器 * 创建特征值通知分发器
@ -13,52 +52,61 @@ import {uuidEquals} from '../utils/uuid'
*/ */
function createCharacteristicDispatcher(ble) { function createCharacteristicDispatcher(ble) {
return (event) => { return (event) => {
const state = ble.getState() const state = ble.getState();
const profile = ble.getProfile() const profile = ble.getProfile();
const current = state.characteristics const current = state.characteristics;
if (current.state && uuidEquals(event.characteristicId, current.state.uuid)) { if (
const stateCode = parseStateCode(event.value) current.state &&
uuidEquals(event.characteristicId, current.state.uuid)
) {
const stateCode = parseStateCode(event.value);
ble.setAuthState({ ble.setAuthState({
lastStateCode: stateCode, lastStateCode: stateCode,
keyMatched: stateCode === 7 || stateCode === 5 || state.auth.keyMatched keyMatched: stateCode === 7 || stateCode === 5 || state.auth.keyMatched,
}) });
ble.emit('protocol:state', { ble.emit('protocol:state', {
...event, ...event,
profileId: profile.id, profileId: profile.id,
stateCode stateCode,
}) });
return return;
} }
if (current.msg && uuidEquals(event.characteristicId, current.msg.uuid)) { if (current.msg && uuidEquals(event.characteristicId, current.msg.uuid)) {
ble.emit('protocol:msg', { ble.emit('protocol:msg', {
...event, ...event,
profileId: profile.id, profileId: profile.id,
deviceInfo: parseDeviceInfo(event.value, profile.id) deviceInfo: parseDeviceInfo(event.value, profile.id),
}) });
return return;
} }
if (current.radar && uuidEquals(event.characteristicId, current.radar.uuid)) { if (
const guessed = guessProfileByRadarFrame(event.value) current.radar &&
if (state.profileId === DEVICE_PROFILE.UNKNOWN && guessed !== DEVICE_PROFILE.UNKNOWN) { uuidEquals(event.characteristicId, current.radar.uuid)
ble.setProfile(guessed) ) {
const guessed = guessProfileByRadarFrame(event.value);
if (
state.profileId === DEVICE_PROFILE.UNKNOWN &&
guessed !== DEVICE_PROFILE.UNKNOWN
) {
ble.setProfile(guessed);
} }
ble.emit('protocol:radar', { ble.emit('protocol:radar', {
...event, ...event,
profileId: ble.getProfile().id profileId: ble.getProfile().id,
}) });
return return;
} }
ble.emit('protocol:raw', { ble.emit('protocol:raw', {
...event, ...event,
profileId: profile.id profileId: profile.id,
}) });
} };
} }
/** /**
@ -67,40 +115,43 @@ function createCharacteristicDispatcher(ble) {
* @param {object} options 透传给 createBleCore 的配置重试策略/日志/profileId等 * @param {object} options 透传给 createBleCore 的配置重试策略/日志/profileId等
*/ */
export function createUnifiedBleController(options = {}) { export function createUnifiedBleController(options = {}) {
const ble = createBleCore(options) const ble = createBleCore(options);
const auth = createAuthModule(ble) const auth = createAuthModule(ble);
const wifi = createWifiModule(ble) const wifi = createWifiModule(ble);
const radar = createRadarModule(ble) const radar = createRadarModule(ble);
const characteristicDispatcher = createCharacteristicDispatcher(ble) const characteristicDispatcher = createCharacteristicDispatcher(ble);
let offCharacteristic = null let offCharacteristic = null;
/** 绑定特征值分发器到 ble 事件总线。 */ /** 绑定特征值分发器到 ble 事件总线。 */
function bindDispatcher() { function bindDispatcher() {
if (offCharacteristic) { if (offCharacteristic) {
return return;
} }
offCharacteristic = ble.on('characteristic:value', characteristicDispatcher) offCharacteristic = ble.on(
'characteristic:value',
characteristicDispatcher,
);
} }
/** 解绑特征值分发器。 */ /** 解绑特征值分发器。 */
function unbindDispatcher() { function unbindDispatcher() {
if (!offCharacteristic) { if (!offCharacteristic) {
return return;
} }
offCharacteristic() offCharacteristic();
offCharacteristic = null offCharacteristic = null;
} }
/** 初始化蓝牙适配器并注册所有监听(适配器状态/连接/特征值/分发器)。 */ /** 初始化蓝牙适配器并注册所有监听(适配器状态/连接/特征值/分发器)。 */
async function init() { async function init() {
await ble.openAdapter() await ble.openAdapter();
ble.watchAdapterState() ble.watchAdapterState();
ble.watchConnectionChange() ble.watchConnectionChange();
ble.watchCharacteristicValue() ble.watchCharacteristicValue();
bindDispatcher() bindDispatcher();
} }
/** /**
@ -113,94 +164,110 @@ export function createUnifiedBleController(options = {}) {
* @returns {object|null} 匹配到的设备对象未找到返回 null * @returns {object|null} 匹配到的设备对象未找到返回 null
*/ */
async function scanAndPickDevice({ async function scanAndPickDevice({
serviceUuid, serviceUuid,
scanDuration = 5000, scanDuration = 5000,
fallbackWithoutFilter = true fallbackWithoutFilter = true,
} = {}) { } = {}) {
const profile = ble.getProfile() const profile = ble.getProfile();
const targetService = serviceUuid || profile.uuids.service const targetService = serviceUuid || profile.uuids.service;
ble.watchDeviceFound() ble.watchDeviceFound();
await ble.startDiscovery({services: [targetService]}) await ble.startDiscovery({ services: [targetService] });
await new Promise((resolve) => setTimeout(resolve, scanDuration)) await new Promise((resolve) => setTimeout(resolve, scanDuration));
await ble.stopDiscovery() await ble.stopDiscovery();
let selected = ble.pickDeviceByService(targetService) let selected = ble.pickDeviceByService(targetService);
if (!selected && fallbackWithoutFilter) { if (!selected && fallbackWithoutFilter) {
await ble.startDiscovery({services: []}) await ble.startDiscovery({ services: [] });
await new Promise((resolve) => setTimeout(resolve, scanDuration)) await new Promise((resolve) => setTimeout(resolve, scanDuration));
await ble.stopDiscovery() await ble.stopDiscovery();
selected = ble.pickDeviceByService(targetService) || ble.listDiscoveredDevices()[0] || null selected =
ble.pickDeviceByService(targetService) ||
ble.listDiscoveredDevices()[0] ||
null;
} }
return selected return selected;
} }
/** /**
* 连接后严格执行协议前置 * 连接后严格执行协议前置厂家要求顺序
* - 发现服务和特征 * 1) 发现服务和特征
* - 订阅 UUID_STATE / UUID_RADAR * 2) 先读取 UUID_MSG(F501) 必须在订阅 F301 之前
* 3) 订阅 UUID_STATE(F301) / UUID_RADAR(F302)
*
* 厂家说明订阅 F301 前必须先读 F501否则后续设备不回 STATE 通知
* 表现为写 KEY/WIFI F301 零回执等待超时
*/ */
async function connectAndDiscover(deviceId) { async function connectAndDiscover(deviceId) {
await ble.connect(deviceId) await ble.connect(deviceId);
await ble.discoverServices(deviceId) await ble.discoverServices(deviceId);
const chars = await ble.discoverCharacteristics() const chars = await ble.discoverCharacteristics();
// 先读 UUID_MSG(F501),再订阅 F301 —— 厂家强制顺序
await readMsgOnce(ble, chars);
if (chars.state && chars.state.uuid) { 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 { try {
await ble.notifyCharacteristic(chars.state.uuid, true); await ble.notifyCharacteristic(chars.state.uuid, true);
console.debug('[BLE][SUB]', 'notify STATE ok', chars.state.uuid) console.debug('[BLE][SUB]', 'notify STATE ok', chars.state.uuid);
} catch (e) { } catch (e) {
console.debug('[BLE][SUB]', 'notify STATE fail', e?.message || e) throw new Error(`订阅 UUID_STATE(F301) 失败:${e?.message || e}`);
} }
} else {
throw new Error('未发现 UUID_STATE(F301) 特征值,无法接收设备回执');
} }
if (chars.radar && chars.radar.uuid) { if (chars.radar && chars.radar.uuid) {
try { try {
await ble.notifyCharacteristic(chars.radar.uuid, true); await ble.notifyCharacteristic(chars.radar.uuid, true);
console.debug('[BLE][SUB]', 'notify RADAR ok', chars.radar.uuid) console.debug('[BLE][SUB]', 'notify RADAR ok', chars.radar.uuid);
} catch (e) { } catch (e) {
console.debug('[BLE][SUB]', 'notify RADAR fail', e?.message || e) console.debug('[BLE][SUB]', 'notify RADAR fail', e?.message || e);
} }
} }
return chars return chars;
} }
/** /**
* 标准流程连接 -> 订阅 -> 鉴权 * 标准流程连接 -> 订阅 -> 鉴权
* 鉴权内部会按 bindStatus 分支处理绑定/匹配 * 鉴权内部会按 bindStatus 分支处理绑定/匹配
*/ */
async function standardConnectFlow({deviceId, key}) { async function standardConnectFlow({ deviceId, key }) {
await init() await init();
let targetDeviceId = deviceId let targetDeviceId = deviceId;
if (!targetDeviceId) { if (!targetDeviceId) {
const device = await scanAndPickDevice() const device = await scanAndPickDevice();
if (!device) { if (!device) {
throw new Error('未找到可连接设备') throw new Error('未找到可连接设备');
} }
targetDeviceId = device.deviceId targetDeviceId = device.deviceId;
} }
await connectAndDiscover(targetDeviceId) await connectAndDiscover(targetDeviceId);
const authResult = await auth.ensureAuthorized(key) const authResult = await auth.ensureAuthorized(key);
return { return {
deviceId: targetDeviceId, deviceId: targetDeviceId,
authResult, authResult,
state: ble.getState(), state: ble.getState(),
profile: ble.getProfile() profile: ble.getProfile(),
} };
} }
/** 释放所有资源:解绑分发器 + 清理核心BLE停扫描/断连接/移监听/关适配器)。 */ /** 释放所有资源:解绑分发器 + 清理核心BLE停扫描/断连接/移监听/关适配器)。 */
async function cleanup() { async function cleanup() {
unbindDispatcher() unbindDispatcher();
await ble.cleanup() await ble.cleanup();
} }
return { return {
@ -212,6 +279,6 @@ export function createUnifiedBleController(options = {}) {
scanAndPickDevice, scanAndPickDevice,
connectAndDiscover, connectAndDiscover,
standardConnectFlow, standardConnectFlow,
cleanup cleanup,
} };
} }

View File

@ -20,18 +20,18 @@ const ERROR_MESSAGES = Object.freeze({
10017: 'startBluetoothDevicesDiscovery: 未找到蓝牙适配器', 10017: 'startBluetoothDevicesDiscovery: 未找到蓝牙适配器',
10018: 'stopBluetoothDevicesDiscovery: 调用失败', 10018: 'stopBluetoothDevicesDiscovery: 调用失败',
10019: 'getBluetoothDevices: 调用失败', 10019: 'getBluetoothDevices: 调用失败',
10020: 'getConnectedBluetoothDevices: 调用失败' 10020: 'getConnectedBluetoothDevices: 调用失败',
}) });
/** /**
* 构造统一 BLE Error方便上层直接读取 code/message * 构造统一 BLE Error方便上层直接读取 code/message
*/ */
export function createBleError(code, message, payload = {}) { export function createBleError(code, message, payload = {}) {
const err = new Error(message || ERROR_MESSAGES[code] || '蓝牙操作失败') const err = new Error(message || ERROR_MESSAGES[code] || '蓝牙操作失败');
err.name = 'BleError' err.name = 'BleError';
err.code = code err.code = code;
Object.assign(err, payload) Object.assign(err, payload);
return err return err;
} }
/** /**
@ -39,16 +39,16 @@ export function createBleError(code, message, payload = {}) {
*/ */
export function normalizeBleError(error, fallbackMessage = '蓝牙操作失败') { export function normalizeBleError(error, fallbackMessage = '蓝牙操作失败') {
if (!error) { if (!error) {
return createBleError(-1, fallbackMessage) return createBleError(-1, fallbackMessage);
} }
if (error.name === 'BleError') { if (error.name === 'BleError') {
return error return error;
} }
const errCode = typeof error.errCode === 'number' ? error.errCode : -1 const errCode = typeof error.errCode === 'number' ? error.errCode : -1;
const message = error.errMsg || ERROR_MESSAGES[errCode] || fallbackMessage const message = error.errMsg || ERROR_MESSAGES[errCode] || fallbackMessage;
return createBleError(errCode, message, { raw: error }) return createBleError(errCode, message, { raw: error });
} }
/** /**
@ -56,8 +56,8 @@ export function normalizeBleError(error, fallbackMessage = '蓝牙操作失败')
*/ */
export function getBleErrorText(error, fallbackText = '蓝牙操作失败') { export function getBleErrorText(error, fallbackText = '蓝牙操作失败') {
if (!error) { if (!error) {
return fallbackText return fallbackText;
} }
return error.message || ERROR_MESSAGES[error.code] || fallbackText return error.message || ERROR_MESSAGES[error.code] || fallbackText;
} }

View File

@ -2,61 +2,61 @@
* 轻量事件总线用于 BLE Core 与业务模块之间的事件解耦 * 轻量事件总线用于 BLE Core 与业务模块之间的事件解耦
*/ */
export function createEventBus() { export function createEventBus() {
const listeners = {} const listeners = {};
/** 订阅事件,返回取消订阅函数。 */ /** 订阅事件,返回取消订阅函数。 */
function on(eventName, handler) { function on(eventName, handler) {
if (!listeners[eventName]) { if (!listeners[eventName]) {
listeners[eventName] = new Set() listeners[eventName] = new Set();
} }
listeners[eventName].add(handler) listeners[eventName].add(handler);
// 返回取消订阅函数,方便调用方在页面卸载时回收监听。 // 返回取消订阅函数,方便调用方在页面卸载时回收监听。
return () => { return () => {
off(eventName, handler) off(eventName, handler);
} };
} }
/** 单次订阅:触发一次后自动取消。 */ /** 单次订阅:触发一次后自动取消。 */
function once(eventName, handler) { function once(eventName, handler) {
const unsubscribe = on(eventName, (payload) => { const unsubscribe = on(eventName, (payload) => {
unsubscribe() unsubscribe();
handler(payload) handler(payload);
}) });
return unsubscribe return unsubscribe;
} }
/** 取消指定事件的某个监听。 */ /** 取消指定事件的某个监听。 */
function off(eventName, handler) { function off(eventName, handler) {
if (!listeners[eventName]) { if (!listeners[eventName]) {
return return;
} }
listeners[eventName].delete(handler) listeners[eventName].delete(handler);
} }
/** 广播事件,所有监听器同步执行(内部已 try-catch 隔离)。 */ /** 广播事件,所有监听器同步执行(内部已 try-catch 隔离)。 */
function emit(eventName, payload) { function emit(eventName, payload) {
if (!listeners[eventName]) { if (!listeners[eventName]) {
return return;
} }
listeners[eventName].forEach((handler) => { listeners[eventName].forEach((handler) => {
try { try {
handler(payload) handler(payload);
} catch (error) { } catch (error) {
console.error('[BLE event handler error]', eventName, error) console.error('[BLE event handler error]', eventName, error);
} }
}) });
} }
/** 清空所有事件监听。 */ /** 清空所有事件监听。 */
function clear() { function clear() {
Object.keys(listeners).forEach((eventName) => { Object.keys(listeners).forEach((eventName) => {
listeners[eventName].clear() listeners[eventName].clear();
}) });
} }
return { return {
@ -64,6 +64,6 @@ export function createEventBus() {
once, once,
off, off,
emit, emit,
clear clear,
} };
} }

View File

@ -8,6 +8,12 @@
* @param {number} options.timeout 超时时间(ms) * @param {number} options.timeout 超时时间(ms)
* @param {string} options.timeoutMessage 超时提示 * @param {string} options.timeoutMessage 超时提示
* @param {string} options.unexpectedMessage 非预期状态提示前缀 * @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 = {}) { export function waitForProtocolState(ble, options = {}) {
const { const {
@ -15,14 +21,20 @@ export function waitForProtocolState(ble, options = {}) {
rejectOnUnexpected = false, rejectOnUnexpected = false,
timeout = 6000, timeout = 6000,
timeoutMessage = '等待 UUID_STATE 超时', timeoutMessage = '等待 UUID_STATE 超时',
unexpectedMessage = '收到非预期 UUID_STATE 状态码' unexpectedMessage = '收到非预期 UUID_STATE 状态码',
refreshOnCodes = [],
refreshTimeout = timeout
} = options } = options
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let timer = null let timer = null
let off = null let off = null
try { try {
console.debug('[BLE][STATEWAIT]', 'start', {allowed: allowedCodes.join(',') || 'ANY', timeout}) console.debug('[BLE][STATEWAIT]', 'start', {
allowed: allowedCodes.join(',') || 'ANY',
timeout,
refreshOn: refreshOnCodes.join(',') || 'NONE'
})
} catch (e) { } catch (e) {
} }
@ -38,13 +50,20 @@ export function waitForProtocolState(ble, options = {}) {
callback() callback()
} }
timer = setTimeout(() => { const armTimer = (duration) => {
try { if (timer) {
console.debug('[BLE][STATEWAIT]', 'timeout') clearTimeout(timer)
} catch (e) {
} }
finish(() => reject(new Error(timeoutMessage))) timer = setTimeout(() => {
}, timeout) try {
console.debug('[BLE][STATEWAIT]', 'timeout')
} catch (e) {
}
finish(() => reject(new Error(timeoutMessage)))
}, duration)
}
armTimer(timeout)
off = ble.on('protocol:state', (payload) => { off = ble.on('protocol:state', (payload) => {
const stateCode = Number(payload?.stateCode) const stateCode = Number(payload?.stateCode)
@ -58,6 +77,16 @@ export function waitForProtocolState(ble, options = {}) {
return return
} }
// 命中中间态刷新:重置超时窗口,继续等待后续最终态
if (refreshOnCodes.length && refreshOnCodes.includes(stateCode)) {
try {
console.debug('[BLE][STATEWAIT]', 'refresh', stateCode)
} catch (e) {
}
armTimer(refreshTimeout)
return
}
if (rejectOnUnexpected) { if (rejectOnUnexpected) {
try { try {
console.debug('[BLE][STATEWAIT]', 'reject', stateCode) console.debug('[BLE][STATEWAIT]', 'reject', stateCode)

View File

@ -1,31 +1,32 @@
import {writeA7Payload} from '../core/packet' import { writeA7Payload } from '../core/packet';
import {waitForProtocolState} from '../core/stateWait' import { waitForProtocolState } from '../core/stateWait';
import {parseDeviceInfo} from '../parsers' import { parseDeviceInfo } from '../parsers';
import {BLE_STAGE} from '../core/state' import { BLE_STAGE } from '../core/state';
/** 生成指定长度的随机密钥(排除易混淆字符 0/O/1/I/l。 */ /** 生成指定长度的随机密钥(排除易混淆字符 0/O/1/I/l。 */
function generateRandomKey(length = 16) { function generateRandomKey(length = 16) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789' const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
let result = '' let result = '';
for (let index = 0; index < length; index += 1) { for (let index = 0; index < length; index += 1) {
const random = Math.floor(Math.random() * chars.length) const random = Math.floor(Math.random() * chars.length);
result += chars[random] result += chars[random];
} }
return result return result;
} }
const authLog = (...args) => { const authLog = (...args) => {
try { try {
console.debug('[BLE][AUTH]', ...args) console.debug('[BLE][AUTH]', ...args);
} catch (e) { } catch (e) {}
} };
}
/** 将 MAC 地址去除非十六进制字符并转大写,用于密钥拼接。 */ /** 将 MAC 地址去除非十六进制字符并转大写,用于密钥拼接。 */
function normalizeMacForKey(macText = '') { function normalizeMacForKey(macText = '') {
return String(macText || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase() return String(macText || '')
.replace(/[^0-9a-fA-F]/g, '')
.toUpperCase();
} }
/** /**
@ -33,23 +34,23 @@ function normalizeMacForKey(macText = '') {
* 默认后缀为 "0000"对应 12 MAC + 4 位后缀 = 16 位密钥 * 默认后缀为 "0000"对应 12 MAC + 4 位后缀 = 16 位密钥
*/ */
export function buildKeyFromMac(macText, suffix = '0000', expectedLength = 16) { export function buildKeyFromMac(macText, suffix = '0000', expectedLength = 16) {
const normalizedMac = normalizeMacForKey(macText) const normalizedMac = normalizeMacForKey(macText);
const normalizedSuffix = String(suffix || '').trim() const normalizedSuffix = String(suffix || '').trim();
if (!normalizedSuffix) { if (!normalizedSuffix) {
throw new Error('密钥后缀不能为空') throw new Error('密钥后缀不能为空');
} }
const requiredMacLength = expectedLength - normalizedSuffix.length const requiredMacLength = expectedLength - normalizedSuffix.length;
if (requiredMacLength <= 0) { if (requiredMacLength <= 0) {
throw new Error('密钥长度配置异常') throw new Error('密钥长度配置异常');
} }
// if (normalizedMac.length !== requiredMacLength) { // if (normalizedMac.length !== requiredMacLength) {
// throw new Error(`设备 MAC 长度异常,期望 ${requiredMacLength} 位,实际 ${normalizedMac.length} 位`) // throw new Error(`设备 MAC 长度异常,期望 ${requiredMacLength} 位,实际 ${normalizedMac.length} 位`)
// } // }
return `${normalizedMac}${normalizedSuffix}` return `${normalizedMac}${normalizedSuffix}`;
} }
/** /**
@ -59,22 +60,22 @@ export function buildKeyFromMac(macText, suffix = '0000', expectedLength = 16) {
* @returns {string} 实际写入的密钥值 * @returns {string} 实际写入的密钥值
*/ */
async function writePacketsToKeyCharacteristic(ble, keyText) { async function writePacketsToKeyCharacteristic(ble, keyText) {
const {key} = ble.getState().characteristics const { key } = ble.getState().characteristics;
if (!key || !key.uuid) { if (!key || !key.uuid) {
throw new Error('未发现 UUID_KEY 特征值') throw new Error('未发现 UUID_KEY 特征值');
} }
const profile = ble.getProfile() const profile = ble.getProfile();
const keyValue = String(keyText || '').trim() const keyValue = String(keyText || '').trim();
// if (keyValue.length !== profile.auth.keyLength) { // if (keyValue.length !== profile.auth.keyLength) {
// throw new Error(`密钥长度必须为 ${profile.auth.keyLength}`) // throw new Error(`密钥长度必须为 ${profile.auth.keyLength}`)
// } // }
await writeA7Payload(ble, key.uuid, keyValue, profile.packet) await writeA7Payload(ble, key.uuid, keyValue, profile.packet);
return keyValue return keyValue;
} }
/** /**
@ -85,7 +86,12 @@ async function writePacketsToKeyCharacteristic(ble, keyText) {
* @param {number[]} expectedStateCodes 期望的状态码列表 * @param {number[]} expectedStateCodes 期望的状态码列表
* @param {object} options 超时等配置 * @param {object} options 超时等配置
*/ */
async function writeKeyAndExpectStates(ble, keyText, expectedStateCodes, options = {}) { async function writeKeyAndExpectStates(
ble,
keyText,
expectedStateCodes,
options = {},
) {
// 设备订阅 STATE 后可能先发送 -1(未写密钥)/0(空闲) 等中间态, // 设备订阅 STATE 后可能先发送 -1(未写密钥)/0(空闲) 等中间态,
// 若 rejectOnUnexpected:true 会导致中间态直接 reject阻断鉴权流程。 // 若 rejectOnUnexpected:true 会导致中间态直接 reject阻断鉴权流程。
const waitPromise = waitForProtocolState(ble, { const waitPromise = waitForProtocolState(ble, {
@ -93,16 +99,32 @@ async function writeKeyAndExpectStates(ble, keyText, expectedStateCodes, options
rejectOnUnexpected: false, rejectOnUnexpected: false,
timeout: options.timeout || 6000, timeout: options.timeout || 6000,
timeoutMessage: '等待 UUID_STATE 超时', timeoutMessage: '等待 UUID_STATE 超时',
unexpectedMessage: '密钥流程状态异常' unexpectedMessage: '密钥流程状态异常',
}) });
authLog('STATE wait before writeKey, expect=', expectedStateCodes) authLog('STATE wait before writeKey, expect=', expectedStateCodes);
const keyValue = await writePacketsToKeyCharacteristic(ble, keyText) const keyValue = await writePacketsToKeyCharacteristic(ble, keyText);
authLog('key written, length=', keyValue) authLog('key written, length=', keyValue);
const stateCode = Number((await waitPromise).stateCode) try {
authLog('STATE received for KEY=', stateCode) const stateCode = Number((await waitPromise).stateCode);
ble.setStage(BLE_STAGE.AUTHORIZED) authLog('STATE received for KEY=', stateCode);
ble.setAuthState({keyMatched: true, lastStateCode: stateCode}) ble.setStage(BLE_STAGE.AUTHORIZED);
return {success: true, stateCode, key: keyValue} 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}`);
}
} }
/** /**
@ -111,56 +133,77 @@ async function writeKeyAndExpectStates(ble, keyText, expectedStateCodes, options
* - bindStatus=1已绑定使用已知密钥匹配等待 STATE=7 * - bindStatus=1已绑定使用已知密钥匹配等待 STATE=7
* - 其他状态尝试用提供的密钥匹配等待 STATE=7 5 * - 其他状态尝试用提供的密钥匹配等待 STATE=7 5
*/ */
async function ensureAuthorizedWithDeviceInfo(ble, info, keyText, options = {}) { async function ensureAuthorizedWithDeviceInfo(
const bindStatus = info.bindStatus !== null ? Number(info.bindStatus) : null ble,
const profile = ble.getProfile() info,
keyText,
options = {},
) {
const bindStatus = info.bindStatus !== null ? Number(info.bindStatus) : null;
const profile = ble.getProfile();
ble.setAuthState({ ble.setAuthState({
bound: Number.isNaN(bindStatus) ? null : bindStatus, bound: Number.isNaN(bindStatus) ? null : bindStatus,
keyMatched: false keyMatched: false,
}) });
if (bindStatus === 0) { if (bindStatus === 0) {
const keyToBind = String(keyText || '').trim() || generateRandomKey(profile.auth.keyLength) const keyToBind =
const authResult = await writeKeyAndExpectStates(ble, keyToBind, [5], options) String(keyText || '').trim() || generateRandomKey(profile.auth.keyLength);
const authResult = await writeKeyAndExpectStates(
ble,
keyToBind,
[5],
options,
);
return { return {
mode: 'BIND', mode: 'BIND',
deviceInfo: info, deviceInfo: info,
authResult, authResult,
key: keyToBind key: keyToBind,
} };
} }
if (bindStatus === 1) { if (bindStatus === 1) {
const keyToMatch = String(keyText || '').trim() const keyToMatch = String(keyText || '').trim();
if (!keyToMatch) { if (!keyToMatch) {
throw new Error('设备已绑定,必须提供已绑定密钥用于匹配') throw new Error('设备已绑定,必须提供已绑定密钥用于匹配');
} }
const authResult = await writeKeyAndExpectStates(ble, keyToMatch, [7], options) const authResult = await writeKeyAndExpectStates(
ble,
keyToMatch,
[7],
options,
);
return { return {
mode: 'MATCH', mode: 'MATCH',
deviceInfo: info, deviceInfo: info,
authResult, authResult,
key: keyToMatch key: keyToMatch,
} };
} }
const fallbackKey = String(keyText || '').trim() const fallbackKey = String(keyText || '').trim();
if (!fallbackKey) { if (!fallbackKey) {
throw new Error(`无法识别的绑定状态: ${info.bindStatus},且未提供密钥`) throw new Error(`无法识别的绑定状态: ${info.bindStatus},且未提供密钥`);
} }
const fallbackResult = await writeKeyAndExpectStates(ble, fallbackKey, [7, 5], options) const fallbackResult = await writeKeyAndExpectStates(
ble,
fallbackKey,
[7, 5],
options,
);
return { return {
mode: 'UNKNOWN_BIND_STATE_FALLBACK', mode: 'UNKNOWN_BIND_STATE_FALLBACK',
deviceInfo: info, deviceInfo: info,
authResult: fallbackResult, authResult: fallbackResult,
key: fallbackKey key: fallbackKey,
} };
} }
/** /**
@ -173,35 +216,48 @@ export function createAuthModule(ble) {
* 先订阅 protocol:msg 事件再触发 read保证不丢通知 * 先订阅 protocol:msg 事件再触发 read保证不丢通知
*/ */
async function readDeviceInfo(options = {}) { async function readDeviceInfo(options = {}) {
const {msg} = ble.getState().characteristics const { msg } = ble.getState().characteristics;
if (!msg || !msg.uuid) { if (!msg || !msg.uuid) {
throw new Error('未发现 UUID_MSG 特征值') throw new Error('未发现 UUID_MSG 特征值');
} }
const timeout = Number(options.msgTimeout) || 4000 const timeout = Number(options.msgTimeout) || 4000;
let timer = null let timer = null;
let off = null let off = null;
const valuePromise = new Promise((resolve, reject) => { const valuePromise = new Promise((resolve, reject) => {
const finish = (callback) => { const finish = (callback) => {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer);
if (off) off() if (off) off();
callback() callback();
} };
off = ble.on('protocol:msg', (payload) => finish(() => resolve(payload))) off = ble.on('protocol:msg', (payload) => finish(() => resolve(payload)));
timer = setTimeout(() => finish(() => reject(new Error('读取 UUID_MSG 超时'))), timeout) timer = setTimeout(
}) () => finish(() => reject(new Error('读取 UUID_MSG 超时'))),
timeout,
);
});
try { try {
await ble.readCharacteristic(msg.uuid) await ble.readCharacteristic(msg.uuid);
const payload = await valuePromise const payload = await valuePromise;
return parseDeviceInfo(payload.value, ble.getProfile().id) 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) { } catch (error) {
if (timer) clearTimeout(timer) if (timer) clearTimeout(timer);
if (off) off() if (off) off();
throw error throw error;
} }
} }
@ -212,9 +268,9 @@ export function createAuthModule(ble) {
* 3) bindStatus=1 写已知密钥并等待 STATE=7 * 3) bindStatus=1 写已知密钥并等待 STATE=7
*/ */
async function ensureAuthorized(keyText, options = {}) { async function ensureAuthorized(keyText, options = {}) {
ble.setStage(BLE_STAGE.AUTHORIZING) ble.setStage(BLE_STAGE.AUTHORIZING);
const info = await readDeviceInfo(options) const info = await readDeviceInfo(options);
return ensureAuthorizedWithDeviceInfo(ble, info, keyText, options) return ensureAuthorizedWithDeviceInfo(ble, info, keyText, options);
} }
/** /**
@ -222,19 +278,28 @@ export function createAuthModule(ble) {
* 用于先鉴权再进行 WiFi 配置的页面流程 * 用于先鉴权再进行 WiFi 配置的页面流程
*/ */
async function ensureAuthorizedByMac(options = {}) { async function ensureAuthorizedByMac(options = {}) {
ble.setStage(BLE_STAGE.AUTHORIZING) ble.setStage(BLE_STAGE.AUTHORIZING);
const info = await readDeviceInfo(options) const info = await readDeviceInfo(options);
const profile = ble.getProfile() const profile = ble.getProfile();
const keyByMac = buildKeyFromMac(info.mac, options.suffix || '0000', profile.auth.keyLength) const keyByMac = buildKeyFromMac(
info.mac,
options.suffix || '0000',
profile.auth.keyLength,
);
const authResult = await ensureAuthorizedWithDeviceInfo(ble, info, keyByMac, options) const authResult = await ensureAuthorizedWithDeviceInfo(
ble,
info,
keyByMac,
options,
);
return { return {
...authResult, ...authResult,
key: keyByMac, key: keyByMac,
keyRule: 'MAC+0000' keyRule: 'MAC+0000',
} };
} }
return { return {
@ -242,6 +307,6 @@ export function createAuthModule(ble) {
ensureAuthorized, ensureAuthorized,
ensureAuthorizedByMac, ensureAuthorizedByMac,
buildKeyFromMac, buildKeyFromMac,
generateRandomKey generateRandomKey,
} };
} }

View File

@ -1,4 +1,4 @@
import { parseRadarData } from '../parsers' import { parseRadarData } from '../parsers';
/** /**
* 雷达控制模块管理雷达启停窄床模式跌倒参数写入 * 雷达控制模块管理雷达启停窄床模式跌倒参数写入
@ -7,11 +7,11 @@ import { parseRadarData } from '../parsers'
*/ */
export function createRadarModule(ble) { export function createRadarModule(ble) {
function requireAuthorized() { function requireAuthorized() {
const state = ble.getState() const state = ble.getState();
// 协议要求:获取雷达校准数据前必须先完成密钥匹配。 // 协议要求:获取雷达校准数据前必须先完成密钥匹配。
if (!state.auth || state.auth.keyMatched !== true) { if (!state.auth || state.auth.keyMatched !== true) {
throw new Error('请先完成密钥匹配,再执行雷达命令') throw new Error('请先完成密钥匹配,再执行雷达命令');
} }
} }
@ -20,33 +20,33 @@ export function createRadarModule(ble) {
* @param {number} commandByte 指令字节 0xA1 启动0xA2 停止 * @param {number} commandByte 指令字节 0xA1 启动0xA2 停止
*/ */
async function sendCommand(commandByte) { async function sendCommand(commandByte) {
const { cmd } = ble.getState().characteristics const { cmd } = ble.getState().characteristics;
if (!cmd || !cmd.uuid) { if (!cmd || !cmd.uuid) {
throw new Error('未发现 UUID_CMD 特征值') throw new Error('未发现 UUID_CMD 特征值');
} }
await ble.writeCharacteristic(cmd.uuid, new Uint8Array([commandByte])) await ble.writeCharacteristic(cmd.uuid, new Uint8Array([commandByte]));
} }
/** 启动雷达数据流(写入 CMD = 0xA1。 */ /** 启动雷达数据流(写入 CMD = 0xA1。 */
async function startRadarStream() { async function startRadarStream() {
const profile = ble.getProfile() const profile = ble.getProfile();
// 协议1.2.2:先密钥匹配,再向 UUID_CMD 写 0xA1。 // 协议1.2.2:先密钥匹配,再向 UUID_CMD 写 0xA1。
requireAuthorized() requireAuthorized();
await sendCommand(profile.radar.startCommand) await sendCommand(profile.radar.startCommand);
return true return true;
} }
/** 停止雷达数据流(写入 CMD = 0xA2。 */ /** 停止雷达数据流(写入 CMD = 0xA2。 */
async function stopRadarStream() { async function stopRadarStream() {
const profile = ble.getProfile() const profile = ble.getProfile();
// 停止同样要求处于鉴权通过态,避免误发到未鉴权连接。 // 停止同样要求处于鉴权通过态,避免误发到未鉴权连接。
requireAuthorized() requireAuthorized();
await sendCommand(profile.radar.stopCommand) await sendCommand(profile.radar.stopCommand);
return true return true;
} }
/** /**
@ -55,20 +55,23 @@ export function createRadarModule(ble) {
* @param {boolean} enabled true=开启false=关闭 * @param {boolean} enabled true=开启false=关闭
*/ */
async function setNarrowMode(enabled) { async function setNarrowMode(enabled) {
const profile = ble.getProfile() const profile = ble.getProfile();
if (!profile.cmd.supportsNarrowMode) { if (!profile.cmd.supportsNarrowMode) {
throw new Error('当前设备不支持窄床模式设置') throw new Error('当前设备不支持窄床模式设置');
} }
requireAuthorized() requireAuthorized();
// 协议规定:写入 [0x7C, flag] 两字节 // 协议规定:写入 [0x7C, flag] 两字节
const { cmd } = ble.getState().characteristics const { cmd } = ble.getState().characteristics;
if (!cmd || !cmd.uuid) { if (!cmd || !cmd.uuid) {
throw new Error('未发现 UUID_CMD 特征值') throw new Error('未发现 UUID_CMD 特征值');
} }
await ble.writeCharacteristic(cmd.uuid, new Uint8Array([0x7C, enabled ? 1 : 0])) await ble.writeCharacteristic(
cmd.uuid,
new Uint8Array([0x7c, enabled ? 1 : 0]),
);
} }
/** /**
@ -76,31 +79,32 @@ export function createRadarModule(ble) {
* @param {Uint8Array|number[]} rawParams 跌倒参数字节 * @param {Uint8Array|number[]} rawParams 跌倒参数字节
*/ */
async function writeFallParams(rawParams) { async function writeFallParams(rawParams) {
const profile = ble.getProfile() const profile = ble.getProfile();
if (!profile.cmd.supportsFallParam67) { if (!profile.cmd.supportsFallParam67) {
throw new Error('当前设备不支持 0x67 跌倒参数写入') throw new Error('当前设备不支持 0x67 跌倒参数写入');
} }
requireAuthorized() requireAuthorized();
const payload = rawParams instanceof Uint8Array ? rawParams : new Uint8Array(rawParams) const payload =
const packet = new Uint8Array(1 + payload.length) rawParams instanceof Uint8Array ? rawParams : new Uint8Array(rawParams);
packet[0] = 0x67 const packet = new Uint8Array(1 + payload.length);
packet.set(payload, 1) packet[0] = 0x67;
packet.set(payload, 1);
const { cmd } = ble.getState().characteristics const { cmd } = ble.getState().characteristics;
if (!cmd || !cmd.uuid) { if (!cmd || !cmd.uuid) {
throw new Error('未发现 UUID_CMD 特征值') throw new Error('未发现 UUID_CMD 特征值');
} }
await ble.writeCharacteristic(cmd.uuid, packet) await ble.writeCharacteristic(cmd.uuid, packet);
} }
/** 按当前 profile 解析雷达通知数据。 */ /** 按当前 profile 解析雷达通知数据。 */
function parseNotifyPayload(payload) { function parseNotifyPayload(payload) {
const profile = ble.getProfile() const profile = ble.getProfile();
return parseRadarData(payload, profile.id) return parseRadarData(payload, profile.id);
} }
return { return {
@ -110,6 +114,6 @@ export function createRadarModule(ble) {
setNarrowMode, setNarrowMode,
writeFallParams, writeFallParams,
parseNotifyPayload, parseNotifyPayload,
requireAuthorized requireAuthorized,
} };
} }

View File

@ -3,6 +3,16 @@ import { waitForProtocolState } from '../core/stateWait'
const WIFI_FINAL_STATE_CODES = Object.freeze([1, 2, 3]) 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 * 校验 WiFi 入参规则来源于 profile.wifi
*/ */
@ -27,12 +37,15 @@ function validateWifi(profile, ssid, password) {
} }
} }
/** 校验鉴权状态,未完成密钥匹配则抛出异常。 */ /**
* 校验鉴权状态
* 厂家确认密钥流程已不启用此处仅记录未鉴权状态不阻断 WiFi 配置
*/
function requireAuthorized(ble) { function requireAuthorized(ble) {
const state = ble.getState() const state = ble.getState()
if (!state.auth || state.auth.keyMatched !== true) { if (!state.auth || state.auth.keyMatched !== true) {
throw new Error('请先完成密钥鉴权,再发送 WiFi 配置') console.debug('[BLE][WiFi]', '密钥未匹配(密钥流程已废弃,继续配网)')
} }
} }
@ -53,7 +66,7 @@ export function createWifiModule(ble) {
* @param {string} ssid WiFi名称 * @param {string} ssid WiFi名称
* @param {string} password WiFi密码 * @param {string} password WiFi密码
* @param {object} options * @param {object} options
* @param {number} options.timeout 等待状态回执超时(ms)默认12000 * @param {number} options.timeout 等待状态回执超时(ms)默认60000
* @param {number} options.preDelayMs 写入前延迟(ms)ED713 默认120ms * @param {number} options.preDelayMs 写入前延迟(ms)ED713 默认120ms
* @param {number} options.packetInterval 分包写入间隔(ms) * @param {number} options.packetInterval 分包写入间隔(ms)
* @returns {{ success: boolean, stateCode: number }} stateCode=3 表示连接成功 * @returns {{ success: boolean, stateCode: number }} stateCode=3 表示连接成功
@ -70,12 +83,16 @@ export function createWifiModule(ble) {
throw new Error('未发现 UUID_WIFI 特征值') throw new Error('未发现 UUID_WIFI 特征值')
} }
const totalTimeout = options.timeout || WIFI_TOTAL_TIMEOUT_MS
// 先注册 STATE 等待,再写入,确保不漏回执 // 先注册 STATE 等待,再写入,确保不漏回执
// 收到 STATE=4(连接中) 时刷新窗口,避免固定上限短于真实 DHCP 时长误判超时
const waitPromise = waitForProtocolState(ble, { const waitPromise = waitForProtocolState(ble, {
allowedCodes: WIFI_FINAL_STATE_CODES, allowedCodes: WIFI_FINAL_STATE_CODES,
rejectOnUnexpected: false, rejectOnUnexpected: false,
timeout: options.timeout || 12000, timeout: totalTimeout,
timeoutMessage: '等待 WiFi 状态超时' timeoutMessage: '等待 WiFi 状态超时',
refreshOnCodes: WIFI_CONNECTING_STATE_CODES,
refreshTimeout: WIFI_REFRESH_TIMEOUT_MS
}) })
// ED713 固件需要写入前延迟,否则可能丢包 // ED713 固件需要写入前延迟,否则可能丢包
@ -86,12 +103,23 @@ export function createWifiModule(ble) {
} }
log('write WiFi start', { service: ble.getState().serviceId, char: wifi.uuid }) 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, { const sent = await writeA7Payload(ble, wifi.uuid, `${s}|${p}`, profile.packet, {
packetInterval: Number(options.packetInterval) || 0 packetInterval
}) })
log('write WiFi done, packets=', sent) log('write WiFi done, packets=', sent, 'interval=', packetInterval)
const code = Number((await waitPromise).stateCode) 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) log('STATE(final) received for WiFi=', code)
// STATE=3 表示 WiFi 连接成功 // STATE=3 表示 WiFi 连接成功