feat(ble): 重构蓝牙认证模块并新增WiFi配置功能
- 新增buildKeyFromMac函数,按协议生成MAC+0000固定规则密钥 - 在设备列表页添加WiFi配置弹层界面 - 实现WiFi名称密码输入、当前WiFi获取、历史配置导入功能 - 添加prepareWifiConfig流程,专用于WiFi配置前的MAC鉴权 - 更新configureWifi方法,使用新的状态等待机制并验证授权状态
This commit is contained in:
parent
599a01cd73
commit
aa0264ba19
Binary file not shown.
Binary file not shown.
|
|
@ -692,6 +692,20 @@ export function createBluetoothDiscovery(options = {}) {
|
|||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* MAC固定规则鉴权:密钥=MAC(去分隔符)+0000。
|
||||
*/
|
||||
async function authorizeDeviceByMac(options = {}) {
|
||||
if (!currentDeviceId) {
|
||||
throw new Error('请先连接设备后再鉴权')
|
||||
}
|
||||
|
||||
const result = await controller.auth.ensureAuthorizedByMac(options)
|
||||
lastAuthResult = result
|
||||
onAuthChange(result)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准连接流程:连接 -> 发现 -> 订阅 -> 鉴权。
|
||||
*/
|
||||
|
|
@ -721,6 +735,14 @@ export function createBluetoothDiscovery(options = {}) {
|
|||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* WiFi配置专用流程:连接 -> 发现 -> 订阅 -> 按 MAC+0000 完成鉴权。
|
||||
*/
|
||||
async function prepareWifiConfig(deviceId, options = {}) {
|
||||
await connectDevice(deviceId)
|
||||
return authorizeDeviceByMac(options)
|
||||
}
|
||||
|
||||
async function readDeviceInfo() {
|
||||
const info = await controller.auth.readDeviceInfo()
|
||||
lastDeviceInfo = info
|
||||
|
|
@ -806,7 +828,9 @@ export function createBluetoothDiscovery(options = {}) {
|
|||
// 连接与协议流程能力。
|
||||
connectDevice,
|
||||
authorizeDevice,
|
||||
authorizeDeviceByMac,
|
||||
standardConnectFlow,
|
||||
prepareWifiConfig,
|
||||
disconnectDevice,
|
||||
|
||||
// 协议业务能力。
|
||||
|
|
|
|||
|
|
@ -31,19 +31,106 @@
|
|||
</view>
|
||||
<text class="device-id">{{ device.deviceId }}</text>
|
||||
<view class="device-actions">
|
||||
<view class="action-btn">网络配置</view>
|
||||
<view class="action-btn" :class="{ disabled: wifiPreparing }" @click.stop="openWifiPanel(device)">
|
||||
{{ wifiPreparing ? '鉴权中' : '网络配置' }}
|
||||
</view>
|
||||
<view class="action-btn">参数配置</view>
|
||||
<view class="action-btn">服务器配置</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="wifiPanelVisible" class="wifi-mask" @click="closeWifiPanel">
|
||||
<view class="wifi-dialog" @click.stop>
|
||||
<view class="wifi-dialog-header">
|
||||
<text class="wifi-dialog-title">设备网络设置</text>
|
||||
<text class="wifi-dialog-close" @click="closeWifiPanel">×</text>
|
||||
</view>
|
||||
|
||||
<view class="wifi-device-row">
|
||||
<text class="wifi-device-label">设备</text>
|
||||
<text class="wifi-device-value">{{ selectedDeviceName }}</text>
|
||||
</view>
|
||||
|
||||
<view class="wifi-row wifi-row-ssid">
|
||||
<text class="wifi-label"><text class="wifi-required">*</text>WiFi名称</text>
|
||||
<input
|
||||
v-model.trim="wifiForm.ssid"
|
||||
class="wifi-input wifi-input-ssid"
|
||||
placeholder="请输入 WiFi 名称"
|
||||
:maxlength="32"
|
||||
confirm-type="done"
|
||||
/>
|
||||
<view class="wifi-current-btn" :class="{ disabled: fetchingCurrentWifi }" @click.stop="fetchCurrentWifiSsid">
|
||||
{{ fetchingCurrentWifi ? '获取中...' : '一键获取当前连接WiFi' }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="wifi-row">
|
||||
<text class="wifi-label"><text class="wifi-required">*</text>WiFi密码</text>
|
||||
<input
|
||||
v-model.trim="wifiForm.password"
|
||||
class="wifi-input"
|
||||
placeholder="请输入密码"
|
||||
password
|
||||
:maxlength="63"
|
||||
confirm-type="done"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="wifi-import-row" @click="importLastWifiConfig">
|
||||
一键导入上次 WiFi 配置
|
||||
</view>
|
||||
|
||||
<view class="wifi-submit-btn" :class="{ disabled: wifiSending }" @click="confirmWifiSettings">
|
||||
{{ wifiSending ? '发送中...' : '发送' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createBluetoothDiscovery } from '@/hooks/useBluetoothDiscovery'
|
||||
import { navigateBack } from '@/utils/navigation'
|
||||
import { showToast } from '@/utils/toast'
|
||||
|
||||
const WIFI_HISTORY_STORAGE_KEY = 'SMARTHOME_LAST_WIFI_CONFIG'
|
||||
|
||||
const WIFI_STATE_MESSAGES = Object.freeze({
|
||||
1: 'WiFi 参数校验失败,请检查名称与密码',
|
||||
2: '设备连接该 WiFi 失败,请确认密码是否正确',
|
||||
3: 'WiFi 配置成功',
|
||||
4: '设备正在连接 WiFi,请稍候'
|
||||
})
|
||||
|
||||
function getErrorMessage(error) {
|
||||
if (!error) {
|
||||
return '操作失败,请重试'
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
|
||||
return error.message || error.errMsg || '操作失败,请重试'
|
||||
}
|
||||
|
||||
function promisifyUniApi(apiName, params = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof uni[apiName] !== 'function') {
|
||||
reject(new Error(`当前环境不支持 ${apiName}`))
|
||||
return
|
||||
}
|
||||
|
||||
uni[apiName]({
|
||||
...params,
|
||||
success: (res) => resolve(res),
|
||||
fail: (err) => reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
|
|
@ -51,17 +138,37 @@ export default {
|
|||
isSearching: false,
|
||||
deviceList: [],
|
||||
deviceMap: {},
|
||||
discoveryController: null
|
||||
discoveryController: null,
|
||||
|
||||
// WiFi 弹层状态。
|
||||
wifiPanelVisible: false,
|
||||
wifiPreparing: false,
|
||||
wifiSending: false,
|
||||
fetchingCurrentWifi: false,
|
||||
wifiPrepared: false,
|
||||
selectedWifiDevice: null,
|
||||
wifiForm: {
|
||||
ssid: '',
|
||||
password: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
searchBtnText() {
|
||||
return this.isSearching ? '搜索中' : '搜索'
|
||||
},
|
||||
|
||||
selectedDeviceName() {
|
||||
if (!this.selectedWifiDevice) {
|
||||
return '未选择设备'
|
||||
}
|
||||
|
||||
return this.selectedWifiDevice.showName || this.selectedWifiDevice.deviceId || '未命名设备'
|
||||
}
|
||||
},
|
||||
created() {
|
||||
/**
|
||||
* 蓝牙搜索控制器:统一处理设备发现、排序、清理逻辑。
|
||||
* 蓝牙搜索控制器:统一处理设备发现、连接、鉴权、WiFi 发送等流程。
|
||||
*/
|
||||
this.discoveryController = createBluetoothDiscovery({
|
||||
onSearchingChange: (value) => {
|
||||
|
|
@ -82,11 +189,38 @@ export default {
|
|||
this.cleanupDiscovery()
|
||||
},
|
||||
methods: {
|
||||
async safeDisconnect() {
|
||||
if (!this.discoveryController) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.discoveryController.disconnectDevice()
|
||||
} catch (error) {
|
||||
}
|
||||
},
|
||||
|
||||
cleanupWifiPanelState() {
|
||||
this.wifiPanelVisible = false
|
||||
this.wifiPrepared = false
|
||||
this.wifiPreparing = false
|
||||
this.fetchingCurrentWifi = false
|
||||
this.selectedWifiDevice = null
|
||||
this.resetWifiForm()
|
||||
},
|
||||
|
||||
resetWifiForm() {
|
||||
// 只清空当前弹层临时输入,不影响本地历史缓存。
|
||||
this.wifiForm.ssid = ''
|
||||
this.wifiForm.password = ''
|
||||
},
|
||||
|
||||
cleanupDiscovery() {
|
||||
if (!this.discoveryController) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cleanupWifiPanelState()
|
||||
this.discoveryController.cleanupDiscovery()
|
||||
},
|
||||
|
||||
|
|
@ -109,6 +243,194 @@ export default {
|
|||
await this.discoveryController.startSearch()
|
||||
} catch (error) {
|
||||
}
|
||||
},
|
||||
|
||||
async openWifiPanel(device) {
|
||||
if (this.wifiPreparing || this.wifiSending) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.discoveryController) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!device || !device.deviceId) {
|
||||
showToast('未选择可配置设备')
|
||||
return
|
||||
}
|
||||
|
||||
this.wifiPreparing = true
|
||||
this.wifiPrepared = false
|
||||
this.selectedWifiDevice = device
|
||||
|
||||
uni.showLoading({
|
||||
title: '密钥鉴权中',
|
||||
mask: true
|
||||
})
|
||||
|
||||
try {
|
||||
/**
|
||||
* 按需求先执行 MAC+0000 密钥鉴权,成功后再允许进入 WiFi 弹层。
|
||||
*/
|
||||
await this.discoveryController.prepareWifiConfig(device.deviceId, {
|
||||
suffix: '0000',
|
||||
timeout: 8000
|
||||
})
|
||||
|
||||
this.wifiPrepared = true
|
||||
this.wifiPanelVisible = true
|
||||
showToast('密钥鉴权成功,请填写WiFi信息')
|
||||
} catch (error) {
|
||||
showToast(getErrorMessage(error))
|
||||
await this.safeDisconnect()
|
||||
} finally {
|
||||
this.wifiPreparing = false
|
||||
uni.hideLoading()
|
||||
}
|
||||
},
|
||||
|
||||
async closeWifiPanel(force = false) {
|
||||
if (!force && (this.wifiSending || this.wifiPreparing)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cleanupWifiPanelState()
|
||||
await this.safeDisconnect()
|
||||
},
|
||||
|
||||
async fetchCurrentWifiSsid() {
|
||||
if (this.fetchingCurrentWifi) {
|
||||
return
|
||||
}
|
||||
|
||||
this.fetchingCurrentWifi = true
|
||||
|
||||
try {
|
||||
// 启动 WiFi 模块(部分端调用 getConnectedWifi 前必须先 startWifi)。
|
||||
if (typeof uni.startWifi === 'function') {
|
||||
try {
|
||||
await promisifyUniApi('startWifi')
|
||||
} catch (error) {
|
||||
// 已启动等非致命错误忽略,继续尝试读取当前连接。
|
||||
}
|
||||
}
|
||||
|
||||
const result = await promisifyUniApi('getConnectedWifi')
|
||||
const ssid = String(result?.wifi?.SSID || result?.wifi?.ssid || result?.SSID || '').trim()
|
||||
|
||||
if (!ssid || ssid.toLowerCase().includes('unknown')) {
|
||||
throw new Error('未获取到当前连接 WiFi 名称')
|
||||
}
|
||||
|
||||
this.wifiForm.ssid = ssid
|
||||
showToast('已填入当前连接WiFi')
|
||||
} catch (error) {
|
||||
showToast(getErrorMessage(error))
|
||||
} finally {
|
||||
this.fetchingCurrentWifi = false
|
||||
}
|
||||
},
|
||||
|
||||
importLastWifiConfig() {
|
||||
try {
|
||||
const cached = uni.getStorageSync(WIFI_HISTORY_STORAGE_KEY)
|
||||
|
||||
if (!cached || typeof cached !== 'object') {
|
||||
showToast('暂无上次 WiFi 配置')
|
||||
return
|
||||
}
|
||||
|
||||
const ssid = String(cached.ssid || '').trim()
|
||||
const password = String(cached.password || '').trim()
|
||||
|
||||
if (!ssid || !password) {
|
||||
showToast('暂无可用的历史 WiFi 配置')
|
||||
return
|
||||
}
|
||||
|
||||
this.wifiForm.ssid = ssid
|
||||
this.wifiForm.password = password
|
||||
showToast('已导入上次 WiFi 配置')
|
||||
} catch (error) {
|
||||
showToast('读取历史 WiFi 配置失败')
|
||||
}
|
||||
},
|
||||
|
||||
cacheWifiConfig(ssid, password) {
|
||||
try {
|
||||
uni.setStorageSync(WIFI_HISTORY_STORAGE_KEY, {
|
||||
ssid,
|
||||
password,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
} catch (error) {
|
||||
}
|
||||
},
|
||||
|
||||
getWifiStateMessage(stateCode) {
|
||||
return WIFI_STATE_MESSAGES[stateCode] || `WiFi 状态码异常: ${stateCode}`
|
||||
},
|
||||
|
||||
async confirmWifiSettings() {
|
||||
if (this.wifiSending) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.wifiPrepared) {
|
||||
showToast('请先完成密钥鉴权')
|
||||
return
|
||||
}
|
||||
|
||||
const ssid = String(this.wifiForm.ssid || '').trim()
|
||||
const password = String(this.wifiForm.password || '').trim()
|
||||
|
||||
if (!ssid) {
|
||||
showToast('请输入 WiFi 名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
showToast('请输入 WiFi 密码')
|
||||
return
|
||||
}
|
||||
|
||||
this.wifiSending = true
|
||||
|
||||
let needAutoClose = false
|
||||
let needCacheWifi = false
|
||||
let tipMessage = ''
|
||||
|
||||
try {
|
||||
/**
|
||||
* 发送后必须等待设备通过 UUID_STATE 返回最终结果(1/2/3)
|
||||
* 才进行提示并自动关闭弹层。
|
||||
*/
|
||||
const wifiResult = await this.discoveryController.configureWifi(ssid, password, {
|
||||
timeout: 12000
|
||||
})
|
||||
|
||||
const stateMessage = this.getWifiStateMessage(wifiResult.stateCode)
|
||||
tipMessage = stateMessage
|
||||
needAutoClose = true
|
||||
needCacheWifi = Boolean(wifiResult.success)
|
||||
} catch (error) {
|
||||
// 超时/异常场景表示尚未拿到有效设备结果,保持弹层打开。
|
||||
tipMessage = getErrorMessage(error)
|
||||
} finally {
|
||||
this.wifiSending = false
|
||||
|
||||
if (tipMessage) {
|
||||
showToast(tipMessage)
|
||||
}
|
||||
|
||||
if (needCacheWifi) {
|
||||
this.cacheWifiConfig(ssid, password)
|
||||
}
|
||||
|
||||
if (needAutoClose) {
|
||||
await this.closeWifiPanel(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -301,4 +623,159 @@ export default {
|
|||
border-radius: 6rpx;
|
||||
background: #f8fffc;
|
||||
}
|
||||
|
||||
.action-btn.disabled {
|
||||
border-color: #9fdcc3;
|
||||
color: #7db89e;
|
||||
background: #f5faf7;
|
||||
}
|
||||
|
||||
.wifi-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.wifi-dialog {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 28rpx 28rpx 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wifi-dialog-header {
|
||||
height: 108rpx;
|
||||
border-bottom: 1rpx solid #efefef;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wifi-dialog-title {
|
||||
font-size: 42rpx;
|
||||
color: #2d2f33;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wifi-dialog-close {
|
||||
position: absolute;
|
||||
right: 26rpx;
|
||||
top: 18rpx;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
text-align: center;
|
||||
line-height: 72rpx;
|
||||
font-size: 56rpx;
|
||||
color: #b8bcc3;
|
||||
}
|
||||
|
||||
.wifi-device-row {
|
||||
min-height: 74rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
padding: 0 30rpx;
|
||||
border-bottom: 1rpx solid #f4f4f4;
|
||||
}
|
||||
|
||||
.wifi-device-label {
|
||||
font-size: 28rpx;
|
||||
color: #8a8f98;
|
||||
}
|
||||
|
||||
.wifi-device-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 27rpx;
|
||||
color: #4a4f56;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wifi-row {
|
||||
min-height: 102rpx;
|
||||
border-bottom: 1rpx solid #f2f2f2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 30rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.wifi-row-ssid {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wifi-label {
|
||||
width: 170rpx;
|
||||
font-size: 34rpx;
|
||||
color: #363b45;
|
||||
}
|
||||
|
||||
.wifi-required {
|
||||
color: #ff4f4f;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.wifi-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 80rpx;
|
||||
font-size: 32rpx;
|
||||
color: #1f2329;
|
||||
}
|
||||
|
||||
.wifi-input-ssid {
|
||||
max-width: 260rpx;
|
||||
}
|
||||
|
||||
.wifi-current-btn {
|
||||
flex-shrink: 0;
|
||||
min-width: 190rpx;
|
||||
height: 62rpx;
|
||||
line-height: 62rpx;
|
||||
border: 1rpx solid #74dcb2;
|
||||
border-radius: 8rpx;
|
||||
color: #24b478;
|
||||
background: #effcf5;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
padding: 0 12rpx;
|
||||
}
|
||||
|
||||
.wifi-current-btn.disabled {
|
||||
border-color: #b8d8ca;
|
||||
color: #9bbbae;
|
||||
background: #f3f6f4;
|
||||
}
|
||||
|
||||
.wifi-import-row {
|
||||
min-height: 100rpx;
|
||||
border-bottom: 1rpx solid #f2f2f2;
|
||||
font-size: 34rpx;
|
||||
color: #3bc58d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wifi-submit-btn {
|
||||
height: 100rpx;
|
||||
background: #11c462;
|
||||
color: #ffffff;
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wifi-submit-btn.disabled {
|
||||
background: #8edcae;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -32,6 +32,34 @@ export function buildA7Packets(payload, packetConfig = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 payload 按 A7 规则分包后顺序写入目标特征。
|
||||
* 统一 auth/wifi 分包发送逻辑,避免多处重复实现。
|
||||
*/
|
||||
export async function writeA7Payload(ble, characteristicId, payload, packetConfig = {}, options = {}) {
|
||||
if (!ble || typeof ble.writeCharacteristic !== 'function') {
|
||||
throw new Error('writeA7Payload 缺少有效的 ble 实例')
|
||||
}
|
||||
|
||||
if (!characteristicId) {
|
||||
throw new Error('writeA7Payload 缺少 characteristicId')
|
||||
}
|
||||
|
||||
const { packetInterval = 0 } = options
|
||||
const packets = buildA7Packets(payload, packetConfig)
|
||||
|
||||
for (const packet of packets) {
|
||||
await ble.writeCharacteristic(characteristicId, packet)
|
||||
|
||||
if (packetInterval > 0) {
|
||||
// 部分固件在连续写入时需要极短间隔,默认 0 兼容原行为。
|
||||
await new Promise((resolve) => setTimeout(resolve, packetInterval))
|
||||
}
|
||||
}
|
||||
|
||||
return packets.length
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个 A7 分包数据体(去掉前两字节头信息后拼接)。
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* 统一等待 UUID_STATE 通知。
|
||||
*
|
||||
* @param {object} ble createBleCore 实例
|
||||
* @param {object} options 等待配置
|
||||
* @param {number[]} options.allowedCodes 允许通过的状态码;为空则接收任意状态码
|
||||
* @param {boolean} options.rejectOnUnexpected 收到非 allowedCodes 状态码时是否立刻失败
|
||||
* @param {number} options.timeout 超时时间(ms)
|
||||
* @param {string} options.timeoutMessage 超时提示
|
||||
* @param {string} options.unexpectedMessage 非预期状态提示前缀
|
||||
*/
|
||||
export function waitForProtocolState(ble, options = {}) {
|
||||
const {
|
||||
allowedCodes = [],
|
||||
rejectOnUnexpected = false,
|
||||
timeout = 6000,
|
||||
timeoutMessage = '等待 UUID_STATE 超时',
|
||||
unexpectedMessage = '收到非预期 UUID_STATE 状态码'
|
||||
} = options
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer = null
|
||||
let off = null
|
||||
|
||||
const finish = (callback) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (off) {
|
||||
off()
|
||||
}
|
||||
|
||||
callback()
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
finish(() => reject(new Error(timeoutMessage)))
|
||||
}, timeout)
|
||||
|
||||
off = ble.on('protocol:state', (payload) => {
|
||||
const stateCode = Number(payload?.stateCode)
|
||||
|
||||
if (!allowedCodes.length || allowedCodes.includes(stateCode)) {
|
||||
finish(() => resolve(payload))
|
||||
return
|
||||
}
|
||||
|
||||
if (rejectOnUnexpected) {
|
||||
finish(() => reject(new Error(`${unexpectedMessage}: ${stateCode}`)))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -12,7 +12,8 @@ export { createRadarModule } from './modules/radar'
|
|||
export { BLE_STAGE } from './core/state'
|
||||
export { DEVICE_PROFILE, getBleProfile, guessProfileByRadarFrame } from './protocols/profiles'
|
||||
|
||||
export { buildA7Packets } from './core/packet'
|
||||
export { buildA7Packets, writeA7Payload } from './core/packet'
|
||||
export { waitForProtocolState } from './core/stateWait'
|
||||
|
||||
export { bytesToHex, hexToBytes, utf8ToBytes, bytesToUtf8, bytesToMac } from './utils/hex'
|
||||
export { splitToLowHigh, joinLowHigh, readUint16LE, readUint32LE, toSignedInt8 } from './utils/bytes'
|
||||
|
|
|
|||
|
|
@ -1,34 +1,8 @@
|
|||
import { buildA7Packets } from '../core/packet'
|
||||
import { writeA7Payload } from '../core/packet'
|
||||
import { waitForProtocolState } from '../core/stateWait'
|
||||
import { parseDeviceInfo } from '../parsers'
|
||||
import { BLE_STAGE } from '../core/state'
|
||||
|
||||
function waitForStateCode(ble, timeout = 6000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer = null
|
||||
let off = null
|
||||
|
||||
const finish = (callback) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (off) {
|
||||
off()
|
||||
}
|
||||
|
||||
callback()
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
finish(() => reject(new Error('等待 UUID_STATE 超时')))
|
||||
}, timeout)
|
||||
|
||||
off = ble.on('protocol:state', (payload) => {
|
||||
finish(() => resolve(payload))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function generateRandomKey(length = 16) {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'
|
||||
let result = ''
|
||||
|
|
@ -41,6 +15,34 @@ function generateRandomKey(length = 16) {
|
|||
return result
|
||||
}
|
||||
|
||||
function normalizeMacForKey(macText = '') {
|
||||
return String(macText || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按协议约定生成设备密钥:MAC(去分隔符) + 固定后缀。
|
||||
* 默认后缀为 "0000",对应 12 位 MAC + 4 位后缀 = 16 位密钥。
|
||||
*/
|
||||
export function buildKeyFromMac(macText, suffix = '0000', expectedLength = 16) {
|
||||
const normalizedMac = normalizeMacForKey(macText)
|
||||
const normalizedSuffix = String(suffix || '').trim()
|
||||
|
||||
if (!normalizedSuffix) {
|
||||
throw new Error('密钥后缀不能为空')
|
||||
}
|
||||
|
||||
const requiredMacLength = expectedLength - normalizedSuffix.length
|
||||
if (requiredMacLength <= 0) {
|
||||
throw new Error('密钥长度配置异常')
|
||||
}
|
||||
|
||||
if (normalizedMac.length !== requiredMacLength) {
|
||||
throw new Error(`设备 MAC 长度异常,期望 ${requiredMacLength} 位,实际 ${normalizedMac.length} 位`)
|
||||
}
|
||||
|
||||
return `${normalizedMac}${normalizedSuffix}`
|
||||
}
|
||||
|
||||
async function writePacketsToKeyCharacteristic(ble, keyText) {
|
||||
const { key } = ble.getState().characteristics
|
||||
|
||||
|
|
@ -55,22 +57,23 @@ async function writePacketsToKeyCharacteristic(ble, keyText) {
|
|||
throw new Error(`密钥长度必须为 ${profile.auth.keyLength}`)
|
||||
}
|
||||
|
||||
const packets = buildA7Packets(keyValue, profile.packet)
|
||||
for (const packet of packets) {
|
||||
await ble.writeCharacteristic(key.uuid, packet)
|
||||
}
|
||||
await writeA7Payload(ble, key.uuid, keyValue, profile.packet)
|
||||
|
||||
return keyValue
|
||||
}
|
||||
|
||||
async function writeKeyAndExpectStates(ble, keyText, expectedStateCodes, options = {}) {
|
||||
const keyValue = await writePacketsToKeyCharacteristic(ble, keyText)
|
||||
const stateEvent = await waitForStateCode(ble, options.timeout || 6000)
|
||||
const stateCode = stateEvent.stateCode
|
||||
|
||||
if (!expectedStateCodes.includes(stateCode)) {
|
||||
throw new Error(`密钥流程状态异常,期望=${expectedStateCodes.join('/')} 实际=${stateCode}`)
|
||||
}
|
||||
const stateEvent = await waitForProtocolState(ble, {
|
||||
allowedCodes: expectedStateCodes,
|
||||
rejectOnUnexpected: true,
|
||||
timeout: options.timeout || 6000,
|
||||
timeoutMessage: '等待 UUID_STATE 超时',
|
||||
unexpectedMessage: '密钥流程状态异常'
|
||||
})
|
||||
|
||||
const stateCode = Number(stateEvent.stateCode)
|
||||
|
||||
ble.setStage(BLE_STAGE.AUTHORIZED)
|
||||
ble.setAuthState({
|
||||
|
|
@ -85,6 +88,58 @@ async function writeKeyAndExpectStates(ble, keyText, expectedStateCodes, options
|
|||
}
|
||||
}
|
||||
|
||||
async function ensureAuthorizedWithDeviceInfo(ble, info, keyText, options = {}) {
|
||||
const bindStatus = Number(info.bindStatus)
|
||||
const profile = ble.getProfile()
|
||||
|
||||
ble.setAuthState({
|
||||
bound: Number.isNaN(bindStatus) ? null : bindStatus,
|
||||
keyMatched: false
|
||||
})
|
||||
|
||||
if (bindStatus === 0) {
|
||||
const keyToBind = String(keyText || '').trim() || generateRandomKey(profile.auth.keyLength)
|
||||
const authResult = await writeKeyAndExpectStates(ble, keyToBind, [5], options)
|
||||
|
||||
return {
|
||||
mode: 'BIND',
|
||||
deviceInfo: info,
|
||||
authResult,
|
||||
key: keyToBind
|
||||
}
|
||||
}
|
||||
|
||||
if (bindStatus === 1) {
|
||||
const keyToMatch = String(keyText || '').trim()
|
||||
if (!keyToMatch) {
|
||||
throw new Error('设备已绑定,必须提供已绑定密钥用于匹配')
|
||||
}
|
||||
|
||||
const authResult = await writeKeyAndExpectStates(ble, keyToMatch, [7], options)
|
||||
|
||||
return {
|
||||
mode: 'MATCH',
|
||||
deviceInfo: info,
|
||||
authResult,
|
||||
key: keyToMatch
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackKey = String(keyText || '').trim()
|
||||
if (!fallbackKey) {
|
||||
throw new Error(`无法识别的绑定状态: ${info.bindStatus},且未提供密钥`)
|
||||
}
|
||||
|
||||
const fallbackResult = await writeKeyAndExpectStates(ble, fallbackKey, [7, 5], options)
|
||||
|
||||
return {
|
||||
mode: 'UNKNOWN_BIND_STATE_FALLBACK',
|
||||
deviceInfo: info,
|
||||
authResult: fallbackResult,
|
||||
key: fallbackKey
|
||||
}
|
||||
}
|
||||
|
||||
export function createAuthModule(ble) {
|
||||
async function readDeviceInfo() {
|
||||
const { msg } = ble.getState().characteristics
|
||||
|
|
@ -108,73 +163,41 @@ export function createAuthModule(ble) {
|
|||
|
||||
/**
|
||||
* 严格遵循协议1.2绑定流程:
|
||||
* 1) 先读取 UUID_MSG 获取 bindStatus;
|
||||
* 2) bindStatus=0 -> 写随机密钥并等待 UUID_STATE=5(绑定成功);
|
||||
* 3) bindStatus=1 -> 写已知密钥并等待 UUID_STATE=7(密钥匹配成功)。
|
||||
*
|
||||
* 兼容说明:当某些固件 MSG 未明确返回 0/1 时,回退为“已知密钥匹配优先”,
|
||||
* 允许状态 7(匹配成功) 或 5(设备首次绑定成功)。
|
||||
* 1) 读取 UUID_MSG 的 bindStatus;
|
||||
* 2) bindStatus=0 写随机/指定密钥并等待 STATE=5;
|
||||
* 3) bindStatus=1 写已知密钥并等待 STATE=7。
|
||||
*/
|
||||
async function ensureAuthorized(keyText, options = {}) {
|
||||
ble.setStage(BLE_STAGE.AUTHORIZING)
|
||||
const info = await readDeviceInfo()
|
||||
return ensureAuthorizedWithDeviceInfo(ble, info, keyText, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定规则鉴权:密钥 = 设备MAC(去分隔符) + "0000"。
|
||||
* 用于“先鉴权再进行 WiFi 配置”的页面流程。
|
||||
*/
|
||||
async function ensureAuthorizedByMac(options = {}) {
|
||||
ble.setStage(BLE_STAGE.AUTHORIZING)
|
||||
|
||||
const info = await readDeviceInfo()
|
||||
const bindStatus = Number(info.bindStatus)
|
||||
const profile = ble.getProfile()
|
||||
const keyByMac = buildKeyFromMac(info.mac, options.suffix || '0000', profile.auth.keyLength)
|
||||
|
||||
ble.setAuthState({
|
||||
bound: Number.isNaN(bindStatus) ? null : bindStatus,
|
||||
keyMatched: false
|
||||
})
|
||||
|
||||
// 未绑定:按协议执行绑定。
|
||||
if (bindStatus === 0) {
|
||||
const keyToBind = String(keyText || '').trim() || generateRandomKey(ble.getProfile().auth.keyLength)
|
||||
const authResult = await writeKeyAndExpectStates(ble, keyToBind, [5], options)
|
||||
|
||||
return {
|
||||
mode: 'BIND',
|
||||
deviceInfo: info,
|
||||
authResult,
|
||||
key: keyToBind
|
||||
}
|
||||
}
|
||||
|
||||
// 已绑定:按协议执行密钥匹配。
|
||||
if (bindStatus === 1) {
|
||||
const keyToMatch = String(keyText || '').trim()
|
||||
if (!keyToMatch) {
|
||||
throw new Error('设备已绑定,必须提供已绑定密钥用于匹配')
|
||||
}
|
||||
|
||||
const authResult = await writeKeyAndExpectStates(ble, keyToMatch, [7], options)
|
||||
|
||||
return {
|
||||
mode: 'MATCH',
|
||||
deviceInfo: info,
|
||||
authResult,
|
||||
key: keyToMatch
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定状态不明确时,执行兼容分支(优先匹配,允许绑定成功)。
|
||||
const fallbackKey = String(keyText || '').trim()
|
||||
if (!fallbackKey) {
|
||||
throw new Error(`无法识别的绑定状态: ${info.bindStatus},且未提供密钥`)
|
||||
}
|
||||
|
||||
const fallbackResult = await writeKeyAndExpectStates(ble, fallbackKey, [7, 5], options)
|
||||
const authResult = await ensureAuthorizedWithDeviceInfo(ble, info, keyByMac, options)
|
||||
|
||||
return {
|
||||
mode: 'UNKNOWN_BIND_STATE_FALLBACK',
|
||||
deviceInfo: info,
|
||||
authResult: fallbackResult,
|
||||
key: fallbackKey
|
||||
...authResult,
|
||||
key: keyByMac,
|
||||
keyRule: 'MAC+0000'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
readDeviceInfo,
|
||||
ensureAuthorized,
|
||||
ensureAuthorizedByMac,
|
||||
buildKeyFromMac,
|
||||
generateRandomKey
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { buildA7Packets } from '../core/packet'
|
||||
import { writeA7Payload } from '../core/packet'
|
||||
import { waitForProtocolState } from '../core/stateWait'
|
||||
|
||||
const WIFI_FINAL_STATE_CODES = Object.freeze([1, 2, 3])
|
||||
|
||||
/**
|
||||
* 校验 WiFi 入参,规则来源于 profile.wifi。
|
||||
|
|
@ -24,50 +27,25 @@ function validateWifi(profile, ssid, password) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听协议状态通知,等待 WiFi 配网状态码返回。
|
||||
*/
|
||||
function waitForWifiState(ble, timeout = 8000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let timer = null
|
||||
let off = null
|
||||
function requireAuthorized(ble) {
|
||||
const state = ble.getState()
|
||||
|
||||
const clearAll = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (off) {
|
||||
off()
|
||||
}
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
clearAll()
|
||||
reject(new Error('等待 WiFi 状态超时'))
|
||||
}, timeout)
|
||||
|
||||
off = ble.on('protocol:state', (payload) => {
|
||||
if (![1, 2, 3, 4].includes(payload.stateCode)) {
|
||||
return
|
||||
}
|
||||
|
||||
clearAll()
|
||||
resolve(payload.stateCode)
|
||||
})
|
||||
})
|
||||
if (!state.auth || state.auth.keyMatched !== true) {
|
||||
throw new Error('请先完成密钥鉴权,再发送 WiFi 配置')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WiFi 配网模块:负责打包并发送 ssid|password,随后等待状态通知。
|
||||
* WiFi 配网模块:负责打包发送 ssid|password,并等待 STATE 回执。
|
||||
*/
|
||||
export function createWifiModule(ble) {
|
||||
async function configureWifi(ssid, password, options = {}) {
|
||||
const profile = ble.getProfile()
|
||||
const targetSsid = String(ssid || '')
|
||||
const targetPass = String(password || '')
|
||||
const targetSsid = String(ssid || '').trim()
|
||||
const targetPass = String(password || '').trim()
|
||||
|
||||
validateWifi(profile, targetSsid, targetPass)
|
||||
requireAuthorized(ble)
|
||||
|
||||
const { wifi } = ble.getState().characteristics
|
||||
if (!wifi || !wifi.uuid) {
|
||||
|
|
@ -75,14 +53,21 @@ export function createWifiModule(ble) {
|
|||
}
|
||||
|
||||
const payload = `${targetSsid}|${targetPass}`
|
||||
const packets = buildA7Packets(payload, profile.packet)
|
||||
|
||||
// 按分包顺序逐包发送,保持设备端解析一致性。
|
||||
for (const packet of packets) {
|
||||
await ble.writeCharacteristic(wifi.uuid, packet)
|
||||
}
|
||||
// 协议要求 WiFi 参数按 0xA7 分包发送到 UUID_WIFI(F401)。
|
||||
await writeA7Payload(ble, wifi.uuid, payload, profile.packet, {
|
||||
packetInterval: Number(options.packetInterval) || 0
|
||||
})
|
||||
|
||||
const stateCode = await waitForWifiState(ble, options.timeout || 8000)
|
||||
const stateEvent = await waitForProtocolState(ble, {
|
||||
// 仅等待最终态,避免在“连接中(4)”时过早返回导致页面提前结束流程。
|
||||
allowedCodes: WIFI_FINAL_STATE_CODES,
|
||||
rejectOnUnexpected: false,
|
||||
timeout: options.timeout || 12000,
|
||||
timeoutMessage: '等待 WiFi 状态超时'
|
||||
})
|
||||
|
||||
const stateCode = Number(stateEvent.stateCode)
|
||||
|
||||
return {
|
||||
success: stateCode === 3,
|
||||
|
|
@ -91,6 +76,7 @@ export function createWifiModule(ble) {
|
|||
}
|
||||
|
||||
return {
|
||||
configureWifi
|
||||
configureWifi,
|
||||
requireAuthorized
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue