fix(ble): 协议一致性修复、WiFi面板超时修复、搜索行为优化

- 修正 ED713/ED719 MSG 解析分路与雷达 shieldNum 偏移
- 雷达窄床模式命令格式改为两字节 [0x7C, flag]
- 鉴权流程 rejectOnUnexpected=false 避免中间态阻断
- readDeviceInfo 增加 msgTimeout 4秒超时保护
- config.vue prepareWifiConfig 超时修正(800000→8000)
- 搜索 30s 自动停止,暂停保留设备数据
- closeAdapter 重置 handler 引用,每次搜索前完整适配器重开
This commit is contained in:
ozh 2026-04-30 11:21:41 +08:00
parent 68b1485d52
commit 2a6eeccbed
8 changed files with 195 additions and 57 deletions

View File

@ -19,6 +19,7 @@
4. 标准业务阶段
- 读取设备信息:`read(F501)`
- 鉴权:`write(F402, A7分包key)`,监听 `F301` 状态码
- 当前项目约定:**已知密钥 = 设备 MAC 去分隔符后 + `0000`**,例如 `10:52:1C:81:64:F4``10521C8164F40000`
- 鉴权成功后进行功能操作:
- WiFi 配网:`write(F401, A7分包ssid|pass)`
- 控制命令:`write(F403, [cmd])`
@ -69,6 +70,7 @@ utils/ble/
- `scanAndPickDevice/connectAndDiscover/cleanup`
- 业务模块
- `createAuthModule(ble).ensureAuthorized(key)`
- `createAuthModule(ble).ensureAuthorizedByMac({ suffix: '0000' })`
- `createWifiModule(ble).configureWifi(ssid, password)`
- `createRadarModule(ble).startRadarStream()/stopRadarStream()/writeFallParams()`
- 工具
@ -122,3 +124,45 @@ async function runStandardFlow({ key, ssid, password }) {
- `cmd.supportsNarrowMode`: ED713=true
- `cmd.supportsFallParam67`: ED719=true
- 未知设备先用 `UNKNOWN`,收到 `F302` 数据后按签名字节自动识别 profile。
## 6. 修改记录
### 2026-04-30 对照协议文档第二轮修复
#### commonParser.js
- `parseDeviceInfo(payload, profileId)`: 重写为按 ED713/ED719 分路解析
- ED713MAC[0:6] + bindStatus[6] + 其它[7:10] + productId[10:12] + baseBoard[12] + coreBoard[13] + 其它[14:16] + deviceType[16] + wifiStatus[17] + cellularStatus[18]
- ED719布局与 ED713 一致bindStatus/productId 偏移相同,文档仅省略标注),复用 `parseEd713DeviceInfo`
- `parseEd719Radar`: 修正 `shieldNum` 从 bytes[27] → bytes[24](按文档结构体偏移累加 header[4]+event+people+num+nodata+height+mode+beeper+left+right+front+back+sensitive+nodata1+stateDelay=24
- 新增 `wallOption`、`peopleHaveSwitch`、`peopleActivitySwitch` 字段解析
#### radar.js
- `setNarrowMode(enabled)`: 修正命令格式,从单字节 `0x7C`/`0x7D` 改为两字节 `[0x7C, flag]`flag 0=关 1=开(符合协议文档 CMD=0x7C + 1byte参数
#### auth.js
- `readDeviceInfo(options)`: 新增 `msgTimeout` 参数与 4 秒超时保护,防止 UUID_MSG 无回包时页面挂死
- `readDeviceInfo()`: 调用 `parseDeviceInfo` 时传入 `ble.getProfile().id`
- `ensureAuthorized`/`ensureAuthorizedByMac`: 透传 `options``readDeviceInfo`
- `writeKeyAndExpectStates`: `rejectOnUnexpected``true` 改为 `false`,避免设备中间态(-1/0直接 reject 阻断鉴权
- `ensureAuthorizedWithDeviceInfo`: 移除 `bindStatus === null` 分支ED719 实际布局与 ED713 一致bindStatus 可正常读取)
#### controller.js
- 特征分发器 `createCharacteristicDispatcher``parseDeviceInfo` 调用传入 `profile.id`
#### bleCore.js
- `closeAdapter()`: 新增重置 `adapterStateHandler`/`connectionStateHandler`/`characteristicValueHandler`/`deviceFoundHandler` 为 `null`,确保 `openAdapter``init()` 可重新注册所有原生监听
#### pages/device/config.vue
- `openWifiPanel`: `prepareWifiConfig` 超时从 `800000` 改为 `8000`,新增 `msgTimeout: 4000`
- `wifiPanelVisible = true` 后加 `await this.$nextTick()` 确保 Vue DOM 更新后再调原生 toast
#### hooks/useBluetoothDiscovery.js
- 新增 `SCAN_DURATION_MS = 30000`,蓝牙搜索 30 秒后自动停止
- `startSearch`: 搜索开始前同步设置 `isSearching = true`(避免异步导致无法暂停);先 `stopDiscoverySilently()``resetDeviceMap()`(新搜索刷新数据);启动 30 秒自动停止定时器
- `startSearch`: `stopDiscoverySilently()` 后执行完整适配器重置(`unwatch*` 注销所有原生监听 → `closeAdapter` 关闭适配器 → `initialized = false`),由 `ensureInitialized` 重新打开并注册全部监听,确保每次搜索都从干净适配器状态开始,修复二次搜索找不到设备的问题
- `stopSearch`: 移除 `resetDeviceMap()` 调用,暂停搜索时保留已发现设备数据;清除自动停止定时器
### 2026-04-29 对照协议文档修复
(初次修复,记录同上,已被 2026-04-30 条目覆盖)

View File

@ -13,6 +13,7 @@ import {showToast} from '@/utils/toast'
const FILTER_SCAN_TIMEOUT_MS = 4000
const RESTART_SCAN_DELAY_MS = 120
const DISCOVERY_POLL_INTERVAL_MS = 1200
const SCAN_DURATION_MS = 30000
const TARGET_NAME_KEYWORDS = Object.freeze(['ED713', '713WQ', 'ED719', 'EP832'])
function promisifyUniApi(apiName, params = {}) {
@ -77,6 +78,7 @@ export function createBluetoothDiscovery(options = {}) {
let acceptingDeviceEvents = false
let filterFallbackTimer = null
let discoveryPollTimer = null
let autoStopTimer = null
let offDeviceFound = null
let offAdapterState = null
@ -273,6 +275,15 @@ export function createBluetoothDiscovery(options = {}) {
discoveryPollTimer = null
}
function clearAutoStopTimer() {
if (!autoStopTimer) {
return
}
clearTimeout(autoStopTimer)
autoStopTimer = null
}
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
@ -597,8 +608,23 @@ export function createBluetoothDiscovery(options = {}) {
return
}
// 立即标记搜索中,防止 await 期间用户重复点击或 stopSearch 被覆盖
isSearching = true
onSearchingChange(true)
clearFallbackTimer()
clearDiscoveryPollTimer()
clearAutoStopTimer()
// 先停止上一次残留扫描
await stopDiscoverySilently()
// 重置 BLE 适配器:关闭并重开,确保每次搜索都是干净的初始状态
if (initialized) {
unbindControllerEvents()
await controller.ble.closeAdapter()
await delay(RESTART_SCAN_DELAY_MS)
initialized = false
}
searchSessionId += 1
const sessionId = searchSessionId
@ -614,8 +640,6 @@ export function createBluetoothDiscovery(options = {}) {
throw {errCode: 10001}
}
await stopDiscoverySilently()
acceptingDeviceEvents = true
try {
@ -628,12 +652,19 @@ export function createBluetoothDiscovery(options = {}) {
return
}
setSearching(true)
startDiscoveryPolling(sessionId)
await fallbackToUnfilteredDiscoveryIfNeeded(sessionId)
// 30s 自动停止搜索
autoStopTimer = setTimeout(() => {
if (searchSessionId === sessionId) {
stopSearch(false)
}
}, SCAN_DURATION_MS)
} catch (error) {
acceptingDeviceEvents = false
setSearching(false)
isSearching = false
onSearchingChange(false)
showToast(getBluetoothErrorText(error))
throw error
}
@ -642,12 +673,14 @@ export function createBluetoothDiscovery(options = {}) {
async function stopSearch(showStopToast = true) {
clearFallbackTimer()
clearDiscoveryPollTimer()
clearAutoStopTimer()
searchSessionId += 1
acceptingDeviceEvents = false
await stopDiscoverySilently()
// 暂停搜索保留已发现的设备数据,不做 resetDeviceMap
setSearching(false)
if (showStopToast) {
showToast('已停止搜索')

View File

@ -331,15 +331,18 @@ export default {
try {
/**
* 按需求先执行 MAC+0000 密钥鉴权成功后再允许进入 WiFi 弹层
* 先按项目固定已知密钥规则MAC+0000完成鉴权成功后再允许进入 WiFi 弹层
*/
await this.discoveryController.prepareWifiConfig(targetDeviceId, {
suffix: '0000',
timeout: 800000
timeout: 8000,
msgTimeout: 4000
})
this.wifiPrepared = true
this.wifiPanelVisible = true
// Vue DOM toast Vue
await this.$nextTick()
showToast('密钥鉴权成功请填写WiFi信息')
} catch (error) {
showToast(getErrorMessage(error))

View File

@ -109,6 +109,12 @@ export function createBleCore(options = {}) {
logger.warn('[BLE] close adapter failed', error)
}
// 重置原生 handler 引用,以便 openAdapter 后 init() 可重新注册
adapterStateHandler = null
connectionStateHandler = null
characteristicValueHandler = null
deviceFoundHandler = null
patchState({
stage: BLE_STAGE.IDLE,
available: false,

View File

@ -36,7 +36,7 @@ function createCharacteristicDispatcher(ble) {
ble.emit('protocol:msg', {
...event,
profileId: profile.id,
deviceInfo: parseDeviceInfo(event.value)
deviceInfo: parseDeviceInfo(event.value, profile.id)
})
return
}

View File

@ -86,9 +86,11 @@ async function writePacketsToKeyCharacteristic(ble, keyText) {
* @param {object} options 超时等配置
*/
async function writeKeyAndExpectStates(ble, keyText, expectedStateCodes, options = {}) {
// 设备订阅 STATE 后可能先发送 -1(未写密钥)/0(空闲) 等中间态,
// 若 rejectOnUnexpected:true 会导致中间态直接 reject阻断鉴权流程。
const waitPromise = waitForProtocolState(ble, {
allowedCodes: expectedStateCodes,
rejectOnUnexpected: true,
rejectOnUnexpected: false,
timeout: options.timeout || 6000,
timeoutMessage: '等待 UUID_STATE 超时',
unexpectedMessage: '密钥流程状态异常'
@ -105,12 +107,12 @@ async function writeKeyAndExpectStates(ble, keyText, expectedStateCodes, options
/**
* 根据设备绑定状态执行鉴权流程
* - bindStatus=0未绑定生成随机密钥写入等待 STATE=5绑定成功
* - bindStatus=1已绑定使用已知密钥匹配等待 STATE=7匹配成功
* - bindStatus=0未绑定生成随机密钥写入等待 STATE=5
* - bindStatus=1已绑定使用已知密钥匹配等待 STATE=7
* - 其他状态尝试用提供的密钥匹配等待 STATE=7 5
*/
async function ensureAuthorizedWithDeviceInfo(ble, info, keyText, options = {}) {
const bindStatus = Number(info.bindStatus)
const bindStatus = info.bindStatus !== null ? Number(info.bindStatus) : null
const profile = ble.getProfile()
ble.setAuthState({
@ -170,24 +172,37 @@ export function createAuthModule(ble) {
* 读取 UUID_MSG 获取设备信息MAC/绑定状态/产品ID等
* 先订阅 protocol:msg 事件再触发 read保证不丢通知
*/
async function readDeviceInfo() {
async function readDeviceInfo(options = {}) {
const {msg} = ble.getState().characteristics
if (!msg || !msg.uuid) {
throw new Error('未发现 UUID_MSG 特征值')
}
const valuePromise = new Promise((resolve) => {
const off = ble.on('protocol:msg', (payload) => {
off()
resolve(payload)
})
const timeout = Number(options.msgTimeout) || 4000
let timer = null
let off = null
const valuePromise = new Promise((resolve, reject) => {
const finish = (callback) => {
if (timer) clearTimeout(timer)
if (off) off()
callback()
}
off = ble.on('protocol:msg', (payload) => finish(() => resolve(payload)))
timer = setTimeout(() => finish(() => reject(new Error('读取 UUID_MSG 超时'))), timeout)
})
try {
await ble.readCharacteristic(msg.uuid)
const payload = await valuePromise
return parseDeviceInfo(payload.value)
return parseDeviceInfo(payload.value, ble.getProfile().id)
} catch (error) {
if (timer) clearTimeout(timer)
if (off) off()
throw error
}
}
/**
@ -198,18 +213,18 @@ export function createAuthModule(ble) {
*/
async function ensureAuthorized(keyText, options = {}) {
ble.setStage(BLE_STAGE.AUTHORIZING)
const info = await readDeviceInfo()
const info = await readDeviceInfo(options)
return ensureAuthorizedWithDeviceInfo(ble, info, keyText, options)
}
/**
* 固定规则鉴权密钥 = 设备MAC(去分隔符) + "0000"
* 用于先鉴权再进行 WiFi 配置的页面流程
* 固定规则鉴权密钥 = 设备MAC(去分隔符) + 0000
* 用于先鉴权再进行 WiFi 配置的页面流程
*/
async function ensureAuthorizedByMac(options = {}) {
ble.setStage(BLE_STAGE.AUTHORIZING)
const info = await readDeviceInfo()
const info = await readDeviceInfo(options)
const profile = ble.getProfile()
const keyByMac = buildKeyFromMac(info.mac, options.suffix || '0000', profile.auth.keyLength)

View File

@ -51,7 +51,8 @@ export function createRadarModule(ble) {
/**
* 设置窄床模式 ED713 支持
* @param {boolean} enabled true=开启(0x7D)false=关闭(0x7C)
* 协议CMD=0x7C参数1byte0=, 1=设置后雷达重启
* @param {boolean} enabled true=开启false=关闭
*/
async function setNarrowMode(enabled) {
const profile = ble.getProfile()
@ -61,7 +62,13 @@ export function createRadarModule(ble) {
}
requireAuthorized()
await sendCommand(enabled ? 0x7D : 0x7C)
// 协议规定:写入 [0x7C, flag] 两字节
const { cmd } = ble.getState().characteristics
if (!cmd || !cmd.uuid) {
throw new Error('未发现 UUID_CMD 特征值')
}
await ble.writeCharacteristic(cmd.uuid, new Uint8Array([0x7C, enabled ? 1 : 0]))
}
/**

View File

@ -1,5 +1,6 @@
import {bytesToHex, bytesToMac} from '../utils/hex'
import {joinLowHigh, readUint16LE, toUint8Array} from '../utils/bytes'
import {DEVICE_PROFILE} from '../protocols/profiles'
/**
* 解析 UUID_STATE 的状态字节转为有符号 8bit
@ -14,41 +15,61 @@ export function parseStateCode(payload) {
}
/**
* 解析 UUID_MSG 设备信息ED713/ED719 通用字段优先
* ED713 MSG 解析
* 文档布局MAC(6) + bindStatus(1) + 其它(3) + productId_H(1) + productId_L(1)
* + baseBoard(1) + coreBoard(1) + 其它(2) + deviceType(1)
* + wifiStatus(1) + cellularStatus(1) + 其它(1)
*/
export function parseDeviceInfo(payload) {
function parseEd713DeviceInfo(bytes) {
if (!bytes.length) {
return {
raw: bytes, mac: '', bindStatus: null, productId: null,
deviceType: null, wifiStatus: null, cellularStatus: null
}
}
return {
raw: bytes,
mac: bytesToMac(bytes.slice(0, 6)),
bindStatus: bytes.length > 6 ? bytes[6] : null,
productId: bytes.length > 11 ? joinLowHigh(bytes[11], bytes[10]) : null,
baseBoardVersion: bytes.length > 12 ? bytes[12] : null,
coreBoardVersion: bytes.length > 13 ? bytes[13] : null,
deviceType: bytes.length > 16 ? bytes[16] : null,
wifiStatus: bytes.length > 17 ? bytes[17] : null,
cellularStatus: bytes.length > 18 ? bytes[18] : null
}
}
/**
* ED719 MSG 解析
* 实际偏移与 ED713 一致文档省略了 bindStatus/productId 但底板数据布局相同
* MAC(6) + bindStatus(1) + 其它(3) + productId_H(1) + productId_L(1)
* + baseBoard(1) + coreBoard(1) + 其它(2) + deviceType(1)
* + wifiStatus(1) + cellularStatus(1) + 其它(1)
*/
function parseEd719DeviceInfo(bytes) {
return parseEd713DeviceInfo(bytes)
}
/**
* 解析 UUID_MSG 设备信息 profile 区分 ED713/ED719
*/
export function parseDeviceInfo(payload, profileId = DEVICE_PROFILE.UNKNOWN) {
const bytes = toUint8Array(payload)
if (!bytes.length) {
return {
raw: bytes,
mac: '',
bindStatus: null,
productId: null,
deviceType: null,
wifiStatus: null,
cellularStatus: null
raw: bytes, mac: '', bindStatus: null, productId: null,
deviceType: null, wifiStatus: null, cellularStatus: null
}
}
const macBytes = bytes.slice(0, 6)
const bindStatus = bytes.length > 6 ? bytes[6] : null
const productId = bytes.length > 10 ? joinLowHigh(bytes[10], bytes[9]) : null
const deviceType = bytes.length > 14 ? bytes[14] : null
const wifiStatus = bytes.length > 15 ? bytes[15] : null
const cellularStatus = bytes.length > 16 ? bytes[16] : null
if (profileId === DEVICE_PROFILE.ED713) return parseEd713DeviceInfo(bytes)
if (profileId === DEVICE_PROFILE.ED719) return parseEd719DeviceInfo(bytes)
return {
raw: bytes,
mac: bytesToMac(macBytes),
bindStatus,
productId,
deviceType,
wifiStatus,
cellularStatus,
baseBoardVersion: bytes.length > 11 ? bytes[11] : null,
coreBoardVersion: bytes.length > 12 ? bytes[12] : null
}
// UNKNOWN按 ED713 布局兼容
return parseEd713DeviceInfo(bytes)
}
/**
@ -86,7 +107,12 @@ export function parseEd713Radar(payload) {
/**
* ED719 雷达数据解析
* 屏蔽区数量由 shieldNum 控制每块固定 9 字节
* 按文档结构体定义
* header[4] + event(1) + people(1) + num(1) + nodata(1)
* + height(2) + mode(1) + beeper(1) + left(2) + right(2) + front(2) + back(2)
* + sensitive(1) + nodata1(1) + stateDelay(2) = 24 bytes
* shield_num offset 24屏蔽区在 offset 25
* 屏蔽区之后wallOption + peopleHaveSwitch + peopleActivitySwitch
*/
export function parseEd719Radar(payload) {
const bytes = toUint8Array(payload)
@ -100,10 +126,12 @@ export function parseEd719Radar(payload) {
}
}
const shieldNum = bytes[27] || 0
const shieldStart = 28
const shieldNum = bytes[24] || 0
const shieldStart = 25
const shieldBlockLength = Math.min(Math.max(shieldNum, 0), 4) * 9
const wallOffset = shieldStart + shieldBlockLength
return {
type: 'ED719',
valid: true,
@ -122,6 +150,8 @@ export function parseEd719Radar(payload) {
stateDelay: readUint16LE(bytes, 22),
shieldNum,
shieldZoneRaw: Array.from(bytes.slice(shieldStart, shieldStart + shieldBlockLength)),
wallOption: bytes[shieldStart + shieldBlockLength] ?? null
wallOption: bytes[wallOffset] ?? null,
peopleHaveSwitch: bytes[wallOffset + 1] ?? null,
peopleActivitySwitch: bytes[wallOffset + 2] ?? null
}
}