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.

20
.gitignore vendored
View File

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

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 = {}) {
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() {

View File

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

View File

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

View File

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

View File

@ -1,10 +1,49 @@
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'
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;
}
}
/**
* 创建特征值通知分发器
@ -13,52 +52,61 @@ import {uuidEquals} from '../utils/uuid'
*/
function createCharacteristicDispatcher(ble) {
return (event) => {
const state = ble.getState()
const profile = ble.getProfile()
const current = state.characteristics
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)
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
})
keyMatched: stateCode === 7 || stateCode === 5 || state.auth.keyMatched,
});
ble.emit('protocol:state', {
...event,
profileId: profile.id,
stateCode
})
return
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
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)
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
profileId: ble.getProfile().id,
});
return;
}
ble.emit('protocol:raw', {
...event,
profileId: profile.id
})
}
profileId: profile.id,
});
};
}
/**
@ -67,40 +115,43 @@ function createCharacteristicDispatcher(ble) {
* @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 ble = createBleCore(options);
const auth = createAuthModule(ble);
const wifi = createWifiModule(ble);
const radar = createRadarModule(ble);
const characteristicDispatcher = createCharacteristicDispatcher(ble)
let offCharacteristic = null
const characteristicDispatcher = createCharacteristicDispatcher(ble);
let offCharacteristic = null;
/** 绑定特征值分发器到 ble 事件总线。 */
function bindDispatcher() {
if (offCharacteristic) {
return
return;
}
offCharacteristic = ble.on('characteristic:value', characteristicDispatcher)
offCharacteristic = ble.on(
'characteristic:value',
characteristicDispatcher,
);
}
/** 解绑特征值分发器。 */
function unbindDispatcher() {
if (!offCharacteristic) {
return
return;
}
offCharacteristic()
offCharacteristic = null
offCharacteristic();
offCharacteristic = null;
}
/** 初始化蓝牙适配器并注册所有监听(适配器状态/连接/特征值/分发器)。 */
async function init() {
await ble.openAdapter()
ble.watchAdapterState()
ble.watchConnectionChange()
ble.watchCharacteristicValue()
bindDispatcher()
await ble.openAdapter();
ble.watchAdapterState();
ble.watchConnectionChange();
ble.watchCharacteristicValue();
bindDispatcher();
}
/**
@ -115,92 +166,108 @@ export function createUnifiedBleController(options = {}) {
async function scanAndPickDevice({
serviceUuid,
scanDuration = 5000,
fallbackWithoutFilter = true
fallbackWithoutFilter = true,
} = {}) {
const profile = ble.getProfile()
const targetService = serviceUuid || profile.uuids.service
const profile = ble.getProfile();
const targetService = serviceUuid || profile.uuids.service;
ble.watchDeviceFound()
ble.watchDeviceFound();
await ble.startDiscovery({services: [targetService]})
await new Promise((resolve) => setTimeout(resolve, scanDuration))
await ble.stopDiscovery()
await ble.startDiscovery({ services: [targetService] });
await new Promise((resolve) => setTimeout(resolve, scanDuration));
await ble.stopDiscovery();
let selected = ble.pickDeviceByService(targetService)
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
await ble.startDiscovery({ services: [] });
await new Promise((resolve) => setTimeout(resolve, scanDuration));
await ble.stopDiscovery();
selected =
ble.pickDeviceByService(targetService) ||
ble.listDiscoveredDevices()[0] ||
null;
}
return selected
return selected;
}
/**
* 连接后严格执行协议前置
* - 发现服务和特征
* - 订阅 UUID_STATE / UUID_RADAR
* 连接后严格执行协议前置厂家要求顺序
* 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()
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)
console.debug('[BLE][SUB]', 'notify STATE ok', chars.state.uuid);
} 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) {
try {
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) {
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 分支处理绑定/匹配
*/
async function standardConnectFlow({deviceId, key}) {
await init()
async function standardConnectFlow({ deviceId, key }) {
await init();
let targetDeviceId = deviceId
let targetDeviceId = deviceId;
if (!targetDeviceId) {
const device = await scanAndPickDevice()
const device = await scanAndPickDevice();
if (!device) {
throw new Error('未找到可连接设备')
throw new Error('未找到可连接设备');
}
targetDeviceId = device.deviceId
targetDeviceId = device.deviceId;
}
await connectAndDiscover(targetDeviceId)
const authResult = await auth.ensureAuthorized(key)
await connectAndDiscover(targetDeviceId);
const authResult = await auth.ensureAuthorized(key);
return {
deviceId: targetDeviceId,
authResult,
state: ble.getState(),
profile: ble.getProfile()
}
profile: ble.getProfile(),
};
}
/** 释放所有资源:解绑分发器 + 清理核心BLE停扫描/断连接/移监听/关适配器)。 */
async function cleanup() {
unbindDispatcher()
await ble.cleanup()
unbindDispatcher();
await ble.cleanup();
}
return {
@ -212,6 +279,6 @@ export function createUnifiedBleController(options = {}) {
scanAndPickDevice,
connectAndDiscover,
standardConnectFlow,
cleanup
}
cleanup,
};
}

View File

@ -20,18 +20,18 @@ const ERROR_MESSAGES = Object.freeze({
10017: 'startBluetoothDevicesDiscovery: 未找到蓝牙适配器',
10018: 'stopBluetoothDevicesDiscovery: 调用失败',
10019: 'getBluetoothDevices: 调用失败',
10020: 'getConnectedBluetoothDevices: 调用失败'
})
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
const err = new Error(message || ERROR_MESSAGES[code] || '蓝牙操作失败');
err.name = 'BleError';
err.code = code;
Object.assign(err, payload);
return err;
}
/**
@ -39,16 +39,16 @@ export function createBleError(code, message, payload = {}) {
*/
export function normalizeBleError(error, fallbackMessage = '蓝牙操作失败') {
if (!error) {
return createBleError(-1, fallbackMessage)
return createBleError(-1, fallbackMessage);
}
if (error.name === 'BleError') {
return error
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 })
const errCode = typeof error.errCode === 'number' ? error.errCode : -1;
const message = error.errMsg || ERROR_MESSAGES[errCode] || fallbackMessage;
return createBleError(errCode, message, { raw: error });
}
/**
@ -56,8 +56,8 @@ export function normalizeBleError(error, fallbackMessage = '蓝牙操作失败')
*/
export function getBleErrorText(error, fallbackText = '蓝牙操作失败') {
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 与业务模块之间的事件解耦
*/
export function createEventBus() {
const listeners = {}
const listeners = {};
/** 订阅事件,返回取消订阅函数。 */
function on(eventName, handler) {
if (!listeners[eventName]) {
listeners[eventName] = new Set()
listeners[eventName] = new Set();
}
listeners[eventName].add(handler)
listeners[eventName].add(handler);
// 返回取消订阅函数,方便调用方在页面卸载时回收监听。
return () => {
off(eventName, handler)
}
off(eventName, handler);
};
}
/** 单次订阅:触发一次后自动取消。 */
function once(eventName, handler) {
const unsubscribe = on(eventName, (payload) => {
unsubscribe()
handler(payload)
})
unsubscribe();
handler(payload);
});
return unsubscribe
return unsubscribe;
}
/** 取消指定事件的某个监听。 */
function off(eventName, handler) {
if (!listeners[eventName]) {
return
return;
}
listeners[eventName].delete(handler)
listeners[eventName].delete(handler);
}
/** 广播事件,所有监听器同步执行(内部已 try-catch 隔离)。 */
function emit(eventName, payload) {
if (!listeners[eventName]) {
return
return;
}
listeners[eventName].forEach((handler) => {
try {
handler(payload)
handler(payload);
} catch (error) {
console.error('[BLE event handler error]', eventName, error)
console.error('[BLE event handler error]', eventName, error);
}
})
});
}
/** 清空所有事件监听。 */
function clear() {
Object.keys(listeners).forEach((eventName) => {
listeners[eventName].clear()
})
listeners[eventName].clear();
});
}
return {
@ -64,6 +64,6 @@ export function createEventBus() {
once,
off,
emit,
clear
}
clear,
};
}

View File

@ -8,6 +8,12 @@
* @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 {
@ -15,14 +21,20 @@ export function waitForProtocolState(ble, options = {}) {
rejectOnUnexpected = false,
timeout = 6000,
timeoutMessage = '等待 UUID_STATE 超时',
unexpectedMessage = '收到非预期 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})
console.debug('[BLE][STATEWAIT]', 'start', {
allowed: allowedCodes.join(',') || 'ANY',
timeout,
refreshOn: refreshOnCodes.join(',') || 'NONE'
})
} catch (e) {
}
@ -38,13 +50,20 @@ export function waitForProtocolState(ble, options = {}) {
callback()
}
const armTimer = (duration) => {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
try {
console.debug('[BLE][STATEWAIT]', 'timeout')
} catch (e) {
}
finish(() => reject(new Error(timeoutMessage)))
}, timeout)
}, duration)
}
armTimer(timeout)
off = ble.on('protocol:state', (payload) => {
const stateCode = Number(payload?.stateCode)
@ -58,6 +77,16 @@ export function waitForProtocolState(ble, options = {}) {
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)

View File

@ -1,31 +1,32 @@
import {writeA7Payload} from '../core/packet'
import {waitForProtocolState} from '../core/stateWait'
import {parseDeviceInfo} from '../parsers'
import {BLE_STAGE} from '../core/state'
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 = ''
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
let result = '';
for (let index = 0; index < length; index += 1) {
const random = Math.floor(Math.random() * chars.length)
result += chars[random]
const random = Math.floor(Math.random() * chars.length);
result += chars[random];
}
return result
return result;
}
const authLog = (...args) => {
try {
console.debug('[BLE][AUTH]', ...args)
} catch (e) {
}
}
console.debug('[BLE][AUTH]', ...args);
} catch (e) {}
};
/** 将 MAC 地址去除非十六进制字符并转大写,用于密钥拼接。 */
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 位密钥
*/
export function buildKeyFromMac(macText, suffix = '0000', expectedLength = 16) {
const normalizedMac = normalizeMacForKey(macText)
const normalizedSuffix = String(suffix || '').trim()
const normalizedMac = normalizeMacForKey(macText);
const normalizedSuffix = String(suffix || '').trim();
if (!normalizedSuffix) {
throw new Error('密钥后缀不能为空')
throw new Error('密钥后缀不能为空');
}
const requiredMacLength = expectedLength - normalizedSuffix.length
const requiredMacLength = expectedLength - normalizedSuffix.length;
if (requiredMacLength <= 0) {
throw new Error('密钥长度配置异常')
throw new Error('密钥长度配置异常');
}
// if (normalizedMac.length !== requiredMacLength) {
// 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} 实际写入的密钥值
*/
async function writePacketsToKeyCharacteristic(ble, keyText) {
const {key} = ble.getState().characteristics
const { key } = ble.getState().characteristics;
if (!key || !key.uuid) {
throw new Error('未发现 UUID_KEY 特征值')
throw new Error('未发现 UUID_KEY 特征值');
}
const profile = ble.getProfile()
const keyValue = String(keyText || '').trim()
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)
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 {object} options 超时等配置
*/
async function writeKeyAndExpectStates(ble, keyText, expectedStateCodes, options = {}) {
async function writeKeyAndExpectStates(
ble,
keyText,
expectedStateCodes,
options = {},
) {
// 设备订阅 STATE 后可能先发送 -1(未写密钥)/0(空闲) 等中间态,
// 若 rejectOnUnexpected:true 会导致中间态直接 reject阻断鉴权流程。
const waitPromise = waitForProtocolState(ble, {
@ -93,16 +99,32 @@ async function writeKeyAndExpectStates(ble, keyText, expectedStateCodes, options
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)
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}
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}`);
}
}
/**
@ -111,56 +133,77 @@ async function writeKeyAndExpectStates(ble, keyText, expectedStateCodes, options
* - 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()
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
})
keyMatched: false,
});
if (bindStatus === 0) {
const keyToBind = String(keyText || '').trim() || generateRandomKey(profile.auth.keyLength)
const authResult = await writeKeyAndExpectStates(ble, keyToBind, [5], options)
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
}
key: keyToBind,
};
}
if (bindStatus === 1) {
const keyToMatch = String(keyText || '').trim()
const keyToMatch = String(keyText || '').trim();
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 {
mode: 'MATCH',
deviceInfo: info,
authResult,
key: keyToMatch
}
key: keyToMatch,
};
}
const fallbackKey = String(keyText || '').trim()
const fallbackKey = String(keyText || '').trim();
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 {
mode: 'UNKNOWN_BIND_STATE_FALLBACK',
deviceInfo: info,
authResult: fallbackResult,
key: fallbackKey
}
key: fallbackKey,
};
}
/**
@ -173,35 +216,48 @@ export function createAuthModule(ble) {
* 先订阅 protocol:msg 事件再触发 read保证不丢通知
*/
async function readDeviceInfo(options = {}) {
const {msg} = ble.getState().characteristics
const { msg } = ble.getState().characteristics;
if (!msg || !msg.uuid) {
throw new Error('未发现 UUID_MSG 特征值')
throw new Error('未发现 UUID_MSG 特征值');
}
const timeout = Number(options.msgTimeout) || 4000
let timer = null
let off = null
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()
}
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)
})
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
return parseDeviceInfo(payload.value, ble.getProfile().id)
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
if (timer) clearTimeout(timer);
if (off) off();
throw error;
}
}
@ -212,9 +268,9 @@ export function createAuthModule(ble) {
* 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)
ble.setStage(BLE_STAGE.AUTHORIZING);
const info = await readDeviceInfo(options);
return ensureAuthorizedWithDeviceInfo(ble, info, keyText, options);
}
/**
@ -222,19 +278,28 @@ export function createAuthModule(ble) {
* 用于先鉴权再进行 WiFi 配置的页面流程
*/
async function ensureAuthorizedByMac(options = {}) {
ble.setStage(BLE_STAGE.AUTHORIZING)
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 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)
const authResult = await ensureAuthorizedWithDeviceInfo(
ble,
info,
keyByMac,
options,
);
return {
...authResult,
key: keyByMac,
keyRule: 'MAC+0000'
}
keyRule: 'MAC+0000',
};
}
return {
@ -242,6 +307,6 @@ export function createAuthModule(ble) {
ensureAuthorized,
ensureAuthorizedByMac,
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) {
function requireAuthorized() {
const state = ble.getState()
const state = ble.getState();
// 协议要求:获取雷达校准数据前必须先完成密钥匹配。
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 停止
*/
async function sendCommand(commandByte) {
const { cmd } = ble.getState().characteristics
const { cmd } = ble.getState().characteristics;
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。 */
async function startRadarStream() {
const profile = ble.getProfile()
const profile = ble.getProfile();
// 协议1.2.2:先密钥匹配,再向 UUID_CMD 写 0xA1。
requireAuthorized()
await sendCommand(profile.radar.startCommand)
return true
requireAuthorized();
await sendCommand(profile.radar.startCommand);
return true;
}
/** 停止雷达数据流(写入 CMD = 0xA2。 */
async function stopRadarStream() {
const profile = ble.getProfile()
const profile = ble.getProfile();
// 停止同样要求处于鉴权通过态,避免误发到未鉴权连接。
requireAuthorized()
await sendCommand(profile.radar.stopCommand)
return true
requireAuthorized();
await sendCommand(profile.radar.stopCommand);
return true;
}
/**
@ -55,20 +55,23 @@ export function createRadarModule(ble) {
* @param {boolean} enabled true=开启false=关闭
*/
async function setNarrowMode(enabled) {
const profile = ble.getProfile()
const profile = ble.getProfile();
if (!profile.cmd.supportsNarrowMode) {
throw new Error('当前设备不支持窄床模式设置')
throw new Error('当前设备不支持窄床模式设置');
}
requireAuthorized()
requireAuthorized();
// 协议规定:写入 [0x7C, flag] 两字节
const { cmd } = ble.getState().characteristics
const { cmd } = ble.getState().characteristics;
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 跌倒参数字节
*/
async function writeFallParams(rawParams) {
const profile = ble.getProfile()
const profile = ble.getProfile();
if (!profile.cmd.supportsFallParam67) {
throw new Error('当前设备不支持 0x67 跌倒参数写入')
throw new Error('当前设备不支持 0x67 跌倒参数写入');
}
requireAuthorized()
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 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
const { cmd } = ble.getState().characteristics;
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 解析雷达通知数据。 */
function parseNotifyPayload(payload) {
const profile = ble.getProfile()
return parseRadarData(payload, profile.id)
const profile = ble.getProfile();
return parseRadarData(payload, profile.id);
}
return {
@ -110,6 +114,6 @@ export function createRadarModule(ble) {
setNarrowMode,
writeFallParams,
parseNotifyPayload,
requireAuthorized
}
requireAuthorized,
};
}

View File

@ -3,6 +3,16 @@ 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
*/
@ -27,12 +37,15 @@ function validateWifi(profile, ssid, password) {
}
}
/** 校验鉴权状态,未完成密钥匹配则抛出异常。 */
/**
* 校验鉴权状态
* 厂家确认密钥流程已不启用此处仅记录未鉴权状态不阻断 WiFi 配置
*/
function requireAuthorized(ble) {
const state = ble.getState()
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} password WiFi密码
* @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.packetInterval 分包写入间隔(ms)
* @returns {{ success: boolean, stateCode: number }} stateCode=3 表示连接成功
@ -70,12 +83,16 @@ export function createWifiModule(ble) {
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: options.timeout || 12000,
timeoutMessage: '等待 WiFi 状态超时'
timeout: totalTimeout,
timeoutMessage: '等待 WiFi 状态超时',
refreshOnCodes: WIFI_CONNECTING_STATE_CODES,
refreshTimeout: WIFI_REFRESH_TIMEOUT_MS
})
// ED713 固件需要写入前延迟,否则可能丢包
@ -86,12 +103,23 @@ export function createWifiModule(ble) {
}
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: 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)
// STATE=3 表示 WiFi 连接成功