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. 标准业务阶段 4. 标准业务阶段
- 读取设备信息:`read(F501)` - 读取设备信息:`read(F501)`
- 鉴权:`write(F402, A7分包key)`,监听 `F301` 状态码 - 鉴权:`write(F402, A7分包key)`,监听 `F301` 状态码
- 当前项目约定:**已知密钥 = 设备 MAC 去分隔符后 + `0000`**,例如 `10:52:1C:81:64:F4``10521C8164F40000`
- 鉴权成功后进行功能操作: - 鉴权成功后进行功能操作:
- WiFi 配网:`write(F401, A7分包ssid|pass)` - WiFi 配网:`write(F401, A7分包ssid|pass)`
- 控制命令:`write(F403, [cmd])` - 控制命令:`write(F403, [cmd])`
@ -69,6 +70,7 @@ utils/ble/
- `scanAndPickDevice/connectAndDiscover/cleanup` - `scanAndPickDevice/connectAndDiscover/cleanup`
- 业务模块 - 业务模块
- `createAuthModule(ble).ensureAuthorized(key)` - `createAuthModule(ble).ensureAuthorized(key)`
- `createAuthModule(ble).ensureAuthorizedByMac({ suffix: '0000' })`
- `createWifiModule(ble).configureWifi(ssid, password)` - `createWifiModule(ble).configureWifi(ssid, password)`
- `createRadarModule(ble).startRadarStream()/stopRadarStream()/writeFallParams()` - `createRadarModule(ble).startRadarStream()/stopRadarStream()/writeFallParams()`
- 工具 - 工具
@ -122,3 +124,45 @@ async function runStandardFlow({ key, ssid, password }) {
- `cmd.supportsNarrowMode`: ED713=true - `cmd.supportsNarrowMode`: ED713=true
- `cmd.supportsFallParam67`: ED719=true - `cmd.supportsFallParam67`: ED719=true
- 未知设备先用 `UNKNOWN`,收到 `F302` 数据后按签名字节自动识别 profile。 - 未知设备先用 `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 FILTER_SCAN_TIMEOUT_MS = 4000
const RESTART_SCAN_DELAY_MS = 120 const RESTART_SCAN_DELAY_MS = 120
const DISCOVERY_POLL_INTERVAL_MS = 1200 const DISCOVERY_POLL_INTERVAL_MS = 1200
const SCAN_DURATION_MS = 30000
const TARGET_NAME_KEYWORDS = Object.freeze(['ED713', '713WQ', 'ED719', 'EP832']) const TARGET_NAME_KEYWORDS = Object.freeze(['ED713', '713WQ', 'ED719', 'EP832'])
function promisifyUniApi(apiName, params = {}) { function promisifyUniApi(apiName, params = {}) {
@ -77,6 +78,7 @@ export function createBluetoothDiscovery(options = {}) {
let acceptingDeviceEvents = false let acceptingDeviceEvents = false
let filterFallbackTimer = null let filterFallbackTimer = null
let discoveryPollTimer = null let discoveryPollTimer = null
let autoStopTimer = null
let offDeviceFound = null let offDeviceFound = null
let offAdapterState = null let offAdapterState = null
@ -273,6 +275,15 @@ export function createBluetoothDiscovery(options = {}) {
discoveryPollTimer = null discoveryPollTimer = null
} }
function clearAutoStopTimer() {
if (!autoStopTimer) {
return
}
clearTimeout(autoStopTimer)
autoStopTimer = null
}
function delay(ms) { function delay(ms) {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(resolve, ms) setTimeout(resolve, ms)
@ -597,8 +608,23 @@ export function createBluetoothDiscovery(options = {}) {
return return
} }
// 立即标记搜索中,防止 await 期间用户重复点击或 stopSearch 被覆盖
isSearching = true
onSearchingChange(true)
clearFallbackTimer() clearFallbackTimer()
clearDiscoveryPollTimer() clearDiscoveryPollTimer()
clearAutoStopTimer()
// 先停止上一次残留扫描
await stopDiscoverySilently()
// 重置 BLE 适配器:关闭并重开,确保每次搜索都是干净的初始状态
if (initialized) {
unbindControllerEvents()
await controller.ble.closeAdapter()
await delay(RESTART_SCAN_DELAY_MS)
initialized = false
}
searchSessionId += 1 searchSessionId += 1
const sessionId = searchSessionId const sessionId = searchSessionId
@ -614,8 +640,6 @@ export function createBluetoothDiscovery(options = {}) {
throw {errCode: 10001} throw {errCode: 10001}
} }
await stopDiscoverySilently()
acceptingDeviceEvents = true acceptingDeviceEvents = true
try { try {
@ -628,12 +652,19 @@ export function createBluetoothDiscovery(options = {}) {
return return
} }
setSearching(true)
startDiscoveryPolling(sessionId) startDiscoveryPolling(sessionId)
await fallbackToUnfilteredDiscoveryIfNeeded(sessionId) await fallbackToUnfilteredDiscoveryIfNeeded(sessionId)
// 30s 自动停止搜索
autoStopTimer = setTimeout(() => {
if (searchSessionId === sessionId) {
stopSearch(false)
}
}, SCAN_DURATION_MS)
} catch (error) { } catch (error) {
acceptingDeviceEvents = false acceptingDeviceEvents = false
setSearching(false) isSearching = false
onSearchingChange(false)
showToast(getBluetoothErrorText(error)) showToast(getBluetoothErrorText(error))
throw error throw error
} }
@ -642,12 +673,14 @@ export function createBluetoothDiscovery(options = {}) {
async function stopSearch(showStopToast = true) { async function stopSearch(showStopToast = true) {
clearFallbackTimer() clearFallbackTimer()
clearDiscoveryPollTimer() clearDiscoveryPollTimer()
clearAutoStopTimer()
searchSessionId += 1 searchSessionId += 1
acceptingDeviceEvents = false acceptingDeviceEvents = false
await stopDiscoverySilently() await stopDiscoverySilently()
// 暂停搜索保留已发现的设备数据,不做 resetDeviceMap
setSearching(false) setSearching(false)
if (showStopToast) { if (showStopToast) {
showToast('已停止搜索') showToast('已停止搜索')

View File

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

View File

@ -109,6 +109,12 @@ export function createBleCore(options = {}) {
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
patchState({ patchState({
stage: BLE_STAGE.IDLE, stage: BLE_STAGE.IDLE,
available: false, available: false,

View File

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

View File

@ -86,9 +86,11 @@ async function writePacketsToKeyCharacteristic(ble, keyText) {
* @param {object} options 超时等配置 * @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, { const waitPromise = waitForProtocolState(ble, {
allowedCodes: expectedStateCodes, allowedCodes: expectedStateCodes,
rejectOnUnexpected: true, rejectOnUnexpected: false,
timeout: options.timeout || 6000, timeout: options.timeout || 6000,
timeoutMessage: '等待 UUID_STATE 超时', timeoutMessage: '等待 UUID_STATE 超时',
unexpectedMessage: '密钥流程状态异常' unexpectedMessage: '密钥流程状态异常'
@ -105,12 +107,12 @@ async function writeKeyAndExpectStates(ble, keyText, expectedStateCodes, options
/** /**
* 根据设备绑定状态执行鉴权流程 * 根据设备绑定状态执行鉴权流程
* - bindStatus=0未绑定生成随机密钥写入等待 STATE=5绑定成功 * - bindStatus=0未绑定生成随机密钥写入等待 STATE=5
* - bindStatus=1已绑定使用已知密钥匹配等待 STATE=7匹配成功 * - bindStatus=1已绑定使用已知密钥匹配等待 STATE=7
* - 其他状态尝试用提供的密钥匹配等待 STATE=7 5 * - 其他状态尝试用提供的密钥匹配等待 STATE=7 5
*/ */
async function ensureAuthorizedWithDeviceInfo(ble, info, keyText, options = {}) { 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() const profile = ble.getProfile()
ble.setAuthState({ ble.setAuthState({
@ -170,24 +172,37 @@ export function createAuthModule(ble) {
* 读取 UUID_MSG 获取设备信息MAC/绑定状态/产品ID等 * 读取 UUID_MSG 获取设备信息MAC/绑定状态/产品ID等
* 先订阅 protocol:msg 事件再触发 read保证不丢通知 * 先订阅 protocol:msg 事件再触发 read保证不丢通知
*/ */
async function readDeviceInfo() { 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 valuePromise = new Promise((resolve) => { const timeout = Number(options.msgTimeout) || 4000
const off = ble.on('protocol:msg', (payload) => { let timer = null
off() let off = null
resolve(payload)
}) 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)
}) })
await ble.readCharacteristic(msg.uuid) try {
const payload = await valuePromise 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 = {}) { async function ensureAuthorized(keyText, options = {}) {
ble.setStage(BLE_STAGE.AUTHORIZING) ble.setStage(BLE_STAGE.AUTHORIZING)
const info = await readDeviceInfo() const info = await readDeviceInfo(options)
return ensureAuthorizedWithDeviceInfo(ble, info, keyText, options) return ensureAuthorizedWithDeviceInfo(ble, info, keyText, options)
} }
/** /**
* 固定规则鉴权密钥 = 设备MAC(去分隔符) + "0000" * 固定规则鉴权密钥 = 设备MAC(去分隔符) + 0000
* 用于先鉴权再进行 WiFi 配置的页面流程 * 用于先鉴权再进行 WiFi 配置的页面流程
*/ */
async function ensureAuthorizedByMac(options = {}) { async function ensureAuthorizedByMac(options = {}) {
ble.setStage(BLE_STAGE.AUTHORIZING) ble.setStage(BLE_STAGE.AUTHORIZING)
const info = await readDeviceInfo() 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)

View File

@ -51,7 +51,8 @@ export function createRadarModule(ble) {
/** /**
* 设置窄床模式 ED713 支持 * 设置窄床模式 ED713 支持
* @param {boolean} enabled true=开启(0x7D)false=关闭(0x7C) * 协议CMD=0x7C参数1byte0=, 1=设置后雷达重启
* @param {boolean} enabled true=开启false=关闭
*/ */
async function setNarrowMode(enabled) { async function setNarrowMode(enabled) {
const profile = ble.getProfile() const profile = ble.getProfile()
@ -61,7 +62,13 @@ export function createRadarModule(ble) {
} }
requireAuthorized() 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 {bytesToHex, bytesToMac} from '../utils/hex'
import {joinLowHigh, readUint16LE, toUint8Array} from '../utils/bytes' import {joinLowHigh, readUint16LE, toUint8Array} from '../utils/bytes'
import {DEVICE_PROFILE} from '../protocols/profiles'
/** /**
* 解析 UUID_STATE 的状态字节转为有符号 8bit * 解析 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) const bytes = toUint8Array(payload)
if (!bytes.length) { if (!bytes.length) {
return { return {
raw: bytes, raw: bytes, mac: '', bindStatus: null, productId: null,
mac: '', deviceType: null, wifiStatus: null, cellularStatus: null
bindStatus: null,
productId: null,
deviceType: null,
wifiStatus: null,
cellularStatus: null
} }
} }
const macBytes = bytes.slice(0, 6) if (profileId === DEVICE_PROFILE.ED713) return parseEd713DeviceInfo(bytes)
const bindStatus = bytes.length > 6 ? bytes[6] : null if (profileId === DEVICE_PROFILE.ED719) return parseEd719DeviceInfo(bytes)
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
return { // UNKNOWN按 ED713 布局兼容
raw: bytes, return parseEd713DeviceInfo(bytes)
mac: bytesToMac(macBytes),
bindStatus,
productId,
deviceType,
wifiStatus,
cellularStatus,
baseBoardVersion: bytes.length > 11 ? bytes[11] : null,
coreBoardVersion: bytes.length > 12 ? bytes[12] : null
}
} }
/** /**
@ -86,7 +107,12 @@ export function parseEd713Radar(payload) {
/** /**
* ED719 雷达数据解析 * 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) { export function parseEd719Radar(payload) {
const bytes = toUint8Array(payload) const bytes = toUint8Array(payload)
@ -100,10 +126,12 @@ export function parseEd719Radar(payload) {
} }
} }
const shieldNum = bytes[27] || 0 const shieldNum = bytes[24] || 0
const shieldStart = 28 const shieldStart = 25
const shieldBlockLength = Math.min(Math.max(shieldNum, 0), 4) * 9 const shieldBlockLength = Math.min(Math.max(shieldNum, 0), 4) * 9
const wallOffset = shieldStart + shieldBlockLength
return { return {
type: 'ED719', type: 'ED719',
valid: true, valid: true,
@ -122,6 +150,8 @@ export function parseEd719Radar(payload) {
stateDelay: readUint16LE(bytes, 22), stateDelay: readUint16LE(bytes, 22),
shieldNum, shieldNum,
shieldZoneRaw: Array.from(bytes.slice(shieldStart, shieldStart + shieldBlockLength)), 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
} }
} }