蓝牙通讯初步调通

This commit is contained in:
2026-01-13 15:37:51 +08:00
parent dfc04867c7
commit d9f8358191
16 changed files with 2485 additions and 1 deletions

View 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 })
}
})

View File

@@ -0,0 +1,8 @@
{
"navigationBarBackgroundColor": "#fff",
"navigationBarTextStyle": "black",
"navigationStyle": "custom",
"usingComponents": {
"cu-custom": "/colorui/components/cu-custom"
}
}

View 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>

View 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; }

View 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()
}
})

View File

@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "蓝牙连接",
"usingComponents": {},
"navigationStyle": "custom"
}

View 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>

View 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;
}