蓝牙通讯初步调通
This commit is contained in:
154
Document/W13无卡取电设备 - 蓝牙通讯协议.md
Normal file
154
Document/W13无卡取电设备 - 蓝牙通讯协议.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# W13无卡取电设备 - 蓝牙通讯协议
|
||||
|
||||
> 版本同步记录:
|
||||
> - 同步来源:Document/W13无卡取电设备 - 蓝牙通讯协议(1).pdf
|
||||
> - 同步日期:2026-01-12
|
||||
> - 说明:依据PDF抽取与差异比对,更新命令与参数细节,使Markdown与PDF一致。
|
||||
|
||||
## 1. 通讯方式
|
||||
使用蓝牙BLE通讯
|
||||
|
||||
## 2. 帧结构定义
|
||||
|
||||
### 2.1 帧字段说明
|
||||
| 字节范围 | 功能 | 长度(Bytes) | 取值范围(&H) | 备注 |
|
||||
|---------|------|-------------|---------|------|
|
||||
| B0~B1 | Head | 2 | 0xCC 0xC0 | 固定包头 |
|
||||
| B2~B3 | Len | 2 | 00~548 | 数据的总长度,包括包头和CRC校验,低地址在前 |
|
||||
| B4~B5 | CRC | 2 | 00~FF | 整包CRC16校验 |
|
||||
| B6~B7 | Frame | 2 | 00~FF | 帧号 |
|
||||
| B8~B9 | FramNum | 2 | 00~FF | 帧总数 |
|
||||
| B10 | Frame_Type | 1 | 00~FF | 帧类型,命令字 |
|
||||
| B11~B1023 | PARA_0~1012 | Max 1013 | 00~FF | 参数,不同类型有不同的参数字(不定长) |
|
||||
|
||||
### 2.2 帧结构示意图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PC as 上位机
|
||||
participant Device as 无卡取电设备
|
||||
|
||||
PC->>Device: 发送命令帧
|
||||
Note over PC,Device: 帧结构:Head + Len + CRC + Frame + FramNum + Frame_Type + Parameters
|
||||
Device->>PC: 返回响应帧
|
||||
Note over Device,PC: 帧结构:Head + Len + CRC + Frame + FramNum + Frame_Type + Response
|
||||
```
|
||||
|
||||
## 3. 详细命令列表
|
||||
|
||||
命令总览表(快速索引):
|
||||
|
||||
| 序号 | 功能 | 方向 | 命令字 | 备注 |
|
||||
|------|------|------|--------|------|
|
||||
| 1 | 读版本号 | PC→MCU / MCU→PC | 0x01 | P0=0x00,请求;返回软件/硬件版本 |
|
||||
| 2 | 设置无卡取电条件信息 | PC→MCU / MCU→PC | 0x08 | 条件参数设置;返回P0=0x01/0x02 |
|
||||
| 3 | 设置无卡取电端口信息 | PC→MCU / MCU→PC | 0x09 | 端口配置;返回P0=0x01/0x02 |
|
||||
| 4 | OTA升级开始 | PC→MCU | 0x0B | P0=0x01,进入bootloader等待OTA |
|
||||
| 5 | 开启蓝牙打印 | PC→MCU / MCU→PC | 0x0C | 打印开关bit0..bit4;返回P0=0x01/0x02 |
|
||||
| 6 | 雷达状态获得 | PC→MCU / MCU→PC | 0x11 | 开/关读取;返回端口状态位与有人/无人 |
|
||||
| 7 | 测试按键功能 | PC→MCU / MCU→PC | 0x13 | 点按控制与状态返回 |
|
||||
| 8 | 事件设置(门磁/卫浴灯) | PC→MCU / MCU→PC | 0x16 | 控制位bit0/bit1,事件时序参数与单位 |
|
||||
|
||||
### 3.1 读版本号
|
||||
| 方向 | 命令字 | 参数 | 备注 |
|
||||
|------|-------|------|------|
|
||||
| PC→MCU | 0x01 | P0: 0x00 | 读取版本号命令 |
|
||||
| MCU→PC | 0x01 | P0: 软件版本号<br>P1: 硬件版本号 | 返回版本信息 |
|
||||
|
||||
### 3.2 设置无卡取电条件信息(命令1)
|
||||
| 方向 | 命令字 | 参数 | 备注 |
|
||||
|------|-------|------|------|
|
||||
| PC→MCU | 0x08 | P0: 有无逻辑标记<br>P1: 条件组<br>P2: 条件序号<br>P3~P4: 条件判定时间<br>P5: 条件判定时间单位<br>P6~P9: 端口1~10状态<br>P10: 触发阈值<br>P11~P12: 条件超时时间<br>P13: 条件超时时间单位 | 设置无卡取电条件 |
|
||||
| MCU→PC | 0x08 | P0: 0x01(参数正确) / 0x02(参数错误) | 返回设置结果 |
|
||||
|
||||
### 3.3 设置无卡取电条件信息(命令2)
|
||||
| 方向 | 命令字 | 参数 | 备注 |
|
||||
|------|-------|------|------|
|
||||
| PC→MCU | 0x09 | P0: 端口设备类型<br>P1: 端口设备地址<br>P2~P3: 端口设备回路<br>P4: 有人->无人阈值<br>P5: 虚拟端口号<br>P6: 回路是否启用检测统计<br>P7~P8: 回路检测统计时间<br>P9: 回路检测统计时间单位<br>P10: 无人->有人阈值 | 设置无卡取电条件 |
|
||||
| MCU→PC | 0x09 | P0: 0x01(参数正确) / 0x02(参数错误) | 返回设置结果 |
|
||||
|
||||
### 3.4 OTA升级开始
|
||||
| 方向 | 命令字 | 参数 | 备注 |
|
||||
|------|-------|------|------|
|
||||
| PC→MCU | 0x0B | P0: 0x01(开始升级) | 启动OTA升级,设备进入 bootloader,等待 OTA 升级 APP 连接 |
|
||||
|
||||
### 3.5 开启蓝牙打印
|
||||
| 方向 | 命令字 | 参数 | 备注 |
|
||||
|------|-------|------|------|
|
||||
| PC→MCU | 0x0C | P0: bit0(系统调试信息打印开关)<br>bit1(设备驱动层打印调试信息打印开关)<br>bit2(蓝牙信息打印开关)<br>bit3(PC通讯打印开关)<br>bit4(临时调试信息打印开关) | 设置打印开关 |
|
||||
| MCU→PC | 0x0C | P0: 0x01(参数正确) / 0x02(参数错误) | 返回设置结果 |
|
||||
|
||||
### 3.6 雷达状态获得
|
||||
| 方向 | 命令字 | 参数 | 备注 |
|
||||
|------|-------|------|------|
|
||||
| PC→MCU | 0x11 | P0: 0x01(开启读取端口状态) / 0x02(关闭读取) | 请求雷达状态;端口状态值:0=释放,1=触发 |
|
||||
| MCU→PC | 0x11 | P0: 有效端口数量<br>P1: 有无人状态(0x01=有人,0x02=无人)<br>P2: bit0(端口1状态), bit1(端口2状态), bit2(端口3状态), bit3(端口4状态)...(0=释放,1=触发) | 返回雷达状态 |
|
||||
|
||||
补充说明:
|
||||
- 端口状态值:0=释放,1=触发。
|
||||
- 典型端口位含义:bit0=端口1(门磁),bit1=端口2(洗手间),bit2=端口3(卧室),bit3=端口4(门口),后续端口依次类推。
|
||||
|
||||
### 3.7 测试按键功能
|
||||
| 方向 | 命令字 | 参数 | 备注 |
|
||||
|------|-------|------|------|
|
||||
| PC→MCU | 0x13 | P0: 0x01(按键点按控制)<br>P1: bit0(按键1触发), bit1(按键2触发), bit2(按键3触发), bit3(按键4触发), bit4(按键5触发), bit5(按键6触发) | 测试按键功能(对应按键仅支持点按,不具备开关状态) |
|
||||
| MCU→PC | 0x13 | P0: 0x01(参数正确) / 0x02(参数错误) | 返回设置结果 |
|
||||
|
||||
### 3.8 设置门磁开关走廊灯、卫浴雷达开关卫浴灯事件
|
||||
| 方向 | 命令字 | 参数 | 备注 |
|
||||
|------|-------|------|------|
|
||||
| PC→MCU | 0x16 | P0: 控制位(bit0=门磁开关走廊灯事件;bit1=卫浴灯开关事件)<br>门磁开关走廊灯事件:<br>P1: 事件触发延迟时间数值<br>P2: 时间单位(1=秒,2=分,3=时)<br>P3: 事件持续时间数值<br>P4: 时间单位(1=秒,2=分,3=时)<br>P5: 事件释放延迟时间数值<br>P6: 时间单位(1=秒,2=分,3=时)<br>卫浴灯开关事件:<br>P7: 事件触发延迟时间数值<br>P8: 时间单位(1=秒,2=分,3=时)<br>P9: 事件持续时间数值<br>P10: 时间单位(1=秒,2=分,3=时)<br>P11: 事件释放延迟时间数值<br>P12: 时间单位(1=秒,2=分,3=时) | 设置事件参数(用于控制门磁亮走廊灯、卫浴雷达亮卫浴灯等) |
|
||||
| MCU→PC | 0x16 | P0: 0x01(参数正确) / 0x02(参数错误) | 返回设置结果 |
|
||||
|
||||
## 4. 命令交互流程图
|
||||
|
||||
### 4.1 读版本号流程
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[上位机发送读版本号命令<br>Frame_Type=0x01, P0=0x00] --> B[设备接收命令]
|
||||
B --> C{命令解析正确?}
|
||||
C -->|是| D[设备准备版本信息]
|
||||
C -->|否| E[设备返回错误响应]
|
||||
D --> F[设备发送响应帧<br>Frame_Type=0x01, 包含软硬件版本号]
|
||||
E --> G[设备发送错误响应帧<br>Frame_Type=0x01, P0=0x02]
|
||||
F --> H[上位机接收版本信息]
|
||||
G --> I[上位机处理错误]
|
||||
```
|
||||
|
||||
### 4.2 设置无卡取电条件流程
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[上位机发送设置条件命令<br>Frame_Type=0x08/0x09] --> B[设备接收命令]
|
||||
B --> C{参数验证通过?}
|
||||
C -->|是| D[设备保存条件设置]
|
||||
C -->|否| E[设备标记参数错误]
|
||||
D --> F[设备发送成功响应<br>P0=0x01]
|
||||
E --> G[设备发送失败响应<br>P0=0x02]
|
||||
F --> H[上位机确认设置成功]
|
||||
G --> I[上位机重新发送或处理错误]
|
||||
```
|
||||
|
||||
## 5. 数据传输流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant PC as 上位机
|
||||
participant BLE as 蓝牙模块
|
||||
participant Device as 设备主控
|
||||
|
||||
PC->>BLE: 发送BLE数据
|
||||
BLE->>Device: 转发数据帧
|
||||
Device->>BLE: 处理并返回响应
|
||||
BLE->>PC: 转发响应帧
|
||||
```
|
||||
|
||||
## 6. 异常处理
|
||||
|
||||
| 错误类型 | 错误码 | 处理方式 |
|
||||
|---------|-------|---------|
|
||||
| 参数错误 | 0x02 | 重新发送正确参数 |
|
||||
| 命令不支持 | - | 检查命令字是否正确 |
|
||||
| 通讯超时 | - | 重新发送命令 |
|
||||
|
||||
---
|
||||
|
||||
4
app.json
4
app.json
@@ -14,7 +14,9 @@
|
||||
"pages/test/test",
|
||||
"pages/basics/FacialDeviceBinding/FacialDeviceBinding",
|
||||
"pages/basics/progress/progress",
|
||||
"pages/basics/progress/RoomTypeControlLog/RoomTypeControlLog"
|
||||
"pages/basics/progress/RoomTypeControlLog/RoomTypeControlLog",
|
||||
"pages/basics/BluetoothDebugging/BluetoothDebugging",
|
||||
"pages/basics/BluetoothDebugging/B13page/B13page"
|
||||
],
|
||||
"window": {
|
||||
"backgroundTextStyle": "light",
|
||||
|
||||
BIN
img/lanya.png
Normal file
BIN
img/lanya.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
img/xinhaodi.png
Normal file
BIN
img/xinhaodi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 959 B |
BIN
img/xinhaogao.png
Normal file
BIN
img/xinhaogao.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
img/xinhaozhong.png
Normal file
BIN
img/xinhaozhong.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -32,6 +32,12 @@ Page({
|
||||
color: 'pink',
|
||||
icon: 'btn'
|
||||
},
|
||||
{
|
||||
title: '蓝牙调试',
|
||||
name: 'BluetoothDebugging',
|
||||
color: 'cyan',
|
||||
icon: 'tagfill'
|
||||
},
|
||||
{
|
||||
title: '红外转发码库下载',
|
||||
name: 'InfraredLibraryDownload',
|
||||
|
||||
945
pages/basics/BluetoothDebugging/B13page/B13page.js
Normal file
945
pages/basics/BluetoothDebugging/B13page/B13page.js
Normal file
@@ -0,0 +1,945 @@
|
||||
|
||||
const { buildCommand, buildReadVersion, COMMANDS } = require('../../../../utils/w13Packet.js')
|
||||
const FIXED_CONNECT_CMD = new Uint8Array([0xCC, 0xC0, 0x0C, 0x00, 0x4F, 0xC0, 0x01, 0x00, 0x02, 0x00, 0x0C, 0x08])
|
||||
Page({
|
||||
data: {
|
||||
TabCur: 1, // 1: 蓝牙调试 2: 蓝牙升级
|
||||
DevName: '',
|
||||
bleName: '',
|
||||
// 预设占位数据,未获取真实设备信息时用于展示
|
||||
bleMac: '00:00:00:00:00:00',
|
||||
bleAMC: '-',
|
||||
bleVersion: '-',
|
||||
openDelay: 20,
|
||||
bathDelay: 20,
|
||||
logs: [],
|
||||
hexSend: false,
|
||||
hexShow: false,
|
||||
withTimestamp: false,
|
||||
wrapCRLF: false,
|
||||
sendText: '',
|
||||
importFileName: '',
|
||||
// BLE上下文(从上一页传入)
|
||||
deviceId: '',
|
||||
serviceId: '',
|
||||
txCharId: '',
|
||||
rxCharId: '',
|
||||
logList: [],
|
||||
timeUnits: ['时', '分', '秒'],
|
||||
hourValues: Array.from({ length: 24 }, (_, i) => i + 1),
|
||||
msValues: Array.from({ length: 60 }, (_, i) => i + 1),
|
||||
ports: [
|
||||
{ name: '无卡取电 CH1', portLabel: '开门磁', alias: '开门磁', deviceType: 0, deviceAddr: 0, loop: 1, thresholdUp: 0, thresholdDown: 0, enabled: false, detectTime: 0, detectUnit: 0 },
|
||||
{ name: '无卡取电 CH2', portLabel: '门口红外', alias: '门口红外', deviceType: 0, deviceAddr: 0, loop: 2, thresholdUp: 0, thresholdDown: 0, enabled: false, detectTime: 0, detectUnit: 0 },
|
||||
{ name: '无卡取电 CH3', portLabel: '床头红外', alias: '床头红外', deviceType: 0, deviceAddr: 0, loop: 3, thresholdUp: 0, thresholdDown: 0, enabled: false, detectTime: 0, detectUnit: 0 },
|
||||
{ name: '无卡取电 CH4', portLabel: '卫浴红外', alias: '卫浴红外', deviceType: 0, deviceAddr: 0, loop: 4, thresholdUp: 0, thresholdDown: 0, enabled: false, detectTime: 0, detectUnit: 0 }
|
||||
],
|
||||
// 条件“有无人标记”选项,参考截图:无人至有人/短暂离开/长时间离开/有人至无人
|
||||
tagOptions: ['无人至有人', '短暂离开', '长时间离开', '有人至无人'],
|
||||
stateOptions: ['不判断', '触发', '释放', '开启', '关闭'],
|
||||
// 默认条件:与截图一致(组1..6,各1条)
|
||||
conditions: [
|
||||
{ group: 1, seq: 1, tag: 0, cardPower: 0, doorMag: 1, irHall: 0, bathRadar: 0, bathroomRadar: 0, judgeTime: 0, judgeUnit: 2, timeout: 0, timeoutUnit: 2 },
|
||||
{ group: 2, seq: 1, tag: 0, cardPower: 0, doorMag: 0, irHall: 1, bathRadar: 0, bathroomRadar: 0, judgeTime: 0, judgeUnit: 2, timeout: 20, timeoutUnit: 2 },
|
||||
{ group: 2, seq: 2, tag: 0, cardPower: 0, doorMag: 0, irHall: 1, bathRadar: 0, bathroomRadar: 0, judgeTime: 0, judgeUnit: 2, timeout: 20, timeoutUnit: 2 },
|
||||
{ group: 2, seq: 3, tag: 0, cardPower: 0, doorMag: 0, irHall: 1, bathRadar: 0, bathroomRadar: 0, judgeTime: 0, judgeUnit: 2, timeout: 20, timeoutUnit: 2 },
|
||||
{ group: 3, seq: 1, tag: 0, cardPower: 0, doorMag: 0, irHall: 1, bathRadar: 0, bathroomRadar: 0, judgeTime: 0, judgeUnit: 2, timeout: 0, timeoutUnit: 2 },
|
||||
{ group: 4, seq: 1, tag: 1, cardPower: 0, doorMag: 4, irHall: 2, bathRadar: 2, bathroomRadar: 2, judgeTime: 0, judgeUnit: 2, timeout: 2, timeoutUnit: 2 },
|
||||
{ group: 5, seq: 1, tag: 2, cardPower: 0, doorMag: 4, irHall: 2, bathRadar: 2, bathroomRadar: 2, judgeTime: 2, judgeUnit: 1, timeout: 10, timeoutUnit: 1 },
|
||||
{ group: 6, seq: 1, tag: 3, cardPower: 0, doorMag: 4, irHall: 2, bathRadar: 2, bathroomRadar: 2, judgeTime: 2, judgeUnit: 1, timeout: 10, timeoutUnit: 1 }
|
||||
],
|
||||
// 二级菜单:按组折叠
|
||||
condGroups: [],
|
||||
// 雷达指示灯(bit0=门磁,bit1=卫浴,bit2=卧室,bit3=走廊)
|
||||
radarLights: [
|
||||
{ key: 'door', label: '门磁', colorClass: 'gray', triggered: false },
|
||||
{ key: 'bath', label: '卫浴', colorClass: 'gray', triggered: false },
|
||||
{ key: 'bed', label: '卧室', colorClass: 'gray', triggered: false },
|
||||
{ key: 'hall', label: '走廊', colorClass: 'gray', triggered: false }
|
||||
]
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const raw = options && (options.DevName || options.name) || ''
|
||||
// 处理通过 URL 传递的编码,避免中文显示为乱码
|
||||
let devName = ''
|
||||
try {
|
||||
devName = decodeURIComponent(raw)
|
||||
} catch (e) {
|
||||
devName = raw
|
||||
}
|
||||
if (!devName) devName = 'B13设备'
|
||||
const rawMac = options && options.mac ? options.mac : ''
|
||||
const bleMac = rawMac ? decodeURIComponent(rawMac) : '00:00:00:00:00:00'
|
||||
const deviceId = rawMac ? decodeURIComponent(rawMac) : ''
|
||||
const serviceId = options && options.serviceId ? decodeURIComponent(options.serviceId) : ''
|
||||
const txCharId = options && options.txCharId ? decodeURIComponent(options.txCharId) : ''
|
||||
const rxCharId = options && options.rxCharId ? decodeURIComponent(options.rxCharId) : ''
|
||||
this.setData({ DevName: devName, bleName: devName, bleMac, deviceId, serviceId, txCharId, rxCharId })
|
||||
// 页面进入时打印当前蓝牙连接状态
|
||||
this.logBleStatus()
|
||||
// 构建条件组
|
||||
this.buildCondGroups()
|
||||
// 自动发现特征并启动雷达订阅/读取
|
||||
this.ensureBleChannels(() => {
|
||||
this.startRadarStatusWatch()
|
||||
})
|
||||
wx.setNavigationBarTitle({ title: devName })
|
||||
this.logBleStatus()
|
||||
|
||||
},
|
||||
|
||||
onShow() {
|
||||
// 页面显示时也打印一次,方便返回/二次进入场景
|
||||
this.logBleStatus()
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this.teardownBleListener()
|
||||
},
|
||||
|
||||
// 顶部标签切换
|
||||
tabSelect(e) {
|
||||
const id = Number(e.currentTarget.dataset.id || 1)
|
||||
this.setData({ TabCur: id })
|
||||
},
|
||||
|
||||
onOpenDelayChange(e) {
|
||||
this.setData({ openDelay: e.detail.value })
|
||||
},
|
||||
onBathDelayChange(e) {
|
||||
this.setData({ bathDelay: e.detail.value })
|
||||
},
|
||||
|
||||
// 便捷示例:读版本号
|
||||
onSendReadVersion() {
|
||||
try {
|
||||
const pkt = buildReadVersion()
|
||||
const text = this.data.hexShow ? this.toHex(pkt) : `[${Array.from(pkt).join(', ')}]`
|
||||
this.appendLog('TX', `读版本号: ${text}`)
|
||||
} catch (err) {
|
||||
wx.showToast({ title: '构包失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
// 开始OTA升级(命令0x0B, P0=0x01)
|
||||
onStartOta() {
|
||||
try {
|
||||
const pkt = buildCommand(COMMANDS.OTA_START, [0x01])
|
||||
const text = this.toHex(pkt)
|
||||
this.appendLog('TX', `OTA开始: ${text}`)
|
||||
wx.showToast({ title: '已发送OTA开始', icon: 'success' })
|
||||
} catch (err) {
|
||||
wx.showToast({ title: '构包失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
// 开启蓝牙打印(命令0x0C,示例掩码0x1F)
|
||||
onEnableBleLog() {
|
||||
try {
|
||||
const mask = 0x1F
|
||||
const pkt = buildCommand(COMMANDS.ENABLE_BLE_LOG, [mask])
|
||||
const text = this.toHex(pkt)
|
||||
this.appendLog('TX', `开启打印(0x${mask.toString(16).toUpperCase()}): ${text}`)
|
||||
wx.showToast({ title: '已发送打印开关', icon: 'success' })
|
||||
} catch (err) {
|
||||
wx.showToast({ title: '构包失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
toHex(u8) {
|
||||
return Array.from(u8).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ')
|
||||
},
|
||||
|
||||
// 控制台输出当前蓝牙连接状态
|
||||
logBleStatus() {
|
||||
const { deviceId, serviceId, txCharId, rxCharId } = this.data || {}
|
||||
console.info(`[BLE] B13page enter: device=${deviceId || '-'} svc=${serviceId || '-'} tx=${txCharId || '-'} rx=${rxCharId || '-'}`)
|
||||
const onUnknown = () => console.info('[BLE] connection state: unknown (no deviceId or API missing)')
|
||||
if (!deviceId) {
|
||||
onUnknown()
|
||||
return
|
||||
}
|
||||
const logConnected = (connected) => console.info(`[BLE] connection state: ${connected ? 'connected' : 'disconnected'}`)
|
||||
// 先尝试新版接口
|
||||
if (typeof wx.getBLEConnectionState === 'function') {
|
||||
try {
|
||||
wx.getBLEConnectionState({
|
||||
deviceId,
|
||||
success: (res) => logConnected(!!(res && res.connected)),
|
||||
fail: () => console.warn('[BLE] get connection state failed')
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[BLE] get connection state exception')
|
||||
}
|
||||
return
|
||||
}
|
||||
// 兼容旧端:检查已连接设备列表
|
||||
if (typeof wx.getConnectedBluetoothDevices === 'function') {
|
||||
try {
|
||||
wx.getConnectedBluetoothDevices({
|
||||
success: (res) => {
|
||||
const list = (res && res.devices) || []
|
||||
const hit = list.some(d => (d.deviceId || '').toUpperCase() === deviceId.toUpperCase())
|
||||
logConnected(hit)
|
||||
},
|
||||
fail: () => console.warn('[BLE] getConnectedBluetoothDevices failed')
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[BLE] getConnectedBluetoothDevices exception')
|
||||
}
|
||||
return
|
||||
}
|
||||
onUnknown()
|
||||
},
|
||||
|
||||
// 发送前确认当前蓝牙连接状态(兼容无 getBLEConnectionState 场景)
|
||||
ensureBleConnected(next, attempt = 0) {
|
||||
const { deviceId } = this.data || {}
|
||||
if (!deviceId) {
|
||||
wx.showToast({ title: '未连接BLE', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const proceed = () => { if (typeof next === 'function') next() }
|
||||
|
||||
// 优先使用 getBLEConnectionState
|
||||
if (typeof wx.getBLEConnectionState === 'function') {
|
||||
try {
|
||||
wx.getBLEConnectionState({
|
||||
deviceId,
|
||||
success: (res) => {
|
||||
const connected = !!(res && res.connected)
|
||||
if (!connected) {
|
||||
// 某些机型首次查询可能短暂返回断开,允许一次快速重试
|
||||
if (attempt < 1) {
|
||||
this.appendLog('WARN', 'BLE未连接,重试查询...')
|
||||
setTimeout(() => this.ensureBleConnected(next, attempt + 1), 250)
|
||||
return
|
||||
}
|
||||
this.appendLog('WARN', 'BLE未连接,取消发送')
|
||||
wx.showToast({ title: '蓝牙未连接', icon: 'none' })
|
||||
return
|
||||
}
|
||||
proceed()
|
||||
},
|
||||
fail: () => {
|
||||
this.appendLog('WARN', '查询BLE状态失败,尝试兜底')
|
||||
this._fallbackCheckConnected(deviceId, proceed)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
this.appendLog('WARN', '查询BLE状态异常,尝试兜底')
|
||||
this._fallbackCheckConnected(deviceId, proceed)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 兼容旧端:直接兜底检查
|
||||
this._fallbackCheckConnected(deviceId, proceed)
|
||||
},
|
||||
|
||||
// 兜底检查:使用已连接设备列表;若接口不可用则直接继续
|
||||
_fallbackCheckConnected(deviceId, proceed) {
|
||||
if (typeof wx.getConnectedBluetoothDevices !== 'function') {
|
||||
proceed()
|
||||
return
|
||||
}
|
||||
try {
|
||||
wx.getConnectedBluetoothDevices({
|
||||
success: (res) => {
|
||||
const list = (res && res.devices) || []
|
||||
const norm = (s) => (s || '').replace(/-/g, '').toUpperCase()
|
||||
const target = norm(deviceId)
|
||||
const hit = list.some(d => norm(d.deviceId) === target)
|
||||
if (hit) {
|
||||
proceed()
|
||||
return
|
||||
}
|
||||
// 若列表为空或匹配不到,但已有 service/char,放行并记录警告(部分机型/安卓可能返回空列表)
|
||||
if (list.length === 0 || (this.data.serviceId && this.data.txCharId)) {
|
||||
this.appendLog('WARN', '未在已连接列表中找到,假定已连接尝试发送')
|
||||
proceed()
|
||||
return
|
||||
}
|
||||
this.appendLog('WARN', '未在已连接设备列表中找到,取消发送')
|
||||
wx.showToast({ title: '蓝牙未连接', icon: 'none' })
|
||||
},
|
||||
fail: () => {
|
||||
this.appendLog('WARN', '查询已连接设备失败,尝试继续发送')
|
||||
proceed()
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
this.appendLog('WARN', '查询已连接设备异常,尝试继续发送')
|
||||
proceed()
|
||||
}
|
||||
},
|
||||
|
||||
ab2hex(buffer) {
|
||||
if (!buffer) return ''
|
||||
return Array.from(new Uint8Array(buffer)).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ')
|
||||
},
|
||||
|
||||
_matchUuid(uuid, needle) {
|
||||
if (!uuid || !needle) return false
|
||||
const u = String(uuid).replace(/-/g, '').toUpperCase()
|
||||
const n = String(needle).toUpperCase().replace(/^0X/, '')
|
||||
return u.includes(n)
|
||||
},
|
||||
|
||||
hexToBytes(hex) {
|
||||
const clean = (hex || '').replace(/\s+/g, '').toUpperCase()
|
||||
if (clean.length === 0) return new Uint8Array(0)
|
||||
if (clean.length % 2 !== 0 || /[^0-9A-F]/.test(clean)) return null
|
||||
const out = new Uint8Array(clean.length / 2)
|
||||
for (let i = 0; i < clean.length; i += 2) out[i/2] = parseInt(clean.substr(i,2), 16)
|
||||
return out
|
||||
},
|
||||
|
||||
strToBytes(str) {
|
||||
const s = String(str || '')
|
||||
const out = new Uint8Array(s.length)
|
||||
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i) & 0xFF
|
||||
return out
|
||||
},
|
||||
|
||||
writeBleBytes(u8, label) {
|
||||
const { deviceId, serviceId, txCharId } = this.data || {}
|
||||
if (!deviceId) {
|
||||
wx.showToast({ title: '未连接BLE', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const doWrite = () => {
|
||||
// 若缺少特征/服务,先自动发现再发送
|
||||
if (!serviceId || !txCharId) {
|
||||
this.ensureBleChannels(() => this.writeBleBytes(u8, label))
|
||||
return
|
||||
}
|
||||
|
||||
const bytes = (u8 instanceof Uint8Array) ? u8 : new Uint8Array(u8 || [])
|
||||
this.appendLog('CFG', `device=${deviceId || '-'} svc=${serviceId || '-'} tx=${txCharId || '-'}`)
|
||||
// 发送完成时打印当前蓝牙连接状态
|
||||
this.logBleStatus()
|
||||
wx.writeBLECharacteristicValue({
|
||||
deviceId,
|
||||
serviceId,
|
||||
characteristicId: txCharId,
|
||||
value: bytes.buffer,
|
||||
success: () => {
|
||||
// 发送完成时打印当前蓝牙连接状态
|
||||
this.logBleStatus()
|
||||
},
|
||||
fail: (err) => {
|
||||
const msg = (err && err.errMsg) ? err.errMsg : '发送失败'
|
||||
this.appendLog('WARN', `${label || '发送'} ${msg}`)
|
||||
wx.showToast({ title: '发送失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 写入前检查当前蓝牙连接状态(兼容旧接口)
|
||||
this.ensureBleConnected(doWrite)
|
||||
},
|
||||
|
||||
ensureBleChannels(done) {
|
||||
const { deviceId, serviceId, txCharId, rxCharId } = this.data || {}
|
||||
// 若未携带 deviceId,尝试从系统已连接设备列表中兜底获取
|
||||
let devId = deviceId
|
||||
if (!devId && typeof wx.getConnectedBluetoothDevices === 'function') {
|
||||
try {
|
||||
wx.getConnectedBluetoothDevices({
|
||||
success: (res) => {
|
||||
const list = (res && res.devices) || []
|
||||
if (list.length > 0) {
|
||||
const first = list[0]
|
||||
devId = first && (first.deviceId || first.deviceId)
|
||||
if (devId) this.setData({ deviceId: devId })
|
||||
// 继续后续流程
|
||||
this.ensureBleChannels(done)
|
||||
} else {
|
||||
wx.showToast({ title: '未发现已连接设备', icon: 'none' })
|
||||
}
|
||||
},
|
||||
fail: () => wx.showToast({ title: '获取连接设备失败', icon: 'none' })
|
||||
})
|
||||
} catch (e) {
|
||||
wx.showToast({ title: '蓝牙接口异常', icon: 'none' })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!devId) {
|
||||
wx.showToast({ title: '未连接BLE', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (serviceId && txCharId && rxCharId) {
|
||||
if (typeof done === 'function') done()
|
||||
return
|
||||
}
|
||||
if (typeof wx.getBLEDeviceServices !== 'function') {
|
||||
wx.showToast({ title: 'BLE接口不可用', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wx.showLoading({ title: '发现服务...', mask: true })
|
||||
wx.getBLEDeviceServices({
|
||||
deviceId: devId,
|
||||
success: (srvRes) => {
|
||||
const services = srvRes.services || []
|
||||
let found = false
|
||||
let pending = services.length
|
||||
const score = (s) => {
|
||||
const u = (s.uuid || '').toUpperCase()
|
||||
return u.includes('FFE') ? 2 : (s.isPrimary === true ? 1 : 0)
|
||||
}
|
||||
const sorted = services.slice().sort((a, b) => score(b) - score(a))
|
||||
sorted.forEach(s => {
|
||||
wx.getBLEDeviceCharacteristics({
|
||||
deviceId: devId,
|
||||
serviceId: s.uuid,
|
||||
success: (chRes) => {
|
||||
const chars = chRes.characteristics || []
|
||||
const ffe1 = chars.find(c => this._matchUuid(c.uuid, 'FFE1'))
|
||||
const ffe2 = chars.find(c => this._matchUuid(c.uuid, 'FFE2'))
|
||||
if (!found && ffe1 && ffe2) {
|
||||
found = true
|
||||
this.setData({ serviceId: s.uuid, txCharId: ffe1.uuid, rxCharId: ffe2.uuid })
|
||||
// 立即开启通知
|
||||
this.enableNotify()
|
||||
wx.hideLoading()
|
||||
if (typeof done === 'function') done()
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
pending -= 1
|
||||
if (!found && pending === 0) {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '未找到FFE1/FFE2', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '获取服务失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
startRadarStatusWatch() {
|
||||
// 开启订阅(若已传入rx特征),保证能接收数据
|
||||
this.enableNotify()
|
||||
this.setupBleListener()
|
||||
this.sendRadarStatusCommand(true)
|
||||
},
|
||||
|
||||
enableNotify() {
|
||||
const { deviceId, serviceId, rxCharId } = this.data || {}
|
||||
if (!deviceId || !serviceId || !rxCharId || typeof wx.notifyBLECharacteristicValueChange !== 'function') {
|
||||
this.appendLog('WARN', '通知前置条件不足')
|
||||
return
|
||||
}
|
||||
this.appendLog('CFG', `enableNotify device=${deviceId} svc=${serviceId} rx=${rxCharId}`)
|
||||
const tryEnable = (attempt) => {
|
||||
try {
|
||||
wx.notifyBLECharacteristicValueChange({
|
||||
state: true,
|
||||
deviceId,
|
||||
serviceId,
|
||||
characteristicId: rxCharId,
|
||||
success: () => this.appendLog('UI', `已开启通知 device=${deviceId} svc=${serviceId} rx=${rxCharId}`),
|
||||
fail: (err) => {
|
||||
const code = (err && (err.errCode ?? err.code))
|
||||
const msg = (err && (err.errMsg || err.message)) || '未知原因'
|
||||
const detail = code !== undefined ? `code=${code} ${msg}` : msg
|
||||
this.appendLog('WARN', `开启通知失败(重试${attempt}) ${detail}`)
|
||||
wx.showToast({ title: '通知失败', icon: 'none' })
|
||||
if (attempt < 2) {
|
||||
// 触发一次服务特征刷新后再重试
|
||||
this.ensureBleChannels(() => setTimeout(() => tryEnable(attempt + 1), 120))
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = (e && (e.errMsg || e.message)) || '异常'
|
||||
this.appendLog('WARN', `开启通知异常 ${msg}`)
|
||||
wx.showToast({ title: '通知异常', icon: 'none' })
|
||||
}
|
||||
}
|
||||
tryEnable(0)
|
||||
},
|
||||
|
||||
sendRadarStatusCommand(enable) {
|
||||
try {
|
||||
const payload = [enable ? 0x01 : 0x02]
|
||||
const pkt = buildCommand(COMMANDS.RADAR_STATUS, payload)
|
||||
this.transmitPacket(pkt, `雷达状态${enable ? '开启读取' : '关闭读取'}`)
|
||||
} catch (err) {
|
||||
wx.showToast({ title: '雷达命令构建失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
transmitPacket(pkt, label) {
|
||||
|
||||
const hex = this.toHex(pkt)
|
||||
this.appendLog('TX', `${label}: ${hex}`)
|
||||
// 如果页面接入了BLE连接参数,则尝试写入;未配置则仅记录日志
|
||||
const { deviceId, serviceId, txCharId } = this.data || {}
|
||||
if (!deviceId || !serviceId || !txCharId || typeof wx.writeBLECharacteristicValue !== 'function') return
|
||||
try {
|
||||
wx.writeBLECharacteristicValue({
|
||||
deviceId,
|
||||
serviceId,
|
||||
characteristicId: txCharId,
|
||||
value: (pkt && pkt.buffer) ? pkt.buffer : new Uint8Array(pkt || []).buffer,
|
||||
fail: (err) => {
|
||||
const code = err && (err.errCode ?? err.code)
|
||||
const msg = (err && (err.errMsg || err.message)) || '未知原因'
|
||||
const detail = code !== undefined ? `code=${code} ${msg}` : msg
|
||||
this.appendLog('WARN', `写入BLE失败 device=${deviceId} svc=${serviceId} tx=${txCharId} ${detail}`)
|
||||
wx.showToast({ title: '发送失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = (err && (err.errMsg || err.message)) || '异常'
|
||||
this.appendLog('WARN', `写入BLE异常 ${msg}`)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 注册 BLE 通知监听
|
||||
* - 先取消旧监听,避免重复回调造成日志噪音或重复解析
|
||||
* - 仅处理目标 rxCharId 的通知(按 UUID 片段过滤,兼容 16/128 位 UUID)
|
||||
* - 兼容两类上报载荷:ArrayBuffer 与十六进制字符串
|
||||
* - 将原始数据规范化为 Uint8Array,记录日志后交由协议解析器处理
|
||||
*/
|
||||
setupBleListener() {
|
||||
// 移除旧监听,防止重复触发(页面重复进入或多次初始化的场景)
|
||||
this.teardownBleListener()
|
||||
// 运行环境不支持通知回调则直接返回(避免报错)
|
||||
if (typeof wx.onBLECharacteristicValueChange !== 'function') return
|
||||
// 定义并缓存通知回调,便于后续 off 解绑
|
||||
this._onBleChange = (res) => {
|
||||
const { rxCharId } = this.data || {}
|
||||
// 过滤:如果能拿到通知的特征 ID,仅处理与当前订阅 rxCharId 匹配的通知
|
||||
if (rxCharId && res && res.characteristicId) {
|
||||
const cid = String(res.characteristicId).replace(/-/g, '').toUpperCase()
|
||||
const rx = String(rxCharId).replace(/-/g, '').toUpperCase()
|
||||
if (!cid.includes(rx)) return
|
||||
}
|
||||
// 数据抽取:res 可能包含 value(ArrayBuffer) 或自定义的十六进制字符串字段
|
||||
const buffer = res && res.value
|
||||
const hexStr = res && (res.hexStr || res.hex || res.data)
|
||||
let hex = ''
|
||||
let u8 = null
|
||||
// ArrayBuffer → Uint8Array,并生成十六进制视图文本
|
||||
if (buffer) {
|
||||
u8 = new Uint8Array(buffer)
|
||||
hex = this.ab2hex(buffer)
|
||||
} else if (typeof hexStr === 'string') {
|
||||
// 规范化十六进制字符串:去空格、转大写,并按字节切分
|
||||
hex = hexStr.replace(/\s+/g, '').toUpperCase().replace(/(..)/g, '$1 ').trim()
|
||||
const clean = hex.replace(/\s+/g, '')
|
||||
const arr = []
|
||||
for (let i = 0; i < clean.length; i += 2) arr.push(parseInt(clean.substr(i, 2), 16) || 0)
|
||||
u8 = Uint8Array.from(arr)
|
||||
}
|
||||
// 若解析失败(没有有效数据),直接忽略此次通知
|
||||
if (!u8) return
|
||||
// 按显示偏好记录日志(HEX 或原始文本)
|
||||
const viewText = this.data.hexShow ? hex : `[${hex}]`
|
||||
this.appendLog('RX', viewText)
|
||||
// 将规范化数据交给协议解析器,进行业务处理与 UI 更新
|
||||
this.handleIncomingPacket(u8)
|
||||
}
|
||||
// 注册系统 BLE 通知回调
|
||||
wx.onBLECharacteristicValueChange(this._onBleChange)
|
||||
},
|
||||
|
||||
teardownBleListener() {
|
||||
if (this._onBleChange && typeof wx.offBLECharacteristicValueChange === 'function') {
|
||||
wx.offBLECharacteristicValueChange(this._onBleChange)
|
||||
}
|
||||
this._onBleChange = null
|
||||
},
|
||||
|
||||
handleIncomingPacket(u8) {
|
||||
if (!u8 || u8.length < 11) return
|
||||
const headOk = u8[0] === 0xCC && u8[1] === 0xC0
|
||||
const frameType = u8[10]
|
||||
if (!headOk) return
|
||||
if (frameType === (COMMANDS.RADAR_STATUS & 0xFF)) {
|
||||
const parsed = this.parseRadarStatus(u8)
|
||||
if (parsed) {
|
||||
this.updateRadarLights(parsed.bits)
|
||||
this.appendLog('PARSE', `雷达状态: 有效端口${parsed.portCount} 有人标记=${parsed.human === 0x01 ? '有人' : '无人'} 位=0b${parsed.bits.toString(2).padStart(8, '0')}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
parseRadarStatus(u8) {
|
||||
// u8: Head Len CRC Frame FramNum Type Params...
|
||||
if (!u8 || u8.length < 13) return null
|
||||
const params = u8.slice(11)
|
||||
const portCount = params[0] || 0
|
||||
const human = params[1]
|
||||
const bits = params[2] || 0
|
||||
return { portCount, human, bits }
|
||||
},
|
||||
|
||||
updateRadarLights(bits) {
|
||||
const next = (this.data.radarLights || []).map((it, idx) => {
|
||||
const triggered = ((bits >> idx) & 0x01) === 1
|
||||
return {
|
||||
...it,
|
||||
triggered,
|
||||
colorClass: triggered ? 'red' : 'green'
|
||||
}
|
||||
})
|
||||
this.setData({ radarLights: next })
|
||||
},
|
||||
|
||||
// 数值约束
|
||||
clamp(v, min, max) {
|
||||
v = Number(v || 0)
|
||||
if (isNaN(v)) return min
|
||||
if (v < min) return min
|
||||
if (v > max) return max
|
||||
return v
|
||||
},
|
||||
clampDetectByUnit(unit, v) {
|
||||
// unit: 0=时(1..24) 1=分(1..60) 2=秒(1..60)
|
||||
const max = unit === 0 ? 24 : 60
|
||||
return this.clamp(v, 1, max)
|
||||
},
|
||||
|
||||
// === 条件组(二级菜单)逻辑 ===
|
||||
buildCondGroups() {
|
||||
const map = new Map()
|
||||
const list = (this.data.conditions || []).slice().sort((a, b) => (a.group - b.group) || (a.seq - b.seq))
|
||||
list.forEach(it => {
|
||||
if (!map.has(it.group)) {
|
||||
map.set(it.group, { group: it.group, timeout: it.timeout, timeoutUnit: it.timeoutUnit, expanded: false, items: [] })
|
||||
}
|
||||
map.get(it.group).items.push({ ...it, expanded: true })
|
||||
})
|
||||
this.setData({ condGroups: Array.from(map.values()) })
|
||||
},
|
||||
onToggleGroup(e) {
|
||||
const idx = Number(e.currentTarget.dataset.idx)
|
||||
const groups = this.data.condGroups.slice()
|
||||
if (groups[idx]) groups[idx].expanded = !groups[idx].expanded
|
||||
this.setData({ condGroups: groups })
|
||||
},
|
||||
onGroupNumberInput(e) {
|
||||
const idx = Number(e.currentTarget.dataset.idx)
|
||||
const field = e.currentTarget.dataset.field
|
||||
let val = Number(e.detail.value || 0)
|
||||
const groups = this.data.condGroups.slice()
|
||||
if (groups[idx]) {
|
||||
if (field === 'timeout') {
|
||||
const unit = Number(groups[idx].timeoutUnit || 0)
|
||||
val = this.clampDetectByUnit(unit, val || 1)
|
||||
}
|
||||
groups[idx][field] = val
|
||||
}
|
||||
this.setData({ condGroups: groups })
|
||||
},
|
||||
onGroupPickerChange(e) {
|
||||
const idx = Number(e.currentTarget.dataset.idx)
|
||||
const field = e.currentTarget.dataset.field
|
||||
const val = Number(e.detail.value || 0)
|
||||
const groups = this.data.condGroups.slice()
|
||||
if (groups[idx]) {
|
||||
if (field === 'timeout') {
|
||||
// 选择的是索引,真实值=索引+1
|
||||
groups[idx].timeout = (val + 1)
|
||||
} else {
|
||||
groups[idx][field] = val
|
||||
}
|
||||
if (field === 'timeoutUnit') {
|
||||
const t = Number(groups[idx].timeout || 0)
|
||||
groups[idx].timeout = this.clampDetectByUnit(val, t || 1)
|
||||
}
|
||||
}
|
||||
this.setData({ condGroups: groups })
|
||||
},
|
||||
onItemNumberInput(e) {
|
||||
const gidx = Number(e.currentTarget.dataset.gidx)
|
||||
const iidx = Number(e.currentTarget.dataset.iidx)
|
||||
const field = e.currentTarget.dataset.field
|
||||
// 序号为只读,不允许通过输入更新
|
||||
if (field === 'seq') return
|
||||
let val = Number(e.detail.value || 0)
|
||||
const groups = this.data.condGroups.slice()
|
||||
const grp = groups[gidx]
|
||||
if (grp && grp.items[iidx]) {
|
||||
if (field === 'judgeTime') {
|
||||
const unit = Number(grp.items[iidx].judgeUnit || 0)
|
||||
val = this.clampDetectByUnit(unit, val || 1)
|
||||
}
|
||||
grp.items[iidx][field] = val
|
||||
}
|
||||
this.setData({ condGroups: groups })
|
||||
},
|
||||
onItemPickerChange(e) {
|
||||
const gidx = Number(e.currentTarget.dataset.gidx)
|
||||
const iidx = Number(e.currentTarget.dataset.iidx)
|
||||
const field = e.currentTarget.dataset.field
|
||||
const val = Number(e.detail.value || 0)
|
||||
const groups = this.data.condGroups.slice()
|
||||
const grp = groups[gidx]
|
||||
if (grp && grp.items[iidx]) {
|
||||
if (field === 'judgeTime') {
|
||||
// 选择的是索引,真实值=索引+1
|
||||
grp.items[iidx].judgeTime = (val + 1)
|
||||
} else {
|
||||
grp.items[iidx][field] = val
|
||||
}
|
||||
if (field === 'judgeUnit') {
|
||||
const t = Number(grp.items[iidx].judgeTime || 0)
|
||||
grp.items[iidx].judgeTime = this.clampDetectByUnit(val, t || 1)
|
||||
}
|
||||
}
|
||||
this.setData({ condGroups: groups })
|
||||
},
|
||||
onToggleItem(e) {
|
||||
const gidx = Number(e.currentTarget.dataset.gidx)
|
||||
const iidx = Number(e.currentTarget.dataset.iidx)
|
||||
const groups = this.data.condGroups.slice()
|
||||
const grp = groups[gidx]
|
||||
if (grp && grp.items[iidx]) {
|
||||
grp.items[iidx].expanded = !grp.items[iidx].expanded
|
||||
}
|
||||
this.setData({ condGroups: groups })
|
||||
},
|
||||
|
||||
// 端口配置交互
|
||||
onPortAliasInput(e) {
|
||||
const idx = Number(e.currentTarget.dataset.idx)
|
||||
const val = String(e.detail.value || '')
|
||||
const list = this.data.ports.slice()
|
||||
if (list[idx]) list[idx].alias = val
|
||||
this.setData({ ports: list })
|
||||
},
|
||||
onPortNumberInput(e) {
|
||||
const idx = Number(e.currentTarget.dataset.idx)
|
||||
const field = e.currentTarget.dataset.field
|
||||
let val = Number(e.detail.value || 0)
|
||||
const list = this.data.ports.slice()
|
||||
if (list[idx]) {
|
||||
if (field === 'loop') {
|
||||
val = this.clamp(val, 1, 12)
|
||||
} else if (field === 'thresholdUp' || field === 'thresholdDown') {
|
||||
val = this.clamp(val, 0, 100)
|
||||
} else if (field === 'detectTime') {
|
||||
const unit = Number(list[idx].detectUnit || 0)
|
||||
val = this.clampDetectByUnit(unit, val)
|
||||
}
|
||||
list[idx][field] = val
|
||||
}
|
||||
this.setData({ ports: list })
|
||||
},
|
||||
onPortSwitch(e) {
|
||||
const idx = Number(e.currentTarget.dataset.idx)
|
||||
const field = e.currentTarget.dataset.field
|
||||
const checked = !!e.detail.value
|
||||
const list = this.data.ports.slice()
|
||||
if (list[idx]) list[idx][field] = checked
|
||||
this.setData({ ports: list })
|
||||
},
|
||||
onPortUnitChange(e) {
|
||||
const idx = Number(e.currentTarget.dataset.idx)
|
||||
const field = e.currentTarget.dataset.field
|
||||
const val = Number(e.detail.value || 0)
|
||||
const list = this.data.ports.slice()
|
||||
if (list[idx]) {
|
||||
list[idx][field] = val
|
||||
// 切换单位时同时校正检测时间范围
|
||||
const dt = Number(list[idx].detectTime || 0)
|
||||
list[idx].detectTime = this.clampDetectByUnit(val, dt || 1)
|
||||
}
|
||||
this.setData({ ports: list })
|
||||
},
|
||||
onSavePorts() {
|
||||
this.data.ports.forEach((p, i) => {
|
||||
const P0 = p.deviceType & 0xFF
|
||||
const P1 = p.deviceAddr & 0xFF
|
||||
const loopLE = [p.loop & 0xFF, (p.loop >>> 8) & 0xFF]
|
||||
const P4 = p.thresholdDown & 0xFF
|
||||
const P5 = (i + 1) & 0xFF
|
||||
const P6 = p.enabled ? 0x01 : 0x00
|
||||
const dtLE = [p.detectTime & 0xFF, (p.detectTime >>> 8) & 0xFF]
|
||||
const P9 = p.detectUnit & 0xFF
|
||||
const P10 = p.thresholdUp & 0xFF
|
||||
const payload = [P0, P1, ...loopLE, P4, P5, P6, ...dtLE, P9, P10]
|
||||
const pkt = buildCommand(COMMANDS.SET_CONDITION_2, payload, { frame: i + 1, framNum: this.data.ports.length })
|
||||
this.appendLog('TX', `端口配置[${p.name}]: ${this.toHex(pkt)}`)
|
||||
})
|
||||
wx.showToast({ title: '端口配置已发送', icon: 'success' })
|
||||
},
|
||||
|
||||
// 读取端口配置(占位示例,后续可接入真实读取命令)
|
||||
onReadPorts() {
|
||||
this.appendLog('UI', '请求读取端口配置')
|
||||
wx.showToast({ title: '已请求读取端口配置', icon: 'none' })
|
||||
},
|
||||
|
||||
// 条件配置交互
|
||||
onCondNumberInput(e) {
|
||||
const idx = Number(e.currentTarget.dataset.idx)
|
||||
const field = e.currentTarget.dataset.field
|
||||
const val = Number(e.detail.value || 0)
|
||||
const list = this.data.conditions.slice()
|
||||
if (list[idx]) list[idx][field] = val
|
||||
this.setData({ conditions: list })
|
||||
},
|
||||
onCondPickerChange(e) {
|
||||
const idx = Number(e.currentTarget.dataset.idx)
|
||||
const field = e.currentTarget.dataset.field
|
||||
const val = Number(e.detail.value || 0)
|
||||
const list = this.data.conditions.slice()
|
||||
if (list[idx]) list[idx][field] = val
|
||||
this.setData({ conditions: list })
|
||||
},
|
||||
onSaveConditions() {
|
||||
// 将二级菜单分组扁平化回 conditions
|
||||
const flat = []
|
||||
(this.data.condGroups || []).forEach(grp => {
|
||||
(grp.items || []).forEach(it => {
|
||||
flat.push({ ...it, group: grp.group, timeout: grp.timeout, timeoutUnit: grp.timeoutUnit })
|
||||
})
|
||||
})
|
||||
this.setData({ conditions: flat })
|
||||
flat.forEach((c, i) => {
|
||||
const P0 = c.tag & 0xFF
|
||||
const P1 = c.group & 0xFF
|
||||
const P2 = c.seq & 0xFF
|
||||
const jtLE = [c.judgeTime & 0xFF, (c.judgeTime >>> 8) & 0xFF]
|
||||
const P5 = c.judgeUnit & 0xFF
|
||||
const bit = (v) => (v === 1 ? 1 : 0)
|
||||
const b0 = bit(c.doorMag)
|
||||
const b1 = bit(c.irHall)
|
||||
const b2 = bit(c.bathRadar)
|
||||
const b3 = bit(c.bathroomRadar)
|
||||
const P6 = (b0 | (b1 << 1) | (b2 << 2) | (b3 << 3)) & 0xFF
|
||||
const P7 = 0x00
|
||||
const P8 = 0x00
|
||||
const P9 = 0x00
|
||||
const P10 = 0x00
|
||||
const toLE = [c.timeout & 0xFF, (c.timeout >>> 8) & 0xFF]
|
||||
const P13 = c.timeoutUnit & 0xFF
|
||||
const payload = [P0, P1, P2, ...jtLE, P5, P6, P7, P8, P9, P10, ...toLE, P13]
|
||||
const pkt = buildCommand(COMMANDS.SET_CONDITION_1, payload, { frame: i + 1, framNum: flat.length })
|
||||
this.appendLog('TX', `条件配置[组${c.group}/序${c.seq}]: ${this.toHex(pkt)}`)
|
||||
})
|
||||
wx.showToast({ title: '条件配置已发送', icon: 'success' })
|
||||
},
|
||||
|
||||
// 功能栏示例事件
|
||||
onDeleteCondGroup() { this.appendLog('UI', '操作: 删除条件组'); wx.showToast({ title: '删除条件组', icon: 'none' }) },
|
||||
onDeleteCondition() { this.appendLog('UI', '操作: 删除条件'); wx.showToast({ title: '删除条件', icon: 'none' }) },
|
||||
onAddCondGroup() { this.appendLog('UI', '操作: 添加条件组'); wx.showToast({ title: '添加条件组', icon: 'none' }) },
|
||||
onAddCondition() { this.appendLog('UI', '操作: 添加条件'); wx.showToast({ title: '添加条件', icon: 'none' }) },
|
||||
onExport() { this.appendLog('UI', '操作: 导出'); wx.showToast({ title: '导出', icon: 'none' }) },
|
||||
|
||||
onImport() {
|
||||
const now = new Date()
|
||||
const stamp = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
|
||||
this.setData({ importFileName: `已导入 ${stamp}` })
|
||||
this.appendLog('UI', '操作: 导入')
|
||||
wx.showToast({ title: '导入', icon: 'none' })
|
||||
},
|
||||
|
||||
// 一键下发:同时下发端口配置与条件配置
|
||||
onOneKeySend() {
|
||||
try {
|
||||
// 先发送端口配置
|
||||
this.onSavePorts()
|
||||
// 再发送条件配置
|
||||
this.onSaveConditions()
|
||||
wx.showToast({ title: '已一键下发', icon: 'success' })
|
||||
this.appendLog('UI', '操作: 一键下发')
|
||||
} catch (err) {
|
||||
wx.showToast({ title: '下发失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
onCheckboxChange(e) {
|
||||
const key = e.currentTarget.dataset.key
|
||||
if (!key) return
|
||||
const checked = (e.detail.value || []).includes(key)
|
||||
this.setData({ [key]: checked })
|
||||
},
|
||||
|
||||
onInputChange(e) {
|
||||
this.setData({ sendText: e.detail.value })
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送蓝牙数据
|
||||
*
|
||||
* 处理用户输入并发送到蓝牙设备:
|
||||
* 1. 检查输入内容是否为空
|
||||
* 2. 根据HEX发送模式转换数据格式
|
||||
* 3. 记录发送日志
|
||||
* 4. 通过蓝牙发送数据
|
||||
*
|
||||
* @function onSend
|
||||
* @memberof B13page
|
||||
* @description 发送数据到已连接的蓝牙设备
|
||||
*/
|
||||
onSend() {
|
||||
|
||||
|
||||
|
||||
const content = this.data.sendText.trim()
|
||||
if (!content) {
|
||||
wx.showToast({ title: '请输入内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
let viewText = content
|
||||
let bytes = null
|
||||
if (this.data.hexSend) {
|
||||
const u8 = this.hexToBytes(content)
|
||||
if (!u8) {
|
||||
wx.showToast({ title: 'HEX格式错误', icon: 'none' })
|
||||
return
|
||||
}
|
||||
bytes = u8
|
||||
viewText = this.toHex(u8)
|
||||
} else {
|
||||
const text = this.data.wrapCRLF ? (content + '\r\n') : content
|
||||
bytes = this.strToBytes(text)
|
||||
viewText = text
|
||||
}
|
||||
|
||||
// 记录日志(按显示偏好)
|
||||
const show = this.data.hexShow && bytes ? this.toHex(bytes) : viewText
|
||||
this.appendLog('TX', show)
|
||||
this.writeBleBytes(bytes, '发送')
|
||||
|
||||
this.setData({ sendText: '' })
|
||||
},
|
||||
|
||||
|
||||
|
||||
onClearLogs() {
|
||||
this.setData({ logList: [] })
|
||||
},
|
||||
|
||||
/**
|
||||
* 追加一条日志到页面列表
|
||||
* - 可选带时间戳前缀(withTimestamp)
|
||||
* - 头插方式存储,最新在前
|
||||
*/
|
||||
appendLog(direction, text) {
|
||||
const now = new Date()
|
||||
const timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
|
||||
const finalText = this.data.withTimestamp ? `[${timeStr}] ${direction}: ${text}` : `${direction}: ${text}`
|
||||
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const next = [{ id, time: timeStr, text: finalText }, ...this.data.logList]
|
||||
this.setData({ logList: next })
|
||||
}
|
||||
})
|
||||
8
pages/basics/BluetoothDebugging/B13page/B13page.json
Normal file
8
pages/basics/BluetoothDebugging/B13page/B13page.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"navigationBarBackgroundColor": "#fff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"cu-custom": "/colorui/components/cu-custom"
|
||||
}
|
||||
}
|
||||
296
pages/basics/BluetoothDebugging/B13page/B13page.wxml
Normal file
296
pages/basics/BluetoothDebugging/B13page/B13page.wxml
Normal file
@@ -0,0 +1,296 @@
|
||||
<!-- pages/basics/BluetoothDebugging/B13page/B13page.wxml -->
|
||||
<view class="container">
|
||||
<cu-custom bgColor="bg-gradual-blue" isBack="true">
|
||||
<view slot="content">{{DevName || 'B13设备'}}</view>
|
||||
</cu-custom>
|
||||
|
||||
<!-- 顶部标签:参考主机升级页面的切换方式 -->
|
||||
<scroll-view scroll-x class="bg-white nav text-center text-bold text-xl">
|
||||
<view class="flex text-center">
|
||||
<view class="cu-item {{1==TabCur?'text-blue cur':''}}" bindtap="tabSelect" data-id="1">蓝牙调试</view>
|
||||
<view class="cu-item {{2==TabCur?'text-blue cur':''}}" bindtap="tabSelect" data-id="2">蓝牙升级</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- Tab: 蓝牙调试 -->
|
||||
<view wx:if="{{TabCur==1}}" class="content">
|
||||
<!-- 顶部设备分类 -->
|
||||
<view class="grid">
|
||||
<view class="grid-item" wx:for="{{radarLights}}" wx:key="key">
|
||||
<view class="circle {{item.colorClass}}"></view>
|
||||
<text class="label">{{item.label}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 状态功能块 -->
|
||||
<view class="cards">
|
||||
<view class="card">
|
||||
<view class="icon orange"></view>
|
||||
<text class="card-title">房间有人</text>
|
||||
</view>
|
||||
<view class="card">
|
||||
<view class="icon gray"></view>
|
||||
<text class="card-title">房间无人</text>
|
||||
</view>
|
||||
<view class="card">
|
||||
<view class="icon red"></view>
|
||||
<text class="card-title">门开</text>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<view class="icon green"></view>
|
||||
<text class="card-title">门关</text>
|
||||
</view>
|
||||
<view class="card">
|
||||
<view class="icon blue"></view>
|
||||
<text class="card-title">卫浴有人</text>
|
||||
</view>
|
||||
<view class="card">
|
||||
<view class="icon gray"></view>
|
||||
<text class="card-title">卫浴无人</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 延时滑块 -->
|
||||
<view class="slider-card">
|
||||
<view class="slider-head">
|
||||
<text class="title">开门延时</text>
|
||||
<text class="value">{{openDelay}}s</text>
|
||||
</view>
|
||||
<slider bindchange="onOpenDelayChange" value="{{openDelay}}" min="0" max="60" step="1"/>
|
||||
</view>
|
||||
|
||||
<view class="slider-card">
|
||||
<view class="slider-head">
|
||||
<text class="title">卫浴延时</text>
|
||||
<text class="value">{{bathDelay}}s</text>
|
||||
</view>
|
||||
<slider bindchange="onBathDelayChange" value="{{bathDelay}}" min="0" max="60" step="1"/>
|
||||
</view>
|
||||
|
||||
<!-- 通讯日志 -->
|
||||
<view class="log-card">
|
||||
<view class="log-head">
|
||||
<text class="title">通讯日志</text>
|
||||
<text class="action" bindtap="onClearLogs">清空</text>
|
||||
</view>
|
||||
|
||||
<view class="log-options">
|
||||
<checkbox-group class="option" bindchange="onCheckboxChange" data-key="hexSend">
|
||||
<label><checkbox value="hexSend" checked="{{hexSend}}" />HEX发送</label>
|
||||
</checkbox-group>
|
||||
<checkbox-group class="option" bindchange="onCheckboxChange" data-key="hexShow">
|
||||
<label><checkbox value="hexShow" checked="{{hexShow}}" />HEX显示</label>
|
||||
</checkbox-group>
|
||||
<checkbox-group class="option" bindchange="onCheckboxChange" data-key="withTimestamp">
|
||||
<label><checkbox value="withTimestamp" checked="{{withTimestamp}}" />时间戳</label>
|
||||
</checkbox-group>
|
||||
<checkbox-group class="option" bindchange="onCheckboxChange" data-key="wrapCRLF">
|
||||
<label><checkbox value="wrapCRLF" checked="{{wrapCRLF}}" />回车换行</label>
|
||||
</checkbox-group>
|
||||
</view>
|
||||
|
||||
<view class="log-input-row">
|
||||
<textarea class="log-input" placeholder="输入要发送的数据" value="{{sendText}}" bindinput="onInputChange" disable-default-padding="true" />
|
||||
<button class="send-btn" size="mini" type="primary" bindtap="onSend">发送</button>
|
||||
</view>
|
||||
|
||||
<scroll-view class="log-scroll" scroll-y="true">
|
||||
<block wx:if="{{logList.length > 0}}">
|
||||
<view class="log-item" wx:for="{{logList}}" wx:key="id">
|
||||
<text class="log-time">{{item.time}}</text>
|
||||
<text class="log-text">{{item.text}}</text>
|
||||
</view>
|
||||
</block>
|
||||
<view class="log-empty" wx:else>暂无日志记录</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Tab: 蓝牙升级 -->
|
||||
<view wx:if="{{TabCur==2}}" class="content">
|
||||
<view class="device-row">
|
||||
<view class="device-left">
|
||||
<view class="device-line">
|
||||
<text class="dr-label">蓝牙名称:</text>
|
||||
<text class="dr-value">{{bleName || '-'}}</text>
|
||||
</view>
|
||||
<view class="device-line">
|
||||
<text class="dr-label">MAC:</text>
|
||||
<text class="dr-value">{{bleMac || '-'}}</text>
|
||||
<text class="dr-label">版本:</text>
|
||||
<text class="dr-value">{{bleVersion || '-'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="dr-btn dr-btn-view" bindtap="onSendReadVersion">读取蓝牙信息</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能栏(保留导出/导入) -->
|
||||
<view class="cfg-card">
|
||||
<view class="toolbar">
|
||||
<view class="import-box">{{importFileName || '未选择文件'}}</view>
|
||||
<view class="toolbar-actions">
|
||||
<button size="mini" type="default" bindtap="onExport">导出文件</button>
|
||||
<button size="mini" type="default" bindtap="onImport">导入文件</button>
|
||||
<button size="mini" type="primary" bindtap="onOneKeySend">一键下发</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 端口配置表 -->
|
||||
<view class="cfg-card">
|
||||
<view class="cfg-head head-row">
|
||||
<text>端口配置</text>
|
||||
<view class="head-actions">
|
||||
<button size="mini" type="default" bindtap="onReadPorts">读取配置</button>
|
||||
<button size="mini" type="primary" bindtap="onSavePorts">保存配置</button>
|
||||
</view>
|
||||
</view>
|
||||
<view class="table header port">
|
||||
<view class="tr">
|
||||
<view class="th">端口</view>
|
||||
<view class="th">主机输入端口</view>
|
||||
<view class="th">无人至有人阈值</view>
|
||||
<view class="th">有人至无人阈值</view>
|
||||
<view class="th">启用</view>
|
||||
<view class="th">检测时间</view>
|
||||
<view class="th">检测时间单位</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="table body port">
|
||||
<view class="tr" wx:for="{{ports}}" wx:key="name">
|
||||
<view class="td">{{item.portLabel}}</view>
|
||||
<view class="td">
|
||||
<input class="picker-text" placeholder="输入端口" value="{{item.loop}}" type="number" bindinput="onPortNumberInput" data-idx="{{index}}" data-field="loop" />
|
||||
</view>
|
||||
<view class="td">
|
||||
<input class="picker-text" placeholder="无人→有人阈值" value="{{item.thresholdUp}}" type="number" bindinput="onPortNumberInput" data-idx="{{index}}" data-field="thresholdUp" />
|
||||
</view>
|
||||
<view class="td">
|
||||
<input class="picker-text" placeholder="有人→无人阈值" value="{{item.thresholdDown}}" type="number" bindinput="onPortNumberInput" data-idx="{{index}}" data-field="thresholdDown" />
|
||||
</view>
|
||||
<view class="td">
|
||||
<switch checked="{{item.enabled}}" bindchange="onPortSwitch" data-idx="{{index}}" data-field="enabled" />
|
||||
</view>
|
||||
<view class="td">
|
||||
<input class="picker-text" placeholder="检测时间" value="{{item.detectTime}}" type="number" bindinput="onPortNumberInput" data-idx="{{index}}" data-field="detectTime" />
|
||||
</view>
|
||||
<view class="td">
|
||||
<picker mode="selector" range="{{timeUnits}}" value="{{item.detectUnit}}" bindchange="onPortUnitChange" data-idx="{{index}}" data-field="detectUnit">
|
||||
<view class="picker-text">{{timeUnits[item.detectUnit]}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 条件配置(二级菜单:按组折叠) -->
|
||||
<view class="cfg-card cond-card">
|
||||
<view class="cfg-head head-row">
|
||||
<text>条件配置</text>
|
||||
<view class="head-actions">
|
||||
<button size="mini" type="warn" bindtap="onDeleteCondGroup">删除条件组</button>
|
||||
<button size="mini" type="warn" bindtap="onDeleteCondition">删除条件</button>
|
||||
<button size="mini" type="primary" bindtap="onAddCondGroup">添加条件组</button>
|
||||
<button size="mini" type="primary" bindtap="onAddCondition">添加条件</button>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view scroll-y class="cond-scroll">
|
||||
<view class="cond-groups">
|
||||
<view class="group-card" wx:for="{{condGroups}}" wx:key="group" wx:for-item="grp" wx:for-index="gidx">
|
||||
<view class="group-head head-row">
|
||||
<view class="group-info">
|
||||
<text class="group-title">条件组:</text>
|
||||
<text class="group-seq">{{grp.group}}</text>
|
||||
<view class="group-time">
|
||||
<text class="label">超时:</text>
|
||||
<picker class="timeout-picker" mode="selector" range="{{ grp.timeoutUnit === 0 ? hourValues : msValues }}" value="{{ (grp.timeout || 1) - 1 }}" bindchange="onGroupPickerChange" data-idx="{{gidx}}" data-field="timeout">
|
||||
<view class="picker-text">{{grp.timeout || 1}}</view>
|
||||
</picker>
|
||||
<text class="label">单位:</text>
|
||||
<picker class="unit-picker" mode="selector" range="{{timeUnits}}" value="{{grp.timeoutUnit}}" bindchange="onGroupPickerChange" data-idx="{{gidx}}" data-field="timeoutUnit">
|
||||
<view class="picker-text">{{timeUnits[grp.timeoutUnit]}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
<view class="head-actions">
|
||||
<button size="mini" type="default" bindtap="onToggleGroup" data-idx="{{gidx}}">{{grp.expanded?'收起':'展开'}}</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{grp.expanded}}">
|
||||
<view class="table body cond-item">
|
||||
<block wx:for="{{grp.items}}" wx:key="seq" wx:for-item="it" wx:for-index="iidx">
|
||||
<view class="cond-item-card">
|
||||
<!-- 第一行:序号(只读)、持续判定时间(下拉选择)、单位(下拉选择) -->
|
||||
<view class="tr tr-top">
|
||||
<view class="td">
|
||||
<text class="label">条件序号:</text>
|
||||
<text class="seq-value">{{it.seq}}</text>
|
||||
</view>
|
||||
<view class="td">
|
||||
<text class="label">持续判定时间:</text>
|
||||
<picker mode="selector" range="{{ it.judgeUnit === 0 ? hourValues : msValues }}" value="{{ (it.judgeTime || 1) - 1 }}" bindchange="onItemPickerChange" data-gidx="{{gidx}}" data-iidx="{{iidx}}" data-field="judgeTime">
|
||||
<view class="picker-text">{{it.judgeTime || 1}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="td">
|
||||
<text class="label">单位:</text>
|
||||
<picker mode="selector" range="{{timeUnits}}" value="{{it.judgeUnit}}" bindchange="onItemPickerChange" data-gidx="{{gidx}}" data-iidx="{{iidx}}" data-field="judgeUnit">
|
||||
<view class="picker-text">{{timeUnits[it.judgeUnit]}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 第二行:标题 -->
|
||||
<view class="tr tr-mid">
|
||||
<view class="th">有无人标记</view>
|
||||
<view class="th">有卡取电</view>
|
||||
<view class="th">开门磁</view>
|
||||
<view class="th">门口红外</view>
|
||||
<view class="th">卫红外</view>
|
||||
<view class="th">浴红外</view>
|
||||
</view>
|
||||
<!-- 第三行:对应值 -->
|
||||
<view class="tr tr-bottom">
|
||||
<view class="td">
|
||||
<picker mode="selector" range="{{tagOptions}}" value="{{it.tag}}" bindchange="onItemPickerChange" data-gidx="{{gidx}}" data-iidx="{{iidx}}" data-field="tag">
|
||||
<view class="picker-text">{{tagOptions[it.tag]}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="td">
|
||||
<picker mode="selector" range="{{stateOptions}}" value="{{it.cardPower || 0}}" bindchange="onItemPickerChange" data-gidx="{{gidx}}" data-iidx="{{iidx}}" data-field="cardPower">
|
||||
<view class="picker-text">{{stateOptions[it.cardPower || 0]}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="td">
|
||||
<picker mode="selector" range="{{stateOptions}}" value="{{it.doorMag}}" bindchange="onItemPickerChange" data-gidx="{{gidx}}" data-iidx="{{iidx}}" data-field="doorMag">
|
||||
<view class="picker-text">{{stateOptions[it.doorMag]}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="td">
|
||||
<picker mode="selector" range="{{stateOptions}}" value="{{it.irHall}}" bindchange="onItemPickerChange" data-gidx="{{gidx}}" data-iidx="{{iidx}}" data-field="irHall">
|
||||
<view class="picker-text">{{stateOptions[it.irHall]}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="td">
|
||||
<picker mode="selector" range="{{stateOptions}}" value="{{it.bathRadar}}" bindchange="onItemPickerChange" data-gidx="{{gidx}}" data-iidx="{{iidx}}" data-field="bathRadar">
|
||||
<view class="picker-text">{{stateOptions[it.bathRadar]}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="td">
|
||||
<picker mode="selector" range="{{stateOptions}}" value="{{it.bathroomRadar}}" bindchange="onItemPickerChange" data-gidx="{{gidx}}" data-iidx="{{iidx}}" data-field="bathroomRadar">
|
||||
<view class="picker-text">{{stateOptions[it.bathroomRadar]}}</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
117
pages/basics/BluetoothDebugging/B13page/B13page.wxss
Normal file
117
pages/basics/BluetoothDebugging/B13page/B13page.wxss
Normal file
@@ -0,0 +1,117 @@
|
||||
.cond-items { display:flex; flex-direction: column; gap: 10rpx; }
|
||||
.cond-item-card { background:#fff; border: 1rpx solid #edf0f5; border-radius: 12rpx; padding: 10rpx; }
|
||||
.item-head .item-top { display:flex; align-items:center; gap: 10rpx; flex-wrap: wrap; }
|
||||
.item-field { display:flex; align-items:center; gap: 6rpx; }
|
||||
.item-field .label { font-size: 24rpx; color:#606266; }
|
||||
.item-actions { display:flex; align-items:center; gap: 8rpx; }
|
||||
.table.cond-item-content .tr { display:grid; grid-template-columns: 1.1fr 1.1fr 1.1fr 1fr 1fr 1.1fr; gap: 6rpx; padding: 6rpx 0; }
|
||||
/* pages/basics/BluetoothDebugging/B13page/B13page.wxss */
|
||||
.container { background: #f5f6f8; min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.content { padding: 8rpx 10rpx 0rpx; box-sizing: border-box; display: flex; flex-direction: column; gap: 8rpx; flex: 1; }
|
||||
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10rpx; margin-bottom: 12rpx; }
|
||||
.grid-item { background: #fff; border-radius: 14rpx; padding: 10rpx 0; display: flex; flex-direction: column; align-items: center; }
|
||||
.circle { width: 40rpx; height: 40rpx; border-radius: 50%; background: #e9ecef; margin-bottom: 6rpx; }
|
||||
.circle.green { background: #21c161; }
|
||||
.circle.red { background: #ff3b30; }
|
||||
.circle.gray { background: #e9ecef; }
|
||||
.label { font-size: 24rpx; color: #606266; }
|
||||
|
||||
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8rpx; margin-bottom: 10rpx; }
|
||||
.card { background: #fff; border-radius: 14rpx; padding: 14rpx 6rpx; display: flex; flex-direction: column; align-items: center; }
|
||||
.icon { width: 30rpx; height: 30rpx; border-radius: 8rpx; margin-bottom: 4rpx; }
|
||||
.icon.orange { background: #ff8f00; }
|
||||
.icon.red { background: #ff3b30; }
|
||||
.icon.green { background: #21c161; }
|
||||
.icon.blue { background: #0ea5e9; }
|
||||
.icon.gray { background: #9aa0a6; }
|
||||
.card-title { font-size: 24rpx; color: #555; }
|
||||
|
||||
.slider-card { background: #fff; border-radius: 14rpx; padding: 12rpx; box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.04); margin-bottom: 10rpx; }
|
||||
.slider-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6rpx; }
|
||||
.slider-head .title { font-size: 26rpx; color: #333; }
|
||||
.slider-head .value { font-size: 24rpx; color: #21c161; }
|
||||
|
||||
.log-card { background: #fff; border-radius: 14rpx; padding: 14rpx; box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.04); display: flex; flex-direction: column; gap: 8rpx; flex: 1; }
|
||||
.log-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
|
||||
.log-head .title { font-size: 26rpx; color: #333; }
|
||||
.log-head .action { font-size: 24rpx; color: #2bab99; }
|
||||
|
||||
.log-options { display: flex; flex-wrap: nowrap; gap: 14rpx; }
|
||||
.log-options .option { display: flex; align-items: center; font-size: 24rpx; color: #555; white-space: nowrap; }
|
||||
.log-options checkbox { margin-right: 10rpx; transform: scale(0.9); }
|
||||
|
||||
.log-input-row { display: flex; align-items: flex-end; gap: 8rpx; }
|
||||
.log-input { flex: 1; background: #f5f6f8; border-radius: 12rpx; padding: 10rpx 12rpx; font-size: 24rpx; border: 1rpx solid #e5e9f2; height: 100rpx; line-height: 34rpx; box-sizing: border-box; white-space: pre-wrap; word-break: break-all; }
|
||||
.send-btn { padding: 0 22rpx; height: 56rpx; line-height: 56rpx; border-radius: 12rpx; }
|
||||
|
||||
.log-scroll { flex: 1; border: 1rpx solid #e5e9f2; border-radius: 12rpx; padding: 12rpx; background: #fdfdfd; box-sizing: border-box; min-height: 200rpx; max-height: 42vh; }
|
||||
|
||||
/* 设备信息行 */
|
||||
.device-row { display:flex; align-items:flex-start; justify-content: space-between; gap: 12rpx; background:#fff; border-radius: 14rpx; padding: 10rpx 12rpx; box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.04); flex-wrap: wrap; row-gap: 6rpx; }
|
||||
.device-left { display:flex; flex-direction: column; gap: 6rpx; flex: 1; min-width: 60%; }
|
||||
.device-line { display:flex; align-items:center; gap: 8rpx; flex-wrap: wrap; }
|
||||
.device-row .dr-label { color:#606266; font-size: 24rpx; }
|
||||
.device-row .dr-value { color:#333; font-size: 26rpx; }
|
||||
.dr-btn { white-space: nowrap; margin-left: auto; height: 84rpx; line-height: 84rpx; padding: 0 28rpx; display: flex; align-items: center; justify-content: center; text-align: center; flex-shrink: 0; align-self: stretch; }
|
||||
.dr-btn-view { background: #0ea5e9; color: #ffffff; border-radius: 12rpx; box-shadow: 0 2rpx 6rpx rgba(14,165,233,0.35); }
|
||||
.dr-btn-view:active { opacity: 0.85; }
|
||||
|
||||
/* 配置表样式 */
|
||||
.cfg-card { background:#fff; border-radius: 14rpx; padding: 12rpx; box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.04); }
|
||||
.cfg-head { font-size: 26rpx; color:#333; margin-bottom: 10rpx; }
|
||||
.head-row { display:flex; align-items:center; justify-content: space-between; gap: 12rpx; }
|
||||
.head-actions { display:flex; gap: 10rpx; }
|
||||
.cond-card .head-actions { gap: 8rpx; }
|
||||
.cond-card .head-actions button { padding: 0 14rpx; height: 48rpx; line-height: 48rpx; }
|
||||
.toolbar { display:flex; flex-wrap: wrap; gap: 8rpx; align-items: center; justify-content: space-between; }
|
||||
.toolbar .import-box { min-width: 260rpx; padding: 6rpx 10rpx; border: 1rpx solid #e5e9f2; border-radius: 10rpx; background:#f7f8fa; font-size: 22rpx; color:#606266; }
|
||||
.toolbar .toolbar-actions { display:flex; align-items:center; gap: 6rpx; }
|
||||
.toolbar .toolbar-actions button { padding: 0 12rpx; }
|
||||
.table.header, .table.body { display:flex; flex-direction: column; gap: 6rpx; }
|
||||
.table.port .tr { display:grid; grid-template-columns: 1.4fr 1.2fr 1.4fr 1.4fr 0.8fr 1.1fr 1fr; gap: 6rpx; padding: 6rpx 0; }
|
||||
.table.body.port { gap: 2rpx; }
|
||||
.table.body.port .tr { gap: 4rpx; padding: 2rpx 0; }
|
||||
.table.cond-item .tr-top { display:grid; grid-template-columns: 0.8fr 1fr 0.8fr; gap: 4rpx; padding: 2rpx 0; }
|
||||
.cond-card .table.cond-item .tr-top .td { display:flex; align-items:center; gap: 6rpx; flex-wrap: nowrap; }
|
||||
.cond-card .table.cond-item .tr-top .td .label { white-space: nowrap; }
|
||||
.cond-card .table.cond-item .tr-top .td .picker-text { flex: none; white-space: nowrap; }
|
||||
.cond-card .table.cond-item .tr-top .td { min-width: 0; }
|
||||
.cond-card .table.cond-item .tr-top .td .label { font-size: 22rpx; }
|
||||
.cond-card .table.cond-item .tr-top .td:first-child .label { font-weight: 600; color:#8B4513; }
|
||||
.cond-card .table.cond-item .tr-top .td .picker-text { height: 40rpx; padding: 0 6rpx; font-size: 22rpx; }
|
||||
.cond-card .table.cond-item .tr-top .td:first-child .picker-text { width: 90rpx; text-align: center; }
|
||||
.cond-card .table.cond-item .tr-top .td:nth-child(2) .picker-text { width: 95rpx; }
|
||||
.cond-card .table.cond-item .tr-top .td:nth-child(3) .picker-text { width: 100rpx; }
|
||||
.cond-card .seq-value { font-size: 24rpx; font-weight: 600; color: #8B4513; }
|
||||
.table.cond-item .tr-mid { display:grid; grid-template-columns: 1.5fr minmax(100rpx,0.9fr) minmax(100rpx,0.9fr) 1fr 1fr 1fr; gap: 6rpx; padding: 4rpx 0; }
|
||||
.table.cond-item .tr-bottom { display:grid; grid-template-columns: 1.5fr minmax(100rpx,0.9fr) minmax(100rpx,0.9fr) 1fr 1fr 1fr; gap: 6rpx; padding: 6rpx 0; }
|
||||
.cond-card .table.cond-item .tr-mid .th:first-child { white-space: nowrap; }
|
||||
.cond-card .table.cond-item .tr-bottom .td:first-child .picker-text { white-space: nowrap; }
|
||||
.table .th, .table .td { font-size: 24rpx; color:#555; }
|
||||
.table .th.name, .table .td.name { color:#333; }
|
||||
.picker-text { height: 48rpx; padding: 0 12rpx; border: 1rpx solid #e5e9f2; border-radius: 10rpx; background:#f7f8fa; display: flex; align-items: center; }
|
||||
.cond-card .picker-text { height: 44rpx; padding: 0 8rpx; }
|
||||
.picker-text.readonly { background: #f0f1f3; font-weight: 600; }
|
||||
.input.picker-text { height: 48rpx; line-height: 48rpx; padding: 0 12rpx; }
|
||||
.cond-card input.picker-text { height: 44rpx; line-height: 44rpx; padding: 0 8rpx; }
|
||||
.td.group-seq { display:flex; align-items:center; gap: 6rpx; }
|
||||
.td.group-seq .picker-text { flex: 1; }
|
||||
.td.group-seq .sep { color:#9aa0a6; }
|
||||
/* 条件组样式 */
|
||||
.cond-groups { display:flex; flex-direction: column; gap: 10rpx; }
|
||||
.cond-scroll { max-height: 680rpx; border: 1rpx solid #e5e9f2; border-radius: 12rpx; padding: 6rpx; box-sizing: border-box; background: #fff; }
|
||||
.group-card { background:#fafafa; border: 1rpx solid #edf0f5; border-radius: 12rpx; padding: 10rpx; }
|
||||
.group-head .group-info { display:flex; align-items:center; gap: 12rpx; }
|
||||
.group-title { font-size: 24rpx; color:#1e3a8a; font-weight: 600; }
|
||||
.group-seq { font-size: 24rpx; color:#1e3a8a; font-weight: 600; }
|
||||
.group-time { display:flex; align-items:center; gap: 8rpx; }
|
||||
.cond-card .group-time .label { white-space: nowrap; font-size: 22rpx; }
|
||||
.cond-card .group-time .timeout-picker .picker-text { width: 120rpx; height: 44rpx; padding: 0 8rpx; font-size: 22rpx; text-align: center; white-space: nowrap; }
|
||||
.cond-card .group-time .unit-picker .picker-text { width: 100rpx; height: 44rpx; padding: 0 8rpx; font-size: 22rpx; white-space: nowrap; }
|
||||
.cfg-actions { display:flex; justify-content:flex-end; margin-top: 10rpx; }
|
||||
.action-row { display:flex; flex-wrap: wrap; gap: 12rpx; }
|
||||
.log-item { margin-bottom: 10rpx; }
|
||||
.log-item:last-child { margin-bottom: 0; }
|
||||
.log-time { display: block; font-size: 22rpx; color: #9aa0a6; margin-bottom: 4rpx; }
|
||||
.log-text { display: block; font-size: 24rpx; color: #333; word-break: break-all; }
|
||||
.log-empty { font-size: 24rpx; color: #9aa0a6; text-align: center; padding: 20rpx 0; }
|
||||
535
pages/basics/BluetoothDebugging/BluetoothDebugging.js
Normal file
535
pages/basics/BluetoothDebugging/BluetoothDebugging.js
Normal file
@@ -0,0 +1,535 @@
|
||||
// pages/bluetooth-connect/bluetooth-connect.js
|
||||
const app = getApp()
|
||||
|
||||
const FIXED_CONNECT_CMD = new Uint8Array([0xCC, 0xC0, 0x0C, 0x00, 0x4F, 0xC0, 0x01, 0x00, 0x02, 0x00, 0x0C, 0x08])
|
||||
|
||||
Page({
|
||||
data: {
|
||||
ConnectedDevName:"",
|
||||
activeTab: 'W13', // 默认选中W13
|
||||
autho: null,
|
||||
Hotelinfo: {},
|
||||
deviceList: [],
|
||||
currentDeviceId: null,
|
||||
coid:0
|
||||
},
|
||||
|
||||
// 返回上一页
|
||||
goBack() {
|
||||
// 返回前主动断开当前BLE连接,避免连接遗留
|
||||
try {
|
||||
this.disconnectCurrentDevice()
|
||||
} catch (e) {}
|
||||
wx.navigateBack()
|
||||
},
|
||||
|
||||
// 切换导航选项卡
|
||||
switchTab(e) {
|
||||
const tab = e.currentTarget.dataset.tab
|
||||
const current = this.data.activeTab
|
||||
if (tab === current) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasConnected = this.data.deviceList.some(d => d.connected)
|
||||
if (hasConnected) {
|
||||
wx.showModal({
|
||||
title: '提示',
|
||||
content: '当前有已连接的蓝牙设备,切换将断开并重新搜索,是否继续?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.disconnectAllDevices()
|
||||
this.setData({ activeTab: tab })
|
||||
// 切换后立即搜索一次
|
||||
this.searchBluetooth()
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.setData({ activeTab: tab })
|
||||
this.searchBluetooth()
|
||||
}
|
||||
},
|
||||
|
||||
disconnectAllDevices() {
|
||||
const list = this.data.deviceList.map(d => ({ ...d, connected: false }))
|
||||
this.setData({ deviceList: list })
|
||||
},
|
||||
|
||||
// 根据选项卡加载设备数据
|
||||
loadDevicesByTab(tab) {
|
||||
// 这里可以根据tab从服务器获取对应的设备列表
|
||||
console.log('加载设备列表,选项卡:', tab)
|
||||
|
||||
// 模拟不同选项卡的设备数据
|
||||
let deviceList = []
|
||||
if (tab === 'host') {
|
||||
deviceList = [
|
||||
{
|
||||
id: 1,
|
||||
name: '主机设备1',
|
||||
signal: 95,
|
||||
connected: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '主机设备2',
|
||||
signal: 80,
|
||||
connected: false
|
||||
}
|
||||
]
|
||||
} else {
|
||||
deviceList = this.data.deviceList
|
||||
}
|
||||
|
||||
this.setData({ deviceList })
|
||||
},
|
||||
|
||||
// 搜索蓝牙设备
|
||||
searchBluetooth() {
|
||||
const filterPrefix = this.data.activeTab
|
||||
|
||||
wx.showLoading({
|
||||
title: '搜索中...',
|
||||
mask: true
|
||||
})
|
||||
|
||||
// 先断开当前连接设备(如果有)
|
||||
this.disconnectCurrentDevice()
|
||||
|
||||
// 清空旧列表并启动搜索
|
||||
this.setData({ deviceList: [] })
|
||||
|
||||
this.ensureBluetoothReady()
|
||||
.then(() => this.startBluetoothDevicesDiscovery(filterPrefix))
|
||||
.catch((err) => {
|
||||
console.error('蓝牙初始化失败', err)
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '请开启蓝牙和定位权限', icon: 'none' })
|
||||
})
|
||||
},
|
||||
|
||||
ensureBluetoothReady() {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.openBluetoothAdapter({
|
||||
mode: 'central',
|
||||
success: () => {
|
||||
resolve()
|
||||
},
|
||||
fail: (err) => {
|
||||
// 10001 系统蓝牙未打开;10002 无权限
|
||||
if (err && err.errCode === 10001) {
|
||||
wx.showModal({
|
||||
title: '蓝牙未开启',
|
||||
content: '请先打开手机蓝牙后重试',
|
||||
showCancel: false
|
||||
})
|
||||
} else {
|
||||
wx.showModal({
|
||||
title: '权限提示',
|
||||
content: '请授权蓝牙与定位权限后重试',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
startBluetoothDevicesDiscovery(prefix) {
|
||||
// 先取消旧的发现监听,避免多次注册造成干扰
|
||||
this.teardownDeviceFoundListener()
|
||||
this._foundCount = 0
|
||||
console.info('[BLE] start scan, prefix:', prefix || 'ALL')
|
||||
// 先停止可能已有的搜索,待停止完成后再启动,避免竞态
|
||||
wx.stopBluetoothDevicesDiscovery({
|
||||
complete: () => {
|
||||
wx.startBluetoothDevicesDiscovery({
|
||||
allowDuplicatesKey: true, // 允许重复上报,提升二次搜索的发现率
|
||||
success: () => {
|
||||
this.setupDeviceFoundListener(prefix)
|
||||
// 定时停止,避免长时间占用
|
||||
setTimeout(() => {
|
||||
this.stopBluetoothDiscovery()
|
||||
}, 6000)
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('开始搜索蓝牙设备失败', err)
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '搜索失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
setupDeviceFoundListener(prefix) {
|
||||
this._deviceFoundHandler = (res) => {
|
||||
const devices = (res && res.devices) || []
|
||||
if (devices.length) this._foundCount = (this._foundCount || 0) + devices.length
|
||||
this.handleDeviceFound(devices, prefix)
|
||||
}
|
||||
if (typeof wx.onBluetoothDeviceFound === 'function') {
|
||||
wx.onBluetoothDeviceFound(this._deviceFoundHandler)
|
||||
}
|
||||
},
|
||||
|
||||
teardownDeviceFoundListener() {
|
||||
if (this._deviceFoundHandler && typeof wx.offBluetoothDeviceFound === 'function') {
|
||||
wx.offBluetoothDeviceFound(this._deviceFoundHandler)
|
||||
}
|
||||
this._deviceFoundHandler = null
|
||||
},
|
||||
|
||||
handleDeviceFound(devices, prefix) {
|
||||
const list = [...this.data.deviceList]
|
||||
devices.forEach((dev) => {
|
||||
const name = dev.name || dev.localName || ''
|
||||
if (!name) return
|
||||
const isW13 = this.data.activeTab === 'W13'
|
||||
const matched = isW13 ? /^BLV_(W13|C13)_.+$/i.test(name) : (prefix ? name.startsWith(prefix) : true)
|
||||
if (!matched) return
|
||||
|
||||
const existsIndex = list.findIndex((d) => d.id === dev.deviceId)
|
||||
const rssi = dev.RSSI || 0
|
||||
const signal = Math.max(0, Math.min(100, 100 + rssi))
|
||||
const mapped = {
|
||||
id: dev.deviceId,
|
||||
name,
|
||||
mac: dev.deviceId,
|
||||
signal,
|
||||
connected: false,
|
||||
RSSI: rssi,
|
||||
localName: dev.localName || '',
|
||||
serviceUUIDs: dev.serviceUUIDs || []
|
||||
}
|
||||
|
||||
if (existsIndex >= 0) {
|
||||
list[existsIndex] = { ...list[existsIndex], ...mapped }
|
||||
} else {
|
||||
list.push(mapped)
|
||||
}
|
||||
})
|
||||
|
||||
this.setData({ deviceList: list })
|
||||
},
|
||||
|
||||
// 停止蓝牙搜索
|
||||
stopBluetoothDiscovery() {
|
||||
wx.stopBluetoothDevicesDiscovery({
|
||||
complete: () => {
|
||||
console.info('[BLE] stop scan, found events:', this._foundCount || 0, 'list size:', this.data.deviceList.length)
|
||||
wx.hideLoading()
|
||||
const count = this.data.deviceList.length
|
||||
wx.showToast({ title: `发现${count}个设备`, icon: 'success', duration: 1500 })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
// 页面卸载时清理蓝牙扫描与监听
|
||||
// this.teardownDeviceFoundListener()
|
||||
if (typeof wx.stopBluetoothDevicesDiscovery === 'function') {
|
||||
wx.stopBluetoothDevicesDiscovery({ complete: () => {} })
|
||||
}
|
||||
if (this._fixedLoopTimer) {
|
||||
clearInterval(this._fixedLoopTimer)
|
||||
this._fixedLoopTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
// 连接设备
|
||||
onDeviceTap(e) {
|
||||
const index = e.currentTarget.dataset.index
|
||||
const device = this.data.deviceList[index]
|
||||
let coid= this.data.coid
|
||||
if (!device) return
|
||||
|
||||
const currentIndex = this.data.deviceList.findIndex(d => d.connected)
|
||||
|
||||
// 如果点击的就是已连接设备,直接进入对应页面并携带已保存的BLE参数
|
||||
if (currentIndex === index && currentIndex >= 0) {
|
||||
if (this.data.activeTab === 'W13') {
|
||||
const devName = device.name || 'W13设备'
|
||||
const mac = this.data.currentDeviceId || device.id || ''
|
||||
const svc = this.data.currentServiceId || device.serviceId || ''
|
||||
const tx = this.data.currentTxCharId || device.txCharId || ''
|
||||
const rx = this.data.currentRxCharId || device.rxCharId || ''
|
||||
// 至少携带 mac;若已持有 svc/tx/rx 则一并带上,避免重复发现
|
||||
const base = `/pages/basics/BluetoothDebugging/B13page/B13page?DevName=${encodeURIComponent(devName)}&mac=${encodeURIComponent(mac)}`
|
||||
const withParams = (svc && tx && rx)
|
||||
? `${base}&serviceId=${encodeURIComponent(svc)}&txCharId=${encodeURIComponent(tx)}&rxCharId=${encodeURIComponent(rx)}`
|
||||
: base
|
||||
console.log(url)
|
||||
console.log(withParams)
|
||||
wx.navigateTo({ url: withParams })
|
||||
|
||||
|
||||
|
||||
} else {
|
||||
wx.showToast({ title: '已连接当前设备', icon: 'none' })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (currentIndex >= 0 && currentIndex !== index) {
|
||||
wx.showModal({
|
||||
title: '切换设备',
|
||||
content: '已连接其他设备,是否切换到当前设备?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.connectToDevice(index)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (currentIndex < 0) {
|
||||
wx.showModal({
|
||||
title: '连接设备',
|
||||
content: '是否连接此蓝牙设备?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.connectToDevice(index)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.connectToDevice(index)
|
||||
}
|
||||
},
|
||||
|
||||
connectToDevice(index) {
|
||||
const device = this.data.deviceList[index]
|
||||
if (!device || !device.id) {
|
||||
wx.showToast({ title: '设备信息缺失', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
wx.showLoading({ title: '连接中...', mask: true })
|
||||
// 使用 BLE 直连,不再使用模拟延迟
|
||||
wx.createBLEConnection({
|
||||
deviceId: device.id,
|
||||
success: () => {
|
||||
const list = this.data.deviceList.map((d, i) => ({ ...d, connected: i === index }))
|
||||
this.setData({ deviceList: list, currentDeviceId: device.id })
|
||||
// 设置MTU为256,提升传输效率
|
||||
if (typeof wx.setBLEMTU === 'function') {
|
||||
wx.setBLEMTU({
|
||||
deviceId: device.id,
|
||||
mtu: 500,
|
||||
fail: () => console.warn('[BLE] set MTU 256 failed'),
|
||||
success: () => console.info('[BLE] set MTU 256 success')
|
||||
})
|
||||
}
|
||||
// 连接成功后发现服务与特征
|
||||
this.discoverBleChannels(device)
|
||||
},
|
||||
fail: (err) => {
|
||||
wx.hideLoading()
|
||||
console.error('BLE 连接失败', err)
|
||||
wx.showToast({ title: '连接失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 发现包含 FFE1(写) / FFE2(订阅) 的服务与特征,并启用 FFE2 通知
|
||||
discoverBleChannels(device) {
|
||||
const deviceId = device.id
|
||||
wx.getBLEDeviceServices({
|
||||
deviceId,
|
||||
success: (srvRes) => {
|
||||
const services = srvRes.services || []
|
||||
if (!services.length) {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '未发现服务', icon: 'none' })
|
||||
return
|
||||
}
|
||||
let found = false
|
||||
let pending = services.length
|
||||
// 优先自定义/未知服务(UUID 含 FFE)其余按原顺序
|
||||
const score = (s) => {
|
||||
const u = (s.uuid || '').toUpperCase()
|
||||
return u.includes('FFE') ? 2 : (s.isPrimary === true ? 1 : 0)
|
||||
}
|
||||
const sorted = services.slice().sort((a, b) => score(b) - score(a))
|
||||
sorted.forEach(s => {
|
||||
const serviceId = s.uuid
|
||||
wx.getBLEDeviceCharacteristics({
|
||||
deviceId,
|
||||
serviceId,
|
||||
success: (chRes) => {
|
||||
const chars = chRes.characteristics || []
|
||||
const ffe1 = chars.find(c => this._matchUuid(c.uuid, 'FFE1'))
|
||||
const ffe2 = chars.find(c => this._matchUuid(c.uuid, 'FFE2'))
|
||||
if (!found && ffe1 && ffe2) {
|
||||
found = true
|
||||
// 启用FFE2通知
|
||||
wx.notifyBLECharacteristicValueChange({
|
||||
state: true,
|
||||
deviceId,
|
||||
serviceId,
|
||||
characteristicId: ffe2.uuid,
|
||||
complete: () => {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '连接成功', icon: 'success' })
|
||||
// 保存当前服务与特征,供已连接设备直接进入页面使用
|
||||
this.setData({
|
||||
currentServiceId: serviceId,
|
||||
currentTxCharId: ffe1.uuid,
|
||||
currentRxCharId: ffe2.uuid
|
||||
})
|
||||
// 连接成功后发送指定命令帧
|
||||
// this.sendFixedCommand(deviceId, serviceId, ffe1.uuid)
|
||||
|
||||
if (this.data.activeTab === 'W13') {
|
||||
const devName = device.name || 'W13设备'
|
||||
const url = `/pages/basics/BluetoothDebugging/B13page/B13page?DevName=${encodeURIComponent(devName)}&mac=${encodeURIComponent(deviceId)}&serviceId=${encodeURIComponent(serviceId)}&txCharId=${encodeURIComponent(ffe1.uuid)}&rxCharId=${encodeURIComponent(ffe2.uuid)}`
|
||||
console.log(url)
|
||||
wx.navigateTo({ url })
|
||||
// this.sendFixedCommand(deviceId, serviceId, ffe1.uuid)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 不中断流程,继续其他服务
|
||||
},
|
||||
complete: () => {
|
||||
pending -= 1
|
||||
if (!found && pending === 0) {
|
||||
wx.hideLoading()
|
||||
wx.showModal({ title: '提示', content: '未找到FFE1/FFE2特征', showCancel: false })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
fail: (err) => {
|
||||
wx.hideLoading()
|
||||
console.error('获取服务失败', err)
|
||||
wx.showToast({ title: '获取服务失败', icon: 'none' })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
_matchUuid(uuid, needle) {
|
||||
if (!uuid || !needle) return false
|
||||
const u = String(uuid).replace(/-/g, '').toUpperCase()
|
||||
const n = String(needle).toUpperCase().replace(/^0X/, '')
|
||||
// 模糊匹配:包含指定段即可(兼容16/128位 UUID)
|
||||
return u.includes(n)
|
||||
},
|
||||
|
||||
sendFixedCommand(deviceId, serviceId, txCharId) {
|
||||
if (!deviceId || !serviceId || !txCharId || typeof wx.writeBLECharacteristicValue !== 'function') return
|
||||
console.info(`[BLE] sendFixedCommand params device=${deviceId} svc=${serviceId} tx=${txCharId}`)
|
||||
try {
|
||||
wx.writeBLECharacteristicValue({
|
||||
deviceId,
|
||||
serviceId,
|
||||
characteristicId: txCharId,
|
||||
value: FIXED_CONNECT_CMD.buffer,
|
||||
complete: () => {
|
||||
console.info(`[BLE] sent fixed cmd device=${deviceId} svc=${serviceId} tx=${txCharId}`)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[BLE] send fixed cmd failed', e)
|
||||
}
|
||||
},
|
||||
|
||||
// 测试函数:每5秒发送一次固定命令
|
||||
startFixedCmdLoop() {
|
||||
const deviceId = this.data.currentDeviceId
|
||||
const serviceId = this.data.currentServiceId
|
||||
const txCharId = this.data.currentTxCharId
|
||||
if (!deviceId || !serviceId || !txCharId) {
|
||||
wx.showToast({ title: '未连接BLE', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (this._fixedLoopTimer) {
|
||||
clearInterval(this._fixedLoopTimer)
|
||||
this._fixedLoopTimer = null
|
||||
}
|
||||
const sendOnce = () => {
|
||||
try {
|
||||
wx.writeBLECharacteristicValue({
|
||||
deviceId,
|
||||
serviceId,
|
||||
characteristicId: txCharId,
|
||||
value: FIXED_CONNECT_CMD.buffer,
|
||||
fail: (err) => {
|
||||
console.warn('[BLE] loop fixed cmd fail', err && (err.errMsg || err.message) || err)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('[BLE] loop fixed cmd exception', e && (e.errMsg || e.message) || e)
|
||||
}
|
||||
}
|
||||
sendOnce()
|
||||
this._fixedLoopTimer = setInterval(sendOnce, 5000)
|
||||
wx.showToast({ title: '已启动固定命令循环', icon: 'none' })
|
||||
},
|
||||
|
||||
// 可选:停止循环发送
|
||||
stopFixedCmdLoop() {
|
||||
if (this._fixedLoopTimer) {
|
||||
clearInterval(this._fixedLoopTimer)
|
||||
this._fixedLoopTimer = null
|
||||
wx.showToast({ title: '已停止循环', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
// 断开当前连接设备(如果有真实连接)
|
||||
disconnectCurrentDevice() {
|
||||
const idx = this.data.deviceList.findIndex(d => d.connected)
|
||||
if (idx >= 0) {
|
||||
// 标记断开状态
|
||||
const list = this.data.deviceList.map((d, i) => ({ ...d, connected: false }))
|
||||
this.setData({ deviceList: list })
|
||||
}
|
||||
// 如果保留了设备ID,尝试调用系统断开
|
||||
const devId = this.data.currentDeviceId
|
||||
if (devId && typeof wx.closeBLEConnection === 'function') {
|
||||
try {
|
||||
wx.closeBLEConnection({ deviceId: devId, complete: () => {} })
|
||||
} catch (e) {
|
||||
// 忽略断开异常,继续搜索
|
||||
}
|
||||
this.setData({ currentDeviceId: null })
|
||||
}
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
const { autho } = app.globalData || {}
|
||||
let currentHotel = null
|
||||
|
||||
if (autho && Array.isArray(autho) && autho.length > 0) {
|
||||
// 优先取第一个分组里的第一个酒店;后续可按需要改为 options.HotelId 精确匹配
|
||||
const firstGroup = autho[0]
|
||||
if (firstGroup && Array.isArray(firstGroup.Hotels) && firstGroup.Hotels.length > 0) {
|
||||
currentHotel = firstGroup.Hotels[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (currentHotel) {
|
||||
this.setData({
|
||||
autho,
|
||||
Hotelinfo: currentHotel
|
||||
})
|
||||
|
||||
const title = currentHotel.HotelName
|
||||
? `${currentHotel.HotelName}${currentHotel.Code ? ' (' + currentHotel.Code + ')' : ''}`
|
||||
: '蓝牙调试'
|
||||
wx.setNavigationBarTitle({ title })
|
||||
} else {
|
||||
wx.setNavigationBarTitle({ title: '蓝牙调试' })
|
||||
}
|
||||
|
||||
// 页面加载时,根据当前选中的选项卡加载设备
|
||||
this.loadDevicesByTab(this.data.activeTab)
|
||||
// 同步执行一次蓝牙搜索(按 W13 过滤规则)
|
||||
this.searchBluetooth()
|
||||
}
|
||||
})
|
||||
5
pages/basics/BluetoothDebugging/BluetoothDebugging.json
Normal file
5
pages/basics/BluetoothDebugging/BluetoothDebugging.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"navigationBarTitleText": "蓝牙连接",
|
||||
"usingComponents": {},
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
60
pages/basics/BluetoothDebugging/BluetoothDebugging.wxml
Normal file
60
pages/basics/BluetoothDebugging/BluetoothDebugging.wxml
Normal file
@@ -0,0 +1,60 @@
|
||||
<!-- pages/basics/BluetoothDebugging/BluetoothDebugging.wxml -->
|
||||
<view class="container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<cu-custom bgColor="bg-gradual-blue" isBack="true">
|
||||
<view slot="content">{{Hotelinfo.HotelName}}({{Hotelinfo.Code}})</view>
|
||||
</cu-custom>
|
||||
|
||||
<!-- 内容栏顶部 -->
|
||||
<view class="content-header">
|
||||
<!-- 左侧导航栏 -->
|
||||
<view class="nav-tabs">
|
||||
<view
|
||||
class="nav-tab {{activeTab === 'host' ? 'active' : ''}}"
|
||||
data-tab="host"
|
||||
bindtap="switchTab"
|
||||
>
|
||||
主机
|
||||
</view>
|
||||
<view
|
||||
class="nav-tab {{activeTab === 'W13' ? 'active' : ''}}"
|
||||
data-tab="W13"
|
||||
bindtap="switchTab"
|
||||
>
|
||||
W13
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 右侧搜索按钮 -->
|
||||
<view class="search-btn" bindtap="searchBluetooth">
|
||||
搜索蓝牙
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 蓝牙设备列表 -->
|
||||
<scroll-view class="device-scroll" scroll-y="true">
|
||||
<view class="device-card {{item.connected ? 'connected' : ''}}" wx:for="{{deviceList}}" wx:key="id" data-index="{{index}}" bindtap="onDeviceTap">
|
||||
<view class="device-avatar {{item.connected ? 'online' : ''}}">
|
||||
<image class="avatar-img" src="/img/lanya.png" mode="aspectFit"></image>
|
||||
</view>
|
||||
|
||||
<view class="device-content">
|
||||
<view class="device-top">
|
||||
<view class="device-name-group">
|
||||
<text class="device-name">{{item.name || '未命名设备'}}</text>
|
||||
<text class="device-mac">{{item.mac || item.localName || '未知MAC地址'}}</text>
|
||||
</view>
|
||||
|
||||
<view class="signal-chip {{item.RSSI >= -60 ? 'strong' : (item.RSSI >= -80 ? 'medium' : 'weak')}}">
|
||||
<image class="signal-img" src="{{item.RSSI >= -60 ? '/img/xinhaogao.png' : (item.RSSI >= -80 ? '/img/xinhaozhong.png' : '/img/xinhaodi.png')}}" mode="aspectFit"></image>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="device-bottom">
|
||||
<text class="device-state">Bluetooth · {{item.connected ? '已连接' : '未连接'}}</text>
|
||||
<text class="rssi">{{item.RSSI || 0}} dBm</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
193
pages/basics/BluetoothDebugging/BluetoothDebugging.wxss
Normal file
193
pages/basics/BluetoothDebugging/BluetoothDebugging.wxss
Normal file
@@ -0,0 +1,193 @@
|
||||
/* pages/basics/BluetoothDebugging/BluetoothDebugging.wxss */
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
background-color: #f4f4f4;
|
||||
color: #333333;
|
||||
padding: 12rpx;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 内容栏顶部 */
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
background: #ffffff;
|
||||
border: 1rpx solid #ddd;
|
||||
border-radius: 50rpx;
|
||||
padding: 2rpx;
|
||||
gap: 2rpx;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
padding: 12rpx 26rpx;
|
||||
border-radius: 50rpx;
|
||||
font-size: 28rpx;
|
||||
transition: all 0.3s ease;
|
||||
color: #666666;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: linear-gradient(90deg, #00C6FF, #0072FF);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2rpx 10rpx rgba(0, 114, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 右侧搜索按钮 */
|
||||
.search-btn {
|
||||
background: linear-gradient(90deg, #00D977, #00B16A);
|
||||
color: #fff;
|
||||
padding: 16rpx 30rpx;
|
||||
border-radius: 50rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 蓝牙设备列表 */
|
||||
.device-scroll {
|
||||
max-height: calc(100vh - 260rpx);
|
||||
padding: 4rpx 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
background-color: #ffffff;
|
||||
border: 1rpx solid #e5e9f2;
|
||||
border-radius: 20rpx;
|
||||
padding: 12rpx 14rpx;
|
||||
margin-bottom: 14rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.device-card.connected {
|
||||
border: 2rpx solid #23c16b;
|
||||
background: linear-gradient(135deg, #f3fff7 0%, #ffffff 100%);
|
||||
box-shadow: 0 10rpx 24rpx rgba(35, 193, 107, 0.18);
|
||||
}
|
||||
|
||||
.device-avatar {
|
||||
width: 78rpx;
|
||||
height: 78rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #f1f4fa;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'weui';
|
||||
color: #0f9bd7;
|
||||
box-shadow: inset 0 2rpx 6rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.device-avatar.online {
|
||||
background: radial-gradient(circle at 30% 30%, #bdf4d6, #e8fff2 60%, #ffffff 100%);
|
||||
color: #1bbf67;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
}
|
||||
|
||||
.device-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.device-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.device-name-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.device-mac {
|
||||
font-size: 22rpx;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.signal-chip {
|
||||
min-width: 70rpx;
|
||||
height: 42rpx;
|
||||
padding: 0 12rpx;
|
||||
border-radius: 20rpx;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26rpx;
|
||||
font-family: 'weui';
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.signal-img {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
}
|
||||
|
||||
.signal-chip.strong {
|
||||
background-color: rgba(35, 193, 107, 0.12);
|
||||
color: #23c16b;
|
||||
}
|
||||
|
||||
.signal-chip.medium {
|
||||
background-color: rgba(245, 158, 11, 0.14);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.signal-chip.weak {
|
||||
background-color: rgba(244, 63, 94, 0.12);
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.device-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.device-state {
|
||||
font-size: 24rpx;
|
||||
color: #8c9399;
|
||||
}
|
||||
|
||||
.rssi {
|
||||
font-size: 28rpx;
|
||||
color: #1f2d3d;
|
||||
font-weight: 600;
|
||||
}
|
||||
163
utils/w13Packet.js
Normal file
163
utils/w13Packet.js
Normal file
@@ -0,0 +1,163 @@
|
||||
// W13 BLE 协议公共组包模块
|
||||
// 导出通用的组包构造与CRC16计算(支持 CCITT-FALSE 与 MODBUS)
|
||||
|
||||
const DEFAULT_HEAD = [0xCC, 0xC0];
|
||||
const MAX_LEN = 548; // 文档声明的最大总长度(含包头与CRC)
|
||||
|
||||
export const COMMANDS = {
|
||||
READ_VERSION: 0x01,
|
||||
SET_CONDITION_1: 0x08,
|
||||
SET_CONDITION_2: 0x09,
|
||||
OTA_START: 0x0B,
|
||||
ENABLE_BLE_LOG: 0x0C,
|
||||
RADAR_STATUS: 0x11,
|
||||
TEST_KEYS: 0x13,
|
||||
};
|
||||
|
||||
function toUint16LE(v) {
|
||||
const x = v & 0xFFFF;
|
||||
return [x & 0xFF, (x >>> 8) & 0xFF];
|
||||
}
|
||||
|
||||
function ensureUint8Array(data) {
|
||||
if (!data) return new Uint8Array(0);
|
||||
if (data instanceof Uint8Array) return data;
|
||||
if (Array.isArray(data)) return Uint8Array.from(data.map(x => x & 0xFF));
|
||||
if (typeof data === 'string') {
|
||||
// 默认按ASCII编码;如需GBK请先外部转换后传入字节数组
|
||||
const arr = new Uint8Array(data.length);
|
||||
for (let i = 0; i < data.length; i++) arr[i] = data.charCodeAt(i) & 0xFF;
|
||||
return arr;
|
||||
}
|
||||
throw new TypeError('Unsupported payload type');
|
||||
}
|
||||
|
||||
// CRC-16/CCITT-FALSE: poly=0x1021, init=0xFFFF, refin=false, refout=false, xorout=0x0000
|
||||
export function crc16Ccitt(bytes, start = 0, end = bytes.length) {
|
||||
let crc = 0xFFFF;
|
||||
for (let i = start; i < end; i++) {
|
||||
crc ^= (bytes[i] << 8);
|
||||
for (let j = 0; j < 8; j++) {
|
||||
if (crc & 0x8000) crc = (crc << 1) ^ 0x1021; else crc <<= 1;
|
||||
crc &= 0xFFFF;
|
||||
}
|
||||
}
|
||||
return crc & 0xFFFF;
|
||||
}
|
||||
|
||||
// CRC-16/MODBUS: poly=0xA001, init=0xFFFF, refin=true, refout=true, xorout=0x0000
|
||||
export function crc16Modbus(bytes, start = 0, end = bytes.length) {
|
||||
let crc = 0xFFFF;
|
||||
for (let i = start; i < end; i++) {
|
||||
crc ^= bytes[i];
|
||||
for (let j = 0; j < 8; j++) {
|
||||
const lsb = crc & 0x0001;
|
||||
crc >>= 1;
|
||||
if (lsb) crc ^= 0xA001;
|
||||
}
|
||||
}
|
||||
return crc & 0xFFFF;
|
||||
}
|
||||
|
||||
function computeCrc(bytes, type, range) {
|
||||
const [start, end] = range || [0, bytes.length];
|
||||
if (type === 'MODBUS') return crc16Modbus(bytes, start, end);
|
||||
return crc16Ccitt(bytes, start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造W13协议数据帧
|
||||
* 字段:Head(2) + Len(2) + CRC(2) + Frame(2) + FramNum(2) + Frame_Type(1) + Parameters(N)
|
||||
* Len为整包总长度(含Head与CRC),小端;CRC16默认CCITT-FALSE,计算范围为整包除CRC字段自身。
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {number} opts.frame 帧号(0~65535)
|
||||
* @param {number} opts.framNum 帧总数(0~65535)
|
||||
* @param {number} opts.type 命令字(0~255)
|
||||
* @param {Uint8Array|number[]|string} [opts.params] 参数字节或ASCII字符串
|
||||
* @param {('CCITT'|'MODBUS')} [opts.crcType] CRC算法选择,默认CCITT
|
||||
* @param {number[]} [opts.head] 包头,默认[0xCC,0xC0]
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function buildPacket(opts) {
|
||||
const head = (opts && opts.head) || DEFAULT_HEAD;
|
||||
const frame = (opts && opts.frame) != null ? (opts.frame >>> 0) : 1;
|
||||
const framNum = (opts && opts.framNum) != null ? (opts.framNum >>> 0) : 1;
|
||||
const type = (opts && opts.type) != null ? (opts.type & 0xFF) : 0x00;
|
||||
const params = ensureUint8Array(opts && opts.params);
|
||||
const crcType = (opts && opts.crcType) === 'MODBUS' ? 'MODBUS' : 'CCITT';
|
||||
|
||||
// 预分配:固定头(2) + 长度(2) + CRC(2) + Frame(2) + FramNum(2) + Type(1) + 参数
|
||||
const fixedLen = 2 + 2 + 2 + 2 + 2 + 1;
|
||||
const totalLen = fixedLen + params.length;
|
||||
if (totalLen > MAX_LEN) throw new RangeError(`Packet too long: ${totalLen} > ${MAX_LEN}`);
|
||||
|
||||
const buf = new Uint8Array(totalLen);
|
||||
let off = 0;
|
||||
|
||||
// Head
|
||||
buf[off++] = head[0] & 0xFF;
|
||||
buf[off++] = head[1] & 0xFF;
|
||||
|
||||
// Len (占位后写入)
|
||||
const lenLE = toUint16LE(totalLen);
|
||||
buf[off++] = lenLE[0];
|
||||
buf[off++] = lenLE[1];
|
||||
|
||||
// CRC (占位,稍后计算写入)
|
||||
const crcPos = off;
|
||||
buf[off++] = 0x00;
|
||||
buf[off++] = 0x00;
|
||||
|
||||
// Frame
|
||||
const frameLE = toUint16LE(frame);
|
||||
buf[off++] = frameLE[0];
|
||||
buf[off++] = frameLE[1];
|
||||
|
||||
// FramNum
|
||||
const framNumLE = toUint16LE(framNum);
|
||||
buf[off++] = framNumLE[0];
|
||||
buf[off++] = framNumLE[1];
|
||||
|
||||
// Frame_Type
|
||||
buf[off++] = type;
|
||||
|
||||
// Parameters
|
||||
buf.set(params, off);
|
||||
|
||||
// 计算CRC:整包除CRC字段自身
|
||||
// 为避免实现差异,计算时将CRC位置的两个字节视为0
|
||||
buf[crcPos] = 0x00;
|
||||
buf[crcPos + 1] = 0x00;
|
||||
const crc = computeCrc(buf, crcType, [0, buf.length]);
|
||||
const crcLE = toUint16LE(crc);
|
||||
buf[crcPos] = crcLE[0];
|
||||
buf[crcPos + 1] = crcLE[1];
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* 便捷构造:使用命令常量与简易参数数组
|
||||
* @param {number} cmd COMMANDS中的命令字
|
||||
* @param {number[]|Uint8Array|string} payload 参数
|
||||
* @param {{frame?:number, framNum?:number, crcType?:('CCITT'|'MODBUS'), head?:number[]}} [options]
|
||||
*/
|
||||
export function buildCommand(cmd, payload, options = {}) {
|
||||
return buildPacket({
|
||||
frame: options.frame != null ? options.frame : 1,
|
||||
framNum: options.framNum != null ? options.framNum : 1,
|
||||
type: cmd & 0xFF,
|
||||
params: payload,
|
||||
crcType: options.crcType === 'MODBUS' ? 'MODBUS' : 'CCITT',
|
||||
head: options.head || DEFAULT_HEAD,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 示例:构造“读版本号”命令帧 (Frame_Type=0x01, P0=0x00)
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function buildReadVersion() {
|
||||
return buildCommand(COMMANDS.READ_VERSION, [0x00]);
|
||||
}
|
||||
Reference in New Issue
Block a user