/** * 设备详情页 - 实时数据 + 底部固定收发控制台 * * 连接与上报全部由应用级网关(gateway)承担,本页仅: * - 上半屏(可滚动):设备信息 / 测点链路配置 / 离线补传 * - 底部固定收发控制台(串口调试工具风格): * · 数据接收:清空 / 滚动(自动跟随) / Hex(hex 或 ASCII 视图),最新在后 * · 滚动交互:勾选「滚动」时新数据自动滚到底;手动上翻回看自动暂停, * 拖回底部或点「↓ 最新」或重新勾选后恢复跟随 * · 数据发送:勾选 Hex=按 hex 字节发送,取消=按 ASCII 文本发送, * 就地经 commandManager 发送(进发送历史、限频),不跳命令页 * * 注意: * - 采集启停开关只保留在「我的设备」页(设备卡片),本页只读展示启停状态 * - 「发送命令」页(pages/device/command)保留未删(预设命令/发送历史可回看), * 但本页不再提供入口,发送统一走底部发送框 */ const { gateway } = require('../../../services/gateway.js'); const { commandManager } = require('../../../services/command-manager.js'); const storage = require('../../../utils/storage.js'); const measurePoint = require('../../../utils/measure-point.js'); const hex = require('../../../utils/hex.js'); const app = getApp(); Page({ data: { device: null, enabled: false, // 只读:来自存储的启停状态(开关在「我的设备」页) // 网关快照(该设备是否为当前采集对象) bleConnected: false, mqttConnected: false, mqttConnecting: false, mqttUserDisconnected: false, lastError: null, // 本设备是网关当前采集对象且 BLE 未连接 → BLE 状态可点击手动重连 canRetryBle: false, retryingBle: false, // MQTT 未连接 → 状态可点击手动重连(账号/产品级连接,与本设备是否为采集对象无关) canRetryMqtt: false, retryingMqtt: false, // 离线补传可用性(MQTT 已连且有离线数据)→ 同时驱动 disabled 与禁用态样式 canFlush: false, canSend: false, // 收发控制台(最新在后渲染) displayList: [], stats: { received: 0, uploaded: 0, failed: 0 }, offlineCount: 0, autoScroll: true, // 「滚动」勾选:新数据自动滚到底 logFollowing: true, // 运行时跟随态:手动上翻暂停,回到底部恢复 recvHex: true, // 「Hex」勾选:勾选=hex 视图,取消=ASCII 视图 scrollIntoView: '', // 发送区 sendInput: '', sendHex: true, // 勾选=按 hex 字节发送,取消=按 ASCII 文本发送 sending: false, // 测点链路 measurePointId: '', measurePointIdValid: true, collectMode: 2, // 1手动确认 2自动上传 modeManual: false, productIdText: '', // 产品ID取自"我的"页 MQTT 配置 }, onLoad(options) { const sn = options.sn; if (!sn) { wx.showToast({ title: '设备信息缺失', icon: 'none' }); setTimeout(() => wx.navigateBack(), 500); return; } // 从全局或存储获取设备信息 let device = app.globalData.currentDevice; if (!device || device.serialNumber !== sn) { device = storage.getDevice(sn); } if (!device) { wx.showToast({ title: '设备不存在', icon: 'none' }); setTimeout(() => wx.navigateBack(), 500); return; } this.sn = sn; const storedMpId = measurePoint.getMeasurePointId(); this.setData({ device, enabled: !!device.enabled, measurePointId: storedMpId, measurePointIdValid: measurePoint.isValidMeasurePointId(storedMpId) || !storedMpId, collectMode: measurePoint.getCollectMode(), modeManual: measurePoint.getCollectMode() === measurePoint.COLLECT_MODE.MANUAL, }); }, onShow() { // 订阅网关,实时渲染 if (!this._unsub) { this._unsub = gateway.subscribe((snap) => this._apply(snap)); } this._apply(gateway.getSnapshot()); // 产品ID来自“我的”页 MQTT 配置(设备配置不再录入产品信息) const cfg = storage.getMqttConfig(); this.setData({ productIdText: (cfg && cfg.productId != null) ? String(cfg.productId) : '', }); // 启停开关只在「我的设备」页,本页重新显示时同步最新启停态(空状态文案/顶部信息依赖它) if (this.sn) { const latest = storage.getDevice(this.sn); if (latest) { this.setData({ device: latest, enabled: !!latest.enabled }); } } // 本设备已启用时,确保网关在采集(幂等) if (this.data.device && this.data.device.enabled) { gateway.ensureStarted(); } }, onHide() { this._unsubscribe(); }, onUnload() { this._unsubscribe(); }, _unsubscribe() { if (this._unsub) { this._unsub(); this._unsub = null; } }, /** * 应用网关快照到页面 * 收发区只展示“当前采集设备”的数据;非本设备时不显示其帧与统计 */ _apply(snap) { this._lastSnap = snap; const dev = this.data.device; const isActive = !!(snap.device && dev && snap.device.serialNumber === dev.serialNumber); const list = isActive ? snap.dataList : []; // gateway 最新在前;检测头部 id 变化即有新数据 const hasNew = list.length > 0 && list[0].id !== this._lastHeadId; this._lastHeadId = list.length > 0 ? list[0].id : null; const mqttConnected = !!snap.mqttConnected; // 连接中(含底层自愈重连中)与"未连接"要分开:前者点击重连没有意义, // 后者才是需要一键恢复的异常态 const mqttConnecting = !mqttConnected && snap.mqttState === 'connecting'; this.setData({ bleConnected: isActive && snap.bleConnected, // MQTT 是账号/产品级连接(凭证在「我的」页),与"本设备是否为采集对象"无关: // 早期把它与 isActive 绑定,导致非采集设备详情页恒显示"未连接",重连入口也随之失效 mqttConnected, mqttConnecting, mqttUserDisconnected: !!snap.mqttUserDisconnected, // 只要未连接就允许点重连:底层自愈重连中(connecting)也可能卡在错误凭证上, // 此时"点了就硬重连"正是用户想要的,不能用 connecting 把它挡住 canRetryMqtt: !mqttConnected && !this.data.retryingMqtt, // 补传是设备级动作(_flushOffline 只处理当前采集设备的缓存项),仍按 isActive 约束 canFlush: isActive && mqttConnected && snap.offlineCount > 0, canSend: isActive && !!snap.bleConnected && !this.data.sending, lastError: isActive ? snap.lastError : null, canRetryBle: isActive && !snap.bleConnected && !this.data.retryingBle, displayList: this._renderList(list), stats: isActive ? snap.stats : { received: 0, uploaded: 0, failed: 0 }, offlineCount: snap.offlineCount, }); // 有新数据且处于跟随态 → 锚定最后一行(最新在后,滚到底) if (hasNew && this.data.autoScroll && this.data.logFollowing) { this._scrollToLatest(); } }, /** * 渲染列表:gateway 最新在前 → 控制台最新在后(串口日志惯例) * 并按 recvHex 决定显示 hex 或 ASCII 文本 */ _renderList(list) { const recvHex = this.data.recvHex; return list.slice().reverse().map((it) => ({ ...it, text: recvHex ? it.hex : hex.asciiOfHex(it.hex), })); }, /** * 自动滚到底部(scroll-into-view 锚定最后一行) * @param {boolean} force - 值未变化时也强制重新定位(恢复跟随/重新勾选场景) */ _scrollToLatest(force) { const list = this.data.displayList; if (!list.length) return; const target = 'log-' + list[list.length - 1].id; if (this.data.scrollIntoView === target) { if (!force) return; // 值相同时 scroll-into-view 不会重新定位:先清空,下一帧再设 this.setData({ scrollIntoView: '' }); setTimeout(() => this.setData({ scrollIntoView: target }), 0); return; } this.setData({ scrollIntoView: target }); }, /** * 手动重连 BLE(点击 BLE 状态触发) * 走网关的适配器复位重连:连续连接超时后 native 会停在半开连接, * 自动重连与"关开关重开"都刷不掉,必须复位适配器 */ async onRetryBle() { if (!this.data.canRetryBle || this.data.retryingBle) return; this.setData({ retryingBle: true, canRetryBle: false }); wx.showLoading({ title: '重连中...', mask: true }); let res; try { res = await gateway.retryBle(); } catch (err) { res = { ok: false, message: (err && err.message) || '重连异常' }; } finally { // loading 与在途闸门必须无条件回收:retryingBle 残留会让状态标签永久点不动 wx.hideLoading(); this.setData({ retryingBle: false }); this._apply(gateway.getSnapshot()); } wx.showToast({ title: res.ok ? 'BLE 已重连' : (res.message || '重连失败'), icon: res.ok ? 'success' : 'none', duration: 2000, }); }, /** * 手动重连 MQTT(点击 MQTT 状态触发) * * 与 BLE 同理:底层 lib 的自动重连可能停在僵尸 socket 上(close 之后 client 未清空), * 这里走"关闭旧客户端 → 用「我的」页已保存的凭证重建"的确定性出口,成功后自动补传离线 */ async onRetryMqtt() { if (!this.data.canRetryMqtt || this.data.retryingMqtt) return; this.setData({ retryingMqtt: true, canRetryMqtt: false }); wx.showLoading({ title: '重连中...', mask: true }); let res; try { res = await gateway.retryMqtt(); } catch (err) { res = { ok: false, message: (err && err.message) || '重连异常' }; } finally { // 同 BLE:loading 与在途闸门必须无条件回收 wx.hideLoading(); this.setData({ retryingMqtt: false }); this._apply(gateway.getSnapshot()); } wx.showToast({ title: res.ok ? 'MQTT 已重连' : (res.message || '重连失败'), icon: res.ok ? 'success' : 'none', duration: 2000, }); }, /* ===================== 接收区交互 ===================== */ /** * 「滚动」开关:勾选立即回到底并恢复跟随;取消则完全停止自动滚动 * 值取自 checkbox-group 的 detail.value(选中项 value 数组),**不是**布尔值 */ onAutoScrollToggle(e) { const checked = e.detail.value.includes('scroll'); if (checked) { this.setData({ autoScroll: true, logFollowing: true }); this._scrollToLatest(true); } else { this.setData({ autoScroll: false, logFollowing: false }); } }, /** 「Hex」开关:hex 视图 ↔ ASCII 视图,用最近一次快照重渲染 */ onRecvHexToggle(e) { const recvHex = e.detail.value.includes('hex'); this.setData({ recvHex }); const snap = this._lastSnap; if (snap) { const dev = this.data.device; const isActive = !!(snap.device && dev && snap.device.serialNumber === dev.serialNumber); this.setData({ displayList: this._renderList(isActive ? snap.dataList : []) }); } }, /** * 日志滚动:最新在后,向上滚(deltaY<0)=回看旧数据 → 暂停自动跟随; * 程序自动滚底为向下滚(deltaY>0),不会误触发暂停 */ onLogScroll(e) { if (e.detail.deltaY < 0 && this.data.logFollowing) { this.setData({ logFollowing: false }); } }, /** 拖回底部(lower-threshold 内)→ 恢复跟随 */ onLogToLower() { if (!this.data.logFollowing) { this.setData({ logFollowing: true }); } }, /** 点「↓ 最新」浮标 → 回到底部并恢复跟随 */ onFollowTap() { this.setData({ logFollowing: true }); this._scrollToLatest(true); }, /** 清空接收日志与统计(控制台一键清空,不影响离线缓存) */ handleClearData() { gateway.clearHistory(); this._lastHeadId = null; }, /* ===================== 发送区 ===================== */ onSendInput(e) { const value = e.detail.value; // hex 模式统一大写便于阅读;ASCII 模式保留原文 this.setData({ sendInput: this.data.sendHex ? value.toUpperCase() : value }); }, /** * 发送「Hex」开关:切换时把已有输入统一为大写(hex 语义) * 同接收区:值取自 checkbox-group 的 value 数组 */ onSendHexToggle(e) { const sendHex = e.detail.value.includes('hex'); this.setData({ sendHex, sendInput: sendHex ? this.data.sendInput.toUpperCase() : this.data.sendInput, }); }, /** * 发送命令(就地发送,不跳命令页) * hex 模式:交由 commandManager 校验并写 BLE;ASCII 模式:逐字符转字节后发送 * 两条路都进发送历史(命令页「发送历史」tab 可回看) */ async handleSend() { const { sendInput, sendHex, bleConnected, device, sending } = this.data; if (sending) return; if (!sendInput || !sendInput.trim()) { wx.showToast({ title: '请输入发送内容', icon: 'none' }); return; } if (!bleConnected) { wx.showToast({ title: 'BLE 未连接', icon: 'none' }); return; } let payload; let name; if (sendHex) { payload = sendInput.replace(/\s+/g, ''); name = '手动发送'; } else { // ASCII 文本逐字符编码为字节(空格也是内容,不去除) payload = hex.asciiTextToHex(sendInput); if (payload == null) { wx.showToast({ title: 'ASCII 模式不支持中文等非拉丁字符', icon: 'none' }); return; } name = '手动发送(ASCII)'; } this.setData({ sending: true, canSend: false }); wx.showLoading({ title: '发送中...', mask: true }); try { await commandManager.sendCommand(payload, 'BLE', device, { name }); wx.hideLoading(); wx.showToast({ title: '已发送', icon: 'success', duration: 1000 }); } catch (err) { wx.hideLoading(); wx.showToast({ title: err.message || '发送失败', icon: 'none' }); } finally { this.setData({ sending: false, canSend: this.data.bleConnected }); } }, /* ===================== 测点链路 ===================== */ /** * 测点ID输入(数据唯一归属,上报 topic 携带) * 实时校验格式:仅纯数字,1-64 位 * 非法时不写入存储(旧测点ID继续生效),并节流提示"未生效",防止界面值与上报归属不一致的误导 */ onMeasurePointInput(e) { const value = e.detail.value; const isValid = measurePoint.isValidMeasurePointId(value) || !value; if (isValid) { measurePoint.setMeasurePointId(value); } else if (!this._invalidTipAt || Date.now() - this._invalidTipAt > 1500) { this._invalidTipAt = Date.now(); wx.showToast({ title: '仅支持纯数字,本次输入未生效', icon: 'none', duration: 1500 }); } this.setData({ measurePointId: value, measurePointIdValid: isValid, }); }, /** * 采集模式切换:1手动确认 / 2自动上传(纯前端行为) * 从手动切自动时,若有未确认数据给出提示(不强制拦截,切回自动后未确认数据不再上报) */ onModeChange(e) { const mode = parseInt(e.detail.value); const prevMode = this.data.collectMode; if (prevMode === measurePoint.COLLECT_MODE.MANUAL && mode === measurePoint.COLLECT_MODE.AUTO) { const unconfirmed = (this.data.displayList || []).filter(it => !it.uploaded && !it.confirmed); if (unconfirmed.length > 0) { wx.showModal({ title: '模式切换', content: `当前有 ${unconfirmed.length} 条未确认数据,切换到自动模式后将不再上报这些数据。确定继续?`, success: (res) => { if (res.confirm) { measurePoint.setCollectMode(mode); this.setData({ collectMode: mode, modeManual: mode === measurePoint.COLLECT_MODE.MANUAL, }); } else { // 小程序 radio change 后内部选中态已切换,取消需强制重渲染拉回受控状态 this.setData({ collectMode: 0 }); setTimeout(() => { this.setData({ collectMode: prevMode, modeManual: prevMode === measurePoint.COLLECT_MODE.MANUAL, }); }, 0); } }, }); return; } } measurePoint.setCollectMode(mode); this.setData({ collectMode: mode, modeManual: mode === measurePoint.COLLECT_MODE.MANUAL, }); }, /** * 手动确认上报单条数据(模式1) */ async onConfirmData(e) { const itemId = e.currentTarget.dataset.id; try { await gateway.confirmData(itemId); wx.showToast({ title: '已确认上报', icon: 'success' }); } catch (err) { wx.showToast({ title: err.message || '确认失败', icon: 'none' }); } }, /** * 手动补传离线缓存 */ async handleFlushOffline() { const { offlineCount } = this.data; if (!this.data.mqttConnected) { wx.showToast({ title: 'MQTT 未连接', icon: 'none' }); return; } if (offlineCount === 0) { wx.showToast({ title: '暂无离线数据', icon: 'none' }); return; } wx.showLoading({ title: '上传中...', mask: true }); const before = gateway.getSnapshot().offlineCount; await gateway.flushOffline(); wx.hideLoading(); const after = gateway.getSnapshot().offlineCount; wx.showToast({ title: `已上传 ${before - after} 条`, icon: 'none', }); }, });