diff --git a/.claude/skills/yack-mini-dev.md b/.claude/skills/yack-mini-dev.md index 6790b3f4..44356def 100644 --- a/.claude/skills/yack-mini-dev.md +++ b/.claude/skills/yack-mini-dev.md @@ -16,6 +16,11 @@ │ BLE 设备 │ ←──────→ │ yack-mini │ ←──────→ │ yack 平台 │ │ (传感器) │ │ (微信小程序) │ MQTT │ (数据存储) │ └─────────────┘ └──────────────────┘ └─────────────────┘ + | + | + —————————————————— + | 本地缓存/队列 | + —————————————————— ``` ## 项目结构 diff --git a/yack-mini/miniprogram/app.js b/yack-mini/miniprogram/app.js index 3d225e69..ab9ecf44 100644 --- a/yack-mini/miniprogram/app.js +++ b/yack-mini/miniprogram/app.js @@ -3,6 +3,7 @@ */ const envConfig = require('./env.config.js'); const storage = require('./utils/storage.js'); +const { gateway } = require('./services/gateway.js'); App({ globalData: { @@ -24,6 +25,20 @@ App({ this.checkLoginStatus(); }, + // 回到前台:让网关补起连接/重连/补传离线 + onShow() { + console.log('[App] onShow'); + if (this.isLoggedIn()) { + gateway.resume(); + } + }, + + // 切后台:记录时间并暂停网关主动重试(不 teardown) + onHide() { + console.log('[App] onHide'); + gateway.suspend(); + }, + /** * 检查登录状态 * 登录态只依赖 userId(登录一次即可,业务流程不使用 token) @@ -67,6 +82,9 @@ App({ * 清除登录信息 */ clearLoginInfo() { + // 先停网关(断开 BLE/MQTT),再清登录态 + gateway.stop(); + this.globalData.token = null; this.globalData.userInfo = null; this.globalData.userId = null; diff --git a/yack-mini/miniprogram/app.json b/yack-mini/miniprogram/app.json index 2b9277fb..f4dabff6 100644 --- a/yack-mini/miniprogram/app.json +++ b/yack-mini/miniprogram/app.json @@ -4,6 +4,7 @@ "pages/index/index", "pages/device/scan/scan", "pages/device/detail/detail", + "pages/device/command/command", "pages/mine/mine" ], "window": { diff --git a/yack-mini/miniprogram/components/device-card/device-card.js b/yack-mini/miniprogram/components/device-card/device-card.js index 58526c19..048b66ea 100644 --- a/yack-mini/miniprogram/components/device-card/device-card.js +++ b/yack-mini/miniprogram/components/device-card/device-card.js @@ -8,6 +8,11 @@ Component({ type: Object, value: {}, }, + // 网关是否正在采集该设备(首页由 gateway 快照算出传入) + running: { + type: Boolean, + value: false, + }, }, methods: { diff --git a/yack-mini/miniprogram/components/device-card/device-card.wxml b/yack-mini/miniprogram/components/device-card/device-card.wxml index 7ebf181a..baf8f6e8 100644 --- a/yack-mini/miniprogram/components/device-card/device-card.wxml +++ b/yack-mini/miniprogram/components/device-card/device-card.wxml @@ -4,8 +4,8 @@ {{device.name || device.deviceName || '未命名设备'}} - - {{device.connected ? '已连接' : '未连接'}} + + {{running ? '采集中' : (device.enabled ? '连接中' : '未启用')}} diff --git a/yack-mini/miniprogram/components/device-card/device-card.wxss b/yack-mini/miniprogram/components/device-card/device-card.wxss index 9b4aef4b..2208c3e9 100644 --- a/yack-mini/miniprogram/components/device-card/device-card.wxss +++ b/yack-mini/miniprogram/components/device-card/device-card.wxss @@ -1,13 +1,54 @@ /* 设备卡片组件样式 */ + +/* 左滑容器:负责裁剪超出部分,并承载卡片圆角/阴影 */ +.swipe-wrap { + position: relative; + overflow: hidden; + border-radius: 16rpx; + margin-bottom: 20rpx; + box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05); + background-color: #fff; +} + +/* 底层删除按钮:平时被卡片盖住,左滑后露出 */ +.swipe-delete { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 176rpx; + display: flex; + align-items: center; + justify-content: center; + background-color: #ff4d4f; + color: #fff; + border-radius: 0 16rpx 16rpx 0; +} + +.swipe-delete-text { + font-size: 30rpx; + font-weight: 500; +} + +.swipe-delete:active { + background-color: #ff7875; +} + .device-card { + position: relative; + z-index: 1; background-color: #fff; border-radius: 16rpx; padding: 24rpx; - margin-bottom: 20rpx; - box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05); display: flex; flex-direction: row; align-items: center; + transition: transform 0.25s ease; + will-change: transform; +} + +.device-card.is-dragging { + transition: none; } .device-main { @@ -81,45 +122,12 @@ .device-actions { display: flex; - flex-direction: column; - align-items: flex-end; - justify-content: space-between; + flex-direction: row; + align-items: center; + justify-content: center; margin-left: 20rpx; - height: 120rpx; } .device-switch { transform: scale(0.8); - margin-bottom: 12rpx; } - -.delete-btn { - display: flex; - align-items: center; - justify-content: center; - min-width: 100rpx; - height: 56rpx; - padding: 0 20rpx; - margin: 0; - font-size: 24rpx; - color: #ff4d4f; - background-color: #fff1f0; - border: 1rpx solid #ffa39e; - border-radius: 28rpx; - line-height: 56rpx; - text-align: center; - transition: all 0.2s ease; - /* 重置微信小程序 button 默认样式 */ - box-sizing: border-box; - overflow: visible; -} - -.delete-btn::after { - border: none; -} - -.delete-btn:active { - background-color: #ff4d4f; - color: #fff; - border-color: #ff4d4f; -} \ No newline at end of file diff --git a/yack-mini/miniprogram/env.config.js b/yack-mini/miniprogram/env.config.js index cc404f0d..e7d8c91c 100644 --- a/yack-mini/miniprogram/env.config.js +++ b/yack-mini/miniprogram/env.config.js @@ -5,7 +5,7 @@ // 开发环境 const DEV_CONFIG = { - baseUrl: 'http://192.168.100.199:8080', + baseUrl: 'http://192.168.110.117:8080', // 使用阿里云 EMQX 的非加密 WebSocket 端口 mqttServer: 'ws://47.121.180.229:8083/mqtt', }; diff --git a/yack-mini/miniprogram/pages/device/command/command.js b/yack-mini/miniprogram/pages/device/command/command.js new file mode 100644 index 00000000..8427a7ed --- /dev/null +++ b/yack-mini/miniprogram/pages/device/command/command.js @@ -0,0 +1,362 @@ +/** + * 命令发送页面 + */ +const { commandManager } = require('../../../services/command-manager.js'); +const { gateway } = require('../../../services/gateway.js'); +const storage = require('../../../utils/storage.js'); + +const app = getApp(); + +Page({ + data: { + device: null, + activeTab: 0, + tabs: ['预设命令', '自定义', '发送历史'], + + // 预设命令 + commands: [], + + // 自定义输入 + customHex: '', + customName: '', + + // 发送历史 + history: [], + + // BLE连接状态 + bleConnected: false, + + // 编辑弹窗 + showEditModal: false, + editMode: 'add', // 'add' 或 'edit' + editCommand: null, + editName: '', + editHex: '', + }, + + onLoad(options) { + const sn = options.sn; + if (!sn) { + wx.showToast({ title: '设备信息缺失', icon: 'none' }); + setTimeout(() => wx.navigateBack(), 500); + return; + } + + // 获取设备信息 + let device = storage.getDevice(sn); + if (!device) { + wx.showToast({ title: '设备不存在', icon: 'none' }); + setTimeout(() => wx.navigateBack(), 500); + return; + } + + this.setData({ device }); + this.loadCommands(); + this.loadHistory(); + this.checkConnectionStatus(); + }, + + onShow() { + // 订阅网关状态 + if (!this._unsub) { + this._unsub = gateway.subscribe(() => this.checkConnectionStatus()); + } + this.checkConnectionStatus(); + }, + + onHide() { + if (this._unsub) { + this._unsub(); + this._unsub = null; + } + }, + + /** + * 加载预设命令 + */ + loadCommands() { + const commands = commandManager.getCommands(this.data.device.serialNumber); + this.setData({ commands }); + }, + + /** + * 加载发送历史 + */ + loadHistory() { + const history = commandManager.getHistory(this.data.device.serialNumber); + // 格式化时间 + const formattedHistory = history.map(item => ({ + ...item, + sendTime: this._formatTime(item.sendTime), + })); + this.setData({ history: formattedHistory }); + }, + + /** + * 检查连接状态 + */ + checkConnectionStatus() { + const snap = gateway.getSnapshot(); + const isActive = snap.device && snap.device.serialNumber === this.data.device.serialNumber; + + this.setData({ + bleConnected: isActive && snap.bleConnected, + }); + }, + + /** + * 切换 Tab + */ + switchTab(e) { + const index = e.currentTarget.dataset.index; + this.setData({ activeTab: index }); + + // 切换到历史 tab 时重新加载 + if (index === 2) { + this.loadHistory(); + } + }, + + /** + * 输入自定义命令 + */ + onCustomHexInput(e) { + this.setData({ customHex: e.detail.value.toUpperCase() }); + }, + + /** + * 输入命令名称 + */ + onCustomNameInput(e) { + this.setData({ customName: e.detail.value }); + }, + + /** + * 发送自定义命令 + */ + async handleSendCustom() { + const { customHex, customName, device } = this.data; + + if (!customHex) { + wx.showToast({ title: '请输入命令', icon: 'none' }); + return; + } + + await this._sendCommand(customHex, customName || '自定义命令'); + }, + + /** + * 选择预设命令 + */ + async handleSelectCommand(e) { + const index = e.currentTarget.dataset.index; + const command = this.data.commands[index]; + if (!command) return; + + await this._sendCommand(command.hex, command.name, command.id); + }, + + /** + * 发送命令(核心方法,固定使用BLE) + */ + async _sendCommand(hex, name, commandId) { + const { device, bleConnected } = this.data; + + // 检查BLE连接状态 + if (!bleConnected) { + wx.showToast({ title: 'BLE 未连接,请先连接设备', icon: 'none' }); + return; + } + + wx.showLoading({ title: '发送中...', mask: true }); + + try { + // 固定使用BLE方式发送 + await commandManager.sendCommand(hex, 'BLE', device, { name }); + + // 更新命令使用次数 + if (commandId) { + commandManager.incrementUseCount(commandId); + } + + wx.hideLoading(); + wx.showToast({ title: '发送成功', icon: 'success' }); + + // 刷新历史 + this.loadHistory(); + } catch (err) { + wx.hideLoading(); + wx.showToast({ + title: err.message || '发送失败', + icon: 'none', + }); + } + }, + + /** + * 显示添加命令弹窗 + */ + handleAddCommand() { + this.setData({ + showEditModal: true, + editMode: 'add', + editCommand: null, + editName: '', + editHex: '', + }); + }, + + /** + * 编辑命令 + */ + handleEditCommand(e) { + const index = e.currentTarget.dataset.index; + const command = this.data.commands[index]; + if (!command) return; + + this.setData({ + showEditModal: true, + editMode: 'edit', + editCommand: command, + editName: command.name, + editHex: command.hex, + }); + }, + + /** + * 取消编辑 + */ + handleCancelEdit() { + this.setData({ + showEditModal: false, + editCommand: null, + editName: '', + editHex: '', + }); + }, + + /** + * 阻止事件冒泡(空方法) + */ + stopPropagation() { + // 阻止事件冒泡,什么都不做 + }, + + /** + * 编辑命令输入 + */ + onEditNameInput(e) { + this.setData({ editName: e.detail.value }); + }, + + onEditHexInput(e) { + this.setData({ editHex: e.detail.value.toUpperCase() }); + }, + + /** + * 删除命令 + */ + handleSaveCommand() { + const { editMode, editName, editHex, device, editCommand } = this.data; + + if (!editName || editName.trim() === '') { + wx.showToast({ title: '请输入命令名称', icon: 'none' }); + return; + } + if (!editHex || editHex.trim() === '') { + wx.showToast({ title: '请输入命令', icon: 'none' }); + return; + } + + // 验证命令格式 + const hex = editHex.replace(/\s+/g, '').toUpperCase(); + if (!/^[0-9A-F]+$/.test(hex) || hex.length % 2 !== 0) { + wx.showToast({ title: '命令格式错误', icon: 'none' }); + return; + } + + const command = { + name: editName.trim(), + hex: hex, + deviceId: device.serialNumber, + }; + + if (editMode === 'add') { + const success = commandManager.addCommand(command); + if (!success) { + wx.showToast({ title: '命令名称已存在', icon: 'none' }); + return; + } + wx.showToast({ title: '添加成功', icon: 'success' }); + } else { + // 编辑模式 + if (!editCommand || !editCommand.id) { + wx.showToast({ title: '编辑失败', icon: 'none' }); + return; + } + command.id = editCommand.id; + commandManager.updateCommand(command); + wx.showToast({ title: '修改成功', icon: 'success' }); + } + + this.setData({ + showEditModal: false, + editCommand: null, + editName: '', + editHex: '', + }); + this.loadCommands(); + }, + + + /** + * 删除命令 + */ + handleDeleteCommand(e) { + const index = e.currentTarget.dataset.index; + const command = this.data.commands[index]; + if (!command) return; + + wx.showModal({ + title: '删除命令', + content: `确定删除命令 "${command.name}" 吗?`, + confirmColor: '#ff4d4f', + success: (res) => { + if (res.confirm) { + commandManager.removeCommand(command.id); + this.loadCommands(); + wx.showToast({ title: '已删除', icon: 'success' }); + } + }, + }); + }, + + /** + * 清空发送历史 + */ + handleClearHistory() { + wx.showModal({ + title: '清空历史', + content: '确定清空今天的发送历史吗?', + confirmColor: '#ff4d4f', + success: (res) => { + if (res.confirm) { + commandManager.clearHistory(); + this.loadHistory(); + wx.showToast({ title: '已清空', icon: 'success' }); + } + }, + }); + }, + + /** + * 格式化时间 + */ + _formatTime(timestamp) { + const date = new Date(timestamp); + const h = date.getHours().toString().padStart(2, '0'); + const m = date.getMinutes().toString().padStart(2, '0'); + const s = date.getSeconds().toString().padStart(2, '0'); + return `${h}:${m}:${s}`; + }, +}); \ No newline at end of file diff --git a/yack-mini/miniprogram/pages/device/command/command.json b/yack-mini/miniprogram/pages/device/command/command.json new file mode 100644 index 00000000..1b449286 --- /dev/null +++ b/yack-mini/miniprogram/pages/device/command/command.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "发送命令", + "usingComponents": {} +} \ No newline at end of file diff --git a/yack-mini/miniprogram/pages/device/command/command.wxml b/yack-mini/miniprogram/pages/device/command/command.wxml new file mode 100644 index 00000000..c06ad7b8 --- /dev/null +++ b/yack-mini/miniprogram/pages/device/command/command.wxml @@ -0,0 +1,195 @@ + + + + + + 预设命令 + + + 自定义 + + + 发送历史 + + + + + + + + + 预设命令 + + + + + + + {{item.name}} + {{item.hex}} + + + 使用 {{item.useCount || 0}} 次 + + + + + + + + + + + 暂无预设命令 + 点击上方"添加"按钮创建常用命令 + + + + + + + 自定义命令 + + + + 命令名称(可选) + + + + + 十六进制命令 + + 示例:A101FF 或 A1 01 FF(空格会自动去除) + + + + BLE 状态: + + {{bleConnected ? '已连接' : '未连接'}} + + + + + + + + + + 今天的发送历史 + + + + + + + {{item.name}} + {{item.sendTime}} + + + {{item.hex}} + + {{item.method}} + {{item.status === 'success' ? '成功' : '失败'}} + + + + 错误:{{item.error}} + + + + + + 今天暂无发送记录 + 发送的命令将在此显示 + + + + + + + + + {{editMode === 'add' ? '添加命令' : '编辑命令'}} + + + + + 命令名称 * + + + + + 十六进制命令 * + + 示例:A101FF 或 A1 01 FF + + + + + + + + + + + + \ No newline at end of file diff --git a/yack-mini/miniprogram/pages/device/command/command.wxss b/yack-mini/miniprogram/pages/device/command/command.wxss new file mode 100644 index 00000000..b1a04e10 --- /dev/null +++ b/yack-mini/miniprogram/pages/device/command/command.wxss @@ -0,0 +1,439 @@ +/* 命令发送页面样式 */ + +.container { + min-height: 100vh; + background-color: #f5f5f5; +} + +/* Tab 切换 */ +.tabs { + display: flex; + background-color: #fff; + border-bottom: 1rpx solid #e8e8e8; +} + +.tab-item { + flex: 1; + text-align: center; + padding: 28rpx 0; + font-size: 28rpx; + color: #666; + position: relative; +} + +.tab-item.active { + color: #1890ff; + font-weight: 500; +} + +.tab-item.active::after { + content: ''; + position: absolute; + left: 50%; + bottom: 0; + transform: translateX(-50%); + width: 48rpx; + height: 4rpx; + background-color: #1890ff; + border-radius: 2rpx; +} + +/* Tab 内容 */ +.tab-content { + padding: 24rpx; +} + +/* Section */ +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24rpx; +} + +.section-title { + font-size: 32rpx; + font-weight: 500; + color: #333; +} + +/* 命令列表 */ +.command-list { + background-color: #fff; + border-radius: 8rpx; + overflow: hidden; +} + +.command-item { + padding: 24rpx; + border-bottom: 1rpx solid #f0f0f0; +} + +.command-item:last-child { + border-bottom: none; +} + +.command-main { + margin-bottom: 16rpx; +} + +.command-name { + font-size: 30rpx; + font-weight: 500; + color: #333; + margin-bottom: 8rpx; +} + +.command-hex { + font-size: 26rpx; + color: #1890ff; + font-family: 'Courier New', monospace; + background-color: #f0f5ff; + padding: 8rpx 12rpx; + border-radius: 4rpx; + display: inline-block; + margin-bottom: 8rpx; +} + +.command-desc { + font-size: 24rpx; + color: #999; + margin-bottom: 8rpx; +} + +.command-meta { + font-size: 22rpx; + color: #999; +} + +.command-actions { + display: flex; + gap: 16rpx; +} + +.btn-edit { + background-color: #1890ff; + color: #fff; +} + +.btn-delete { + background-color: #ff4d4f; + color: #fff; +} + +.btn-add { + background-color: #1890ff; + color: #fff; +} + +.btn-clear { + background-color: #ff4d4f; + color: #fff; +} + +/* 自定义命令表单 */ +.form-item { + margin-bottom: 32rpx; + background-color: #fff; + border-radius: 8rpx; + padding: 24rpx; +} + +.form-label { + font-size: 28rpx; + color: #333; + margin-bottom: 16rpx; + display: block; +} + +.form-input { + width: 100%; + height: 80rpx; + line-height: 80rpx; + font-size: 28rpx; + color: #333; + background-color: #f5f5f5; + border-radius: 8rpx; + padding: 0 16rpx; +} + +.form-input.hex-input { + font-family: 'Courier New', monospace; + text-transform: uppercase; +} + +.form-hint { + font-size: 22rpx; + color: #999; + margin-top: 12rpx; +} + +.form-textarea { + width: 100%; + min-height: 120rpx; + font-size: 28rpx; + color: #333; + background-color: #f5f5f5; + border-radius: 8rpx; + padding: 16rpx; +} + +/* 连接状态 */ +.connection-status { + background-color: #fff; + border-radius: 8rpx; + padding: 24rpx; + margin-bottom: 32rpx; + display: flex; + align-items: center; +} + +.status-text { + font-size: 28rpx; + color: #333; +} + +.status-value { + font-size: 28rpx; + font-weight: 500; + margin-left: 8rpx; +} + +.status-value.connected { + color: #52c41a; +} + +.status-value.disconnected { + color: #ff4d4f; +} + +/* 发送按钮 */ +.btn-send { + width: 100%; + height: 88rpx; + line-height: 88rpx; + background-color: #1890ff; + color: #fff; + font-size: 32rpx; + border-radius: 8rpx; + border: none; + padding: 0; + margin: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-send[disabled] { + background-color: #b3e5fc; + color: #fff; +} + +.btn-send::after { + border: none; +} + +/* 发送历史 */ +.history-list { + background-color: #fff; + border-radius: 8rpx; + overflow: hidden; +} + +.history-item { + padding: 24rpx; + border-bottom: 1rpx solid #f0f0f0; +} + +.history-item:last-child { + border-bottom: none; +} + +.history-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12rpx; +} + +.history-name { + font-size: 28rpx; + font-weight: 500; + color: #333; +} + +.history-time { + font-size: 22rpx; + color: #999; +} + +.history-content { + display: flex; + justify-content: space-between; + align-items: center; +} + +.history-hex { + font-size: 26rpx; + color: #1890ff; + font-family: 'Courier New', monospace; + background-color: #f0f5ff; + padding: 6rpx 12rpx; + border-radius: 4rpx; +} + +.history-meta { + display: flex; + gap: 12rpx; +} + +.method-tag { + font-size: 22rpx; + color: #52c41a; + background-color: #f6ffed; + padding: 4rpx 12rpx; + border-radius: 4rpx; +} + +.status-tag { + font-size: 22rpx; + padding: 4rpx 12rpx; + border-radius: 4rpx; +} + +.status-tag.success { + color: #52c41a; + background-color: #f6ffed; +} + +.status-tag.failed { + color: #ff4d4f; + background-color: #fff1f0; +} + +.history-error { + font-size: 22rpx; + color: #ff4d4f; + margin-top: 12rpx; + padding: 12rpx; + background-color: #fff1f0; + border-radius: 4rpx; +} + +/* 空状态 */ +.empty { + text-align: center; + padding: 80rpx 0; + background-color: #fff; + border-radius: 8rpx; +} + +.empty-text { + display: block; + font-size: 28rpx; + color: #999; + margin-bottom: 12rpx; +} + +.empty-hint { + display: block; + font-size: 24rpx; + color: #ccc; +} + +/* 弹窗 */ +.modal-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 999; +} + +.modal-content { + width: 90%; + max-width: 600rpx; + background-color: #fff; + border-radius: 16rpx; + overflow: hidden; +} + +.modal-header { + padding: 32rpx; + border-bottom: 1rpx solid #f0f0f0; +} + +.modal-title { + font-size: 32rpx; + font-weight: 500; + color: #333; +} + +.modal-body { + padding: 32rpx; + max-height: 600rpx; + overflow-y: auto; +} + +/* 确保输入框样式正确 */ +.modal-body .form-item { + margin-bottom: 24rpx; + padding: 0; + background-color: transparent; + border-radius: 0; +} + +.modal-body .form-input, +.modal-body .form-textarea { + width: 100%; + background-color: #f5f5f5; + border: 2rpx solid #e8e8e8; + border-radius: 8rpx; +} + +.modal-body .form-input:focus, +.modal-body .form-textarea:focus { + border-color: #1890ff; + background-color: #fff; +} + +.modal-footer { + display: flex; + border-top: 1rpx solid #f0f0f0; +} + +.btn-cancel, +.btn-confirm { + flex: 1; + height: 88rpx; + line-height: 88rpx; + font-size: 32rpx; + border-radius: 0; + border: none; + padding: 0; + margin: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.btn-cancel { + background-color: #fff; + color: #666; + border-right: 1rpx solid #f0f0f0; +} + +.btn-confirm { + background-color: #1890ff; + color: #fff; +} + +.btn-cancel::after, +.btn-confirm::after { + border: none; +} \ No newline at end of file diff --git a/yack-mini/miniprogram/pages/device/detail/detail.js b/yack-mini/miniprogram/pages/device/detail/detail.js index aa62b730..b5688a23 100644 --- a/yack-mini/miniprogram/pages/device/detail/detail.js +++ b/yack-mini/miniprogram/pages/device/detail/detail.js @@ -1,31 +1,34 @@ /** - * 设备详情页 - 实时数据 + * 设备详情页 - 实时数据(纯视图) + * + * 连接与上报全部由应用级网关(gateway)承担,本页仅: + * - 订阅网关快照,实时展示该设备的 hex 原始帧与统计 + * - 提供”启用网关采集”开关(单 enabled,开启即停其他设备) + * - 手动补传离线缓存 / 清空 */ -const ble = require('../../../services/ble.js'); -const mqtt = require('../../../services/mqtt-client.js'); +const { gateway } = require('../../../services/gateway.js'); const storage = require('../../../utils/storage.js'); -const auth = require('../../../utils/auth.js'); const app = getApp(); Page({ data: { device: null, + enabled: false, + + // 网关快照(该设备是否为当前采集对象) + isActive: false, + activeSn: '', + running: false, bleConnected: false, mqttConnected: false, - connecting: false, - mqttConnecting: false, + phaseText: '未启用', + lastError: null, - // 数据列表 + // 数据列表与统计 dataList: [], - maxDataCount: 50, - - // 统计 - stats: { - received: 0, - uploaded: 0, - failed: 0, - }, + stats: { received: 0, uploaded: 0, failed: 0 }, + offlineCount: 0, }, onLoad(options) { @@ -41,314 +44,145 @@ Page({ if (!device || device.serialNumber !== sn) { device = storage.getDevice(sn); } - if (!device) { wx.showToast({ title: '设备不存在', icon: 'none' }); setTimeout(() => wx.navigateBack(), 500); return; } - this.setData({ device }); - - // 监听 BLE 状态变化 - ble.bleService.onStateChange((state) => { - const connected = state === ble.BLE_STATE.READY || state === ble.BLE_STATE.CONNECTED; - this.setData({ bleConnected: connected }); + this.sn = sn; + this.setData({ + device, + enabled: !!device.enabled, }); + }, - // 监听 BLE 数据 - ble.bleService.onData((data) => { - this._handleBleData(data); - }); + onShow() { + // 订阅网关,实时渲染 + if (!this._unsub) { + this._unsub = gateway.subscribe((snap) => this._apply(snap)); + } + this._apply(gateway.getSnapshot()); - // 监听 MQTT 状态变化 - mqtt.mqttClient.onStateChange((state) => { - const connected = state === mqtt.MQTT_STATE.CONNECTED; - this.setData({ mqttConnected: connected }); - }); + // 本设备已启用时,确保网关在采集(幂等) + if (this.data.device && this.data.device.enabled) { + gateway.ensureStarted(); + } + }, + + onHide() { + this._unsubscribe(); }, onUnload() { - // 清除回调,防止页面销毁后 setData 报错 - ble.bleService.clearCallbacks(); - mqtt.mqttClient.clearCallbacks(); + this._unsubscribe(); + }, - // 断开连接 - ble.bleService.disconnect(); - mqtt.mqttClient.disconnect(); + _unsubscribe() { + if (this._unsub) { + this._unsub(); + this._unsub = null; + } }, /** - * 处理 BLE 数据 + * 应用网关快照到页面 */ - _handleBleData(data) { - const now = new Date(); - const time = this._formatTime(now); - - // 使用唯一 ID 标识数据项(防止重复数据更新错乱) - const dataItem = { - id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - hex: data.hex, - time: time, - uploaded: false, - }; - - let dataList = this.data.dataList; - dataList.unshift(dataItem); - - // 限制数量 - if (dataList.length > this.data.maxDataCount) { - dataList = dataList.slice(0, this.data.maxDataCount); - } + _apply(snap) { + const dev = this.data.device; + const isActive = !!(snap.device && dev && snap.device.serialNumber === dev.serialNumber); this.setData({ - dataList, - 'stats.received': this.data.stats.received + 1, + isActive, + activeSn: snap.device ? snap.device.serialNumber : '', + bleConnected: isActive && snap.bleConnected, + mqttConnected: isActive && snap.mqttConnected, + running: isActive && snap.phase === 'running', + phaseText: snap.phaseText, + lastError: isActive ? snap.lastError : null, + dataList: isActive ? snap.dataList : [], + stats: isActive ? snap.stats : { received: 0, uploaded: 0, failed: 0 }, + offlineCount: snap.offlineCount, }); + }, - // 如果 MQTT 已连接且设备已启用,自动上报 - if (this.data.mqttConnected && this.data.device.enabled) { - this._uploadData(data.hex, dataItem.id); + /** + * 启用/停用网关采集(单 enabled) + */ + async handleEnableToggle(e) { + const enabled = e.detail.value; + const device = this.data.device; + if (!device) return; + + if (enabled) { + // 本设备成为唯一启用,并立即启动采集 + storage.activateDevice(device.serialNumber); + this.setData({ + enabled: true, + device: { ...device, enabled: true }, + }); + const target = storage.getDevice(device.serialNumber); + gateway.start(target || { ...device, enabled: true }); } else { - // MQTT 未连接,缓存数据(离线缓存) - storage.cachePendingData({ - deviceId: this.data.device.serialNumber, - productId: this.data.device.productId, - hex: data.hex, - dataId: dataItem.id, + storage.deactivateDevice(device.serialNumber); + this.setData({ + enabled: false, + device: { ...device, enabled: false }, }); + // 停用后若无其他启用设备,网关停止 + await gateway.ensureStarted(); } }, /** - * 上报数据到 yack - * @param {string} hexData - 十六进制数据 - * @param {string} [dataId] - 数据项 ID(用于更新列表状态) - * @throws 上报失败时抛出异常 + * 发送命令 - 跳转命令管理页 */ - async _uploadData(hexData, dataId) { + handleSendCommand() { const device = this.data.device; if (!device) return; - const topic = auth.buildPropertyPostTopic(device.productId, device.serialNumber); - - try { - await mqtt.mqttClient.publish(topic, hexData); - console.log('[Detail] 上报成功:', hexData); - - // 更新状态 - this.setData({ - 'stats.uploaded': this.data.stats.uploaded + 1, - }); - - // 更新 dataList 中的状态(按唯一 ID 查找) - if (dataId) { - const dataList = this.data.dataList; - const index = dataList.findIndex(d => d.id === dataId); - if (index >= 0) { - this.setData({ - [`dataList[${index}].uploaded`]: true, - }); - } - } - - } catch (err) { - console.error('[Detail] 上报失败:', err); - this.setData({ - 'stats.failed': this.data.stats.failed + 1, - }); - // 抛出异常,供调用方统计失败 - throw err; - } + wx.navigateTo({ + url: `/pages/device/command/command?sn=${device.serialNumber}`, + }); }, /** - * 连接/断开 BLE + * 手动补传离线缓存 */ - async handleConnect() { - const { device, bleConnected } = this.data; - if (!device) return; - - if (bleConnected) { - // 断开连接 - await ble.bleService.disconnect(); - this.setData({ bleConnected: false }); - return; - } - - this.setData({ connecting: true }); - - try { - // 初始化蓝牙 - await ble.bleService.initBluetooth(); - // 连接设备 - await ble.bleService.connectDevice(device.deviceId, device.serviceUUID); - this.setData({ bleConnected: true }); - wx.showToast({ title: 'BLE 已连接', icon: 'success' }); - } catch (err) { - console.error('[Detail] BLE 连接失败:', err); - wx.showToast({ - title: err.message || 'BLE 连接失败', - icon: 'none', - }); - } finally { - this.setData({ connecting: false }); - } - }, - - /** - * 连接/断开 MQTT - */ - async handleMqttConnect() { - const { device, mqttConnected } = this.data; - if (!device) return; - - if (mqttConnected) { - // 断开连接 - await mqtt.mqttClient.disconnect(); - this.setData({ mqttConnected: false }); - return; - } - - this.setData({ mqttConnecting: true }); - - try { - // 获取 MQTT 凭证 - const credentials = auth.getMqttCredentialsFromDevice(device); - if (!credentials) { - throw new Error('获取 MQTT 凭证失败'); - } - - // 连接 MQTT - await mqtt.mqttClient.connect(credentials); - this.setData({ mqttConnected: true }); - wx.showToast({ title: 'MQTT 已连接', icon: 'success' }); - - // 上传缓存的离线数据 - this._uploadPendingData(); - - } catch (err) { - console.error('[Detail] MQTT 连接失败:', err); - wx.showToast({ - title: err.message || 'MQTT 连接失败', - icon: 'none', - }); - } finally { - this.setData({ mqttConnecting: false }); - } - }, - - /** - * 上传缓存的离线数据 - */ - async _uploadPendingData() { - const pendingList = storage.getPendingData(); - if (pendingList.length === 0) return; - - console.log('[Detail] 开始上传离线缓存数据:', pendingList.length, '条'); - const device = this.data.device; - - let uploaded = 0; - let failed = 0; - - for (const item of pendingList) { - // 只上传当前设备的数据 - if (item.deviceId === device.serialNumber) { - try { - const topic = auth.buildPropertyPostTopic(device.productId, device.serialNumber); - await mqtt.mqttClient.publish(topic, item.hex); - uploaded++; - } catch (err) { - console.error('[Detail] 离线数据上传失败:', item.hex, err); - failed++; - } - } - } - - // 清除已处理的缓存数据 - storage.clearPendingData(); - - if (uploaded > 0 || failed > 0) { - console.log('[Detail] 离线数据上传完成:', uploaded, '成功,', failed, '失败'); - } - }, - - /** - * 上报单条数据 - */ - async handleUploadSingle(e) { - const index = e.currentTarget.dataset.index; - const data = this.data.dataList[index]; - if (!data || data.uploaded) return; - + async handleFlushOffline() { + const { offlineCount } = this.data; if (!this.data.mqttConnected) { wx.showToast({ title: 'MQTT 未连接', icon: 'none' }); return; } - - try { - await this._uploadData(data.hex, data.id); - } catch (err) { - wx.showToast({ title: err.message || '上报失败', icon: 'none' }); - } - }, - - /** - * 全部上报 - */ - async handleUploadAll() { - const { dataList, mqttConnected } = this.data; - if (!mqttConnected || dataList.length === 0) return; - - wx.showLoading({ title: '上报中...', mask: true }); - - let uploaded = 0; - let failed = 0; - - for (const data of dataList) { - if (!data.uploaded) { - try { - await this._uploadData(data.hex, data.id); - uploaded++; - } catch (err) { - failed++; - } - } + 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: `上报完成: ${uploaded} 成功, ${failed} 失败`, + title: `已上传 ${before - after} 条`, icon: 'none', }); }, /** - * 清空数据 + * 清空展示数据 */ handleClearData() { wx.showModal({ title: '清空数据', - content: '确定清空所有接收到的数据吗?', + content: '确定清空当前实时数据与统计吗?(不影响离线缓存)', success: (res) => { if (res.confirm) { - this.setData({ - dataList: [], - stats: { received: 0, uploaded: 0, failed: 0 }, - }); + gateway.clearHistory(); } }, }); }, - - /** - * 格式化时间 - */ - _formatTime(date) { - const h = date.getHours().toString().padStart(2, '0'); - const m = date.getMinutes().toString().padStart(2, '0'); - const s = date.getSeconds().toString().padStart(2, '0'); - return `${h}:${m}:${s}`; - }, -}); \ No newline at end of file +}); diff --git a/yack-mini/miniprogram/pages/device/detail/detail.wxml b/yack-mini/miniprogram/pages/device/detail/detail.wxml index e4e9447f..6ee7c79b 100644 --- a/yack-mini/miniprogram/pages/device/detail/detail.wxml +++ b/yack-mini/miniprogram/pages/device/detail/detail.wxml @@ -1,4 +1,4 @@ - + @@ -32,29 +32,54 @@ + + + + + 启用网关采集 + 开启后自动连接本设备并透传上报;同一时间仅一个设备启用 + + + + + + 采集状态 + {{running ? '采集中' : phaseText}} + + + BLE {{bleConnected ? '已连接' : '未连接'}} + · + MQTT {{mqttConnected ? '已连接' : '未连接'}} + + + + 网关当前正采集 {{activeSn}},启用本设备后将自动切换 + + {{lastError}} + + 实时数据 - 清空 {{item.time}} {{item.hex}} - + 已上报 - - 上报 + + 待上报 暂无数据 - 连接 BLE 设备后,数据将在此显示 + {{enabled ? '等待设备上报数据…' : '启用网关采集后,设备数据将在此实时显示'}} @@ -62,7 +87,7 @@ {{stats.received}} - 接收数据 + 接收 {{stats.uploaded}} @@ -72,20 +97,18 @@ {{stats.failed}} 失败 + + {{offlineCount}} + 离线 + - + - - - - - + + - \ No newline at end of file + diff --git a/yack-mini/miniprogram/pages/device/detail/detail.wxss b/yack-mini/miniprogram/pages/device/detail/detail.wxss index 1f9bdc06..ab08bfaf 100644 --- a/yack-mini/miniprogram/pages/device/detail/detail.wxss +++ b/yack-mini/miniprogram/pages/device/detail/detail.wxss @@ -80,8 +80,8 @@ word-break: break-all; } -.data-action { - width: 100rpx; +.data-flag { + width: 120rpx; text-align: right; } @@ -90,9 +90,9 @@ color: #52c41a; } -.upload-btn { +.pending { font-size: 22rpx; - color: #1890ff; + color: #bbb; } .empty-hint { @@ -101,6 +101,72 @@ margin-top: 12rpx; } +/* 采集控制卡片 */ +.collect-control { + display: flex; + flex-direction: column; +} + +.collect-row { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +.collect-text { + flex: 1; + padding-right: 20rpx; +} + +.collect-title { + font-size: 28rpx; + font-weight: 600; + color: #333; +} + +.collect-sub { + font-size: 22rpx; + color: #999; + margin-top: 6rpx; +} + +.collect-status { + display: flex; + flex-direction: row; + align-items: center; + margin-top: 24rpx; +} + +.status-label { + font-size: 24rpx; + color: #666; + margin-right: 16rpx; +} + +.collect-meta { + display: flex; + flex-direction: row; + align-items: center; + font-size: 22rpx; + color: #999; + margin-top: 12rpx; +} + +.collect-meta .dot { + margin: 0 8rpx; +} + +.notice-text { + margin-top: 16rpx; + font-size: 22rpx; + color: #fa8c16; +} + +.notice-text.error { + color: #ff4d4f; +} + .stats-section { display: flex; flex-direction: row; @@ -135,9 +201,9 @@ padding: 20rpx 0; } -.btn-connect, -.btn-mqtt, -.btn-upload { +.btn-flush, +.btn-clear, +.btn-command { flex: 1; min-width: 200rpx; display: flex; @@ -152,35 +218,28 @@ margin: 0; } -.btn-connect::after, -.btn-mqtt::after, -.btn-upload::after { +.btn-flush::after, +.btn-clear::after, +.btn-command::after { border: none; } -.btn-connect { - background-color: #1890ff; - color: #fff; -} - -.btn-connect[disabled] { - background-color: #69c0ff; -} - -.btn-mqtt { - background-color: #52c41a; - color: #fff; -} - -.btn-mqtt[disabled] { - background-color: #95de64; -} - -.btn-upload { +.btn-flush { background-color: #722ed1; color: #fff; } -.btn-upload[disabled] { +.btn-flush[disabled] { background-color: #b37feb; + color: #fff; +} + +.btn-clear { + background-color: #ff4d4f; + color: #fff; +} + +.btn-command { + background-color: #52c41a; + color: #fff; } \ No newline at end of file diff --git a/yack-mini/miniprogram/pages/device/scan/scan.js b/yack-mini/miniprogram/pages/device/scan/scan.js index c0fd0762..6e6eb390 100644 --- a/yack-mini/miniprogram/pages/device/scan/scan.js +++ b/yack-mini/miniprogram/pages/device/scan/scan.js @@ -3,6 +3,7 @@ */ const ble = require('../../../services/ble.js'); const storage = require('../../../utils/storage.js'); +const { gateway } = require('../../../services/gateway.js'); Page({ data: { @@ -25,8 +26,9 @@ Page({ }, onLoad() { - // 初始化蓝牙 - this._initBluetooth(); + // 暂停正在跑的网关,避免常驻会话与扫描/校验连接抢占同一 BLE 目标; + // 返回首页后 index.onShow → gateway.ensureStarted() 会自动恢复。 + gateway.stop().then(() => this._initBluetooth()); }, onUnload() { @@ -194,14 +196,18 @@ Page({ }; storage.addDevice(deviceConfig); + // 新设备成为唯一启用(单 enabled 不变量,兼修历史多启用数据) + storage.activateDevice(deviceConfig.serialNumber); wx.showToast({ title: '保存成功', icon: 'success' }); - // 断开 BLE 连接(让详情页重新连接,防止连接泄漏) - await ble.bleService.disconnect(); - - // 延迟返回 - setTimeout(() => { + // 延迟返回(先断开连接再返回,让详情页重新连接) + setTimeout(async () => { + try { + await ble.bleService.disconnect(); + } catch (e) { + console.warn('[Scan] 断开连接失败:', e); + } this.setData({ showConfigModal: false }); wx.navigateBack(); }, 500); @@ -213,6 +219,12 @@ Page({ title: err.message || '连接失败', icon: 'none', }); + // 连接失败时确保断开,避免残留连接 + try { + await ble.bleService.disconnect(); + } catch (e) { + console.warn('[Scan] 断开连接失败:', e); + } } finally { this.setData({ configuring: false }); } diff --git a/yack-mini/miniprogram/pages/index/index.js b/yack-mini/miniprogram/pages/index/index.js index 6fae2123..8647da39 100644 --- a/yack-mini/miniprogram/pages/index/index.js +++ b/yack-mini/miniprogram/pages/index/index.js @@ -2,6 +2,7 @@ * 首页 - 设备列表 */ const storage = require('../../utils/storage.js'); +const { gateway } = require('../../services/gateway.js'); const app = getApp(); Page({ @@ -19,8 +20,35 @@ Page({ }, onShow() { - // 每次显示时刷新设备列表 + // 每次显示时确保网关在采集(登录后/从扫描返回自动恢复),并刷新设备列表 + gateway.ensureStarted(); this.refreshData(); + // 跟随网关运行状态刷新“采集中”标签(仅在运行态变化时刷新,避免高频 setData) + this._bindRunning(); + }, + + onHide() { + if (this._unsub) { + this._unsub(); + this._unsub = null; + } + }, + + /** + * 订阅网关运行态;仅在“采集中的设备”变化时才整表刷新 + */ + _bindRunning() { + const snap = gateway.getSnapshot(); + this._lastRunningSn = (snap.device && snap.bleConnected) ? snap.device.serialNumber : null; + if (this._unsub) return; + this._unsub = gateway.subscribe(() => { + const s = gateway.getSnapshot(); + const sn = (s.device && s.bleConnected) ? s.device.serialNumber : null; + if (sn !== this._lastRunningSn) { + this._lastRunningSn = sn; + this.refreshData(); + } + }); }, /** @@ -28,6 +56,11 @@ Page({ */ refreshData() { const devices = storage.getDeviceList(); + const snap = gateway.getSnapshot(); + const runningSn = (snap.device && snap.bleConnected) ? snap.device.serialNumber : null; + devices.forEach((d) => { + d.running = runningSn !== null && d.serialNumber === runningSn; + }); this.setData({ userInfo: app.globalData.userInfo, devices: devices, @@ -59,9 +92,11 @@ Page({ title: '删除设备', content: '确定删除该设备配置吗?', confirmColor: '#ff4d4f', - success: (res) => { + success: async (res) => { if (res.confirm) { storage.removeDevice(serialNumber); + // 删除的若是正在采集的设备,网关自动停/切换 + await gateway.ensureStarted(); this.refreshData(); wx.showToast({ title: '已删除', icon: 'success' }); } @@ -70,18 +105,20 @@ Page({ }, /** - * 设备启停切换 + * 设备启停切换(单 enabled:开启任意一个会禁用其余设备) */ - handleDeviceSwitch(e) { + async handleDeviceSwitch(e) { const serialNumber = e.detail.serialNumber; const enabled = e.detail.enabled; - const devices = this.data.devices; - const index = devices.findIndex(d => d.serialNumber === serialNumber); - if (index >= 0) { - devices[index].enabled = enabled; - this.setData({ devices }); - storage.updateDevice(devices[index]); + + if (enabled) { + storage.activateDevice(serialNumber); + } else { + storage.deactivateDevice(serialNumber); } + // 网关跟随启停:启用→启动采集;关闭→停止 + await gateway.ensureStarted(); + this.refreshData(); }, /** diff --git a/yack-mini/miniprogram/pages/index/index.wxml b/yack-mini/miniprogram/pages/index/index.wxml index 11a500a0..2e58ce9d 100644 --- a/yack-mini/miniprogram/pages/index/index.wxml +++ b/yack-mini/miniprogram/pages/index/index.wxml @@ -16,6 +16,7 @@ wx:for="{{devices}}" wx:key="serialNumber" device="{{item}}" + running="{{item.running}}" bind:tap="handleDeviceTap" data-index="{{index}}" binddelete="handleDeviceDelete" diff --git a/yack-mini/miniprogram/pages/mine/mine.js b/yack-mini/miniprogram/pages/mine/mine.js index aa414ae2..293565e7 100644 --- a/yack-mini/miniprogram/pages/mine/mine.js +++ b/yack-mini/miniprogram/pages/mine/mine.js @@ -1,74 +1,87 @@ /* pages/mine/mine.js */ const storage = require('../../utils/storage.js'); -const { mqttClient } = require('../../services/mqtt-client.js'); -const { bleService } = require('../../services/ble.js'); +const { gateway } = require('../../services/gateway.js'); Page({ data: { userInfo: null, + activeDevice: '无', mqttStatus: '未连接', mqttStatusColor: '#999', - bleStatus: '未初始化', + bleStatus: '未运行', bleStatusColor: '#999', pendingCount: 0, - appVersion: '1.0.0' + appVersion: '1.0.0', }, onLoad() { this.loadUserInfo(); - this.checkStatus(); }, onShow() { - // 每次显示页面时刷新状态 + if (!this._unsub) { + this._unsub = gateway.subscribe(() => this.checkStatus()); + } + this.loadUserInfo(); this.checkStatus(); }, + onHide() { + if (this._unsub) { + this._unsub(); + this._unsub = null; + } + }, + // 加载用户信息 loadUserInfo() { const userInfo = storage.getUserInfo(); this.setData({ userInfo }); }, - // 检查连接状态 + // 检查连接状态(统一读网关快照,修复旧字符串/对象 API 回归) checkStatus() { - // MQTT 状态 - const mqttState = mqttClient.getState(); + const snap = gateway.getSnapshot(); + const devName = snap.device ? (snap.device.name || snap.device.serialNumber || '') : ''; + + // MQTT let mqttStatus = '未连接'; let mqttStatusColor = '#999'; - if (mqttState.connected) { + if (snap.mqttConnected) { mqttStatus = '已连接'; mqttStatusColor = '#52c41a'; - } else if (mqttState.connecting) { + } else if (snap.mqttState === 'connecting') { mqttStatus = '连接中'; mqttStatusColor = '#1890ff'; } - // 蓝牙状态 - const bleState = bleService.getState(); - let bleStatus = '未初始化'; + // 蓝牙/采集 + let bleStatus = '未运行'; let bleStatusColor = '#999'; - if (bleState.connected) { - bleStatus = '已连接设备'; + if (snap.bleConnected && devName) { + bleStatus = '采集中'; bleStatusColor = '#52c41a'; - } else if (bleState.adapterAvailable) { - bleStatus = '蓝牙已开启'; + } else if (snap.phase === 'starting') { + bleStatus = '连接中'; bleStatusColor = '#1890ff'; - } else if (bleState.adapterAvailable === false) { - bleStatus = '蓝牙未开启'; + } else if (snap.phase === 'error') { + bleStatus = '异常'; bleStatusColor = '#ff4d4f'; + } else if (snap.device && !snap.bleConnected) { + bleStatus = '未连接'; + bleStatusColor = '#fa8c16'; } - // 离线缓存数量 const pendingData = storage.getPendingData(); const pendingCount = pendingData ? pendingData.length : 0; this.setData({ + activeDevice: devName || '无', mqttStatus, mqttStatusColor, bleStatus, bleStatusColor, - pendingCount + pendingCount, }); }, @@ -82,40 +95,33 @@ Page({ storage.clearPendingData(); wx.showToast({ title: '清除成功', - icon: 'success' + icon: 'success', }); this.checkStatus(); } - } + }, }); }, // 退出登录 - logout() { + async logout() { wx.showModal({ title: '提示', content: '确定要退出登录吗?', - success: (res) => { + success: async (res) => { if (res.confirm) { - // 断开 MQTT 连接 - if (mqttClient.isConnected()) { - mqttClient.disconnect(); - } - - // 断开蓝牙连接 - if (bleService.isConnected()) { - bleService.disconnect(); - } + // 先停网关(断开 BLE/MQTT) + await gateway.stop(); // 清除所有本地数据 storage.clearAll(); // 跳转到登录页 wx.reLaunch({ - url: '/pages/login/login' + url: '/pages/login/login', }); } - } + }, }); }, @@ -124,7 +130,7 @@ Page({ wx.showModal({ title: '岩安测控网关', content: '版本:' + this.data.appVersion + '\n\n轻量化蓝牙网关,采集 BLE 设备数据透传上报至 yack 平台。', - showCancel: false + showCancel: false, }); - } -}); \ No newline at end of file + }, +}); diff --git a/yack-mini/miniprogram/pages/mine/mine.wxml b/yack-mini/miniprogram/pages/mine/mine.wxml index 56b348ec..19a4f448 100644 --- a/yack-mini/miniprogram/pages/mine/mine.wxml +++ b/yack-mini/miniprogram/pages/mine/mine.wxml @@ -23,6 +23,10 @@ 蓝牙状态 {{bleStatus}} + + 采集设备 + {{activeDevice}} + 离线缓存 {{pendingCount}} 条 diff --git a/yack-mini/miniprogram/services/ble.js b/yack-mini/miniprogram/services/ble.js index 9e25e57b..4e64352f 100644 --- a/yack-mini/miniprogram/services/ble.js +++ b/yack-mini/miniprogram/services/ble.js @@ -20,6 +20,10 @@ const DEFAULT_CONFIG = { connectTimeout: 10000, // 默认服务 UUID(需根据实际设备配置) serviceUUIDs: [], + // 调试日志开关(高频数据禁止逐包打印,否则阻塞 BLE 接收) + debug: false, + // 掉线自动重连(工业场景建议开启) + autoReconnect: true, }; class BleService { @@ -34,6 +38,19 @@ class BleService { this.config = { ...DEFAULT_CONFIG }; // 监听器注册标志(防止叠加) this._listenersRegistered = false; + // 适配器是否已开启 + this._openedAdapter = false; + // 收包计数/最近收包时间(诊断、静默探测用) + this._recvCount = 0; + this._lastRecvAt = 0; + // 当前会话已启用 notify 的特征列表 + this._notifyCharacteristicIds = []; + // 自动重连相关 + this._lastDeviceId = null; + this._lastServiceId = null; + this._reconnectTimer = null; + this._reconnectAttempts = 0; + this._manuallyDisconnected = false; } /** @@ -73,60 +90,105 @@ class BleService { } /** - * 初始化蓝牙适配器 + * 初始化蓝牙适配器(幂等) + * wx 级全局监听只注册一次,不再每次 off/on 抖动,避免多页/重连切换时的丢包窗口。 * @returns {Promise} */ initBluetooth() { return new Promise((resolve, reject) => { - // 移除旧监听器,防止叠加(微信 API 支持注册多个回调) - if (this._listenersRegistered) { - try { - wx.offBLEConnectionStateChange(); - wx.offBLECharacteristicValueChange(); - } catch (e) { - // 忽略移除失败 - } + // 已开启且监听已挂:直接复用 + if (this._openedAdapter && this._listenersRegistered) { + resolve(); + return; + } + + // 已开适配器但监听未挂(异常恢复路径),补挂监听即可 + const registerListeners = () => { + if (this._listenersRegistered) return; + + wx.onBLEConnectionStateChange((res) => { + console.log('[BLE] 连接状态变化:', res); + if (!res.connected) { + const wasConnected = this.state === BLE_STATE.READY || this.state === BLE_STATE.CONNECTED; + this._setState(BLE_STATE.DISCONNECTED); + // 保留 deviceId 供重连使用(不再置空),掉线自动重连 + if (wasConnected && this.config.autoReconnect && !this._manuallyDisconnected && this._lastDeviceId) { + this._scheduleReconnect(); + } + } + }); + + wx.onBLECharacteristicValueChange((res) => { + // 只处理当前会话设备的值变化,防止切换设备后旧连接/残留连接串包 + if (this.deviceId && res.deviceId && res.deviceId !== this.deviceId) { + return; + } + const hexData = hex.arrayBufferToHex(res.value); + this._recvCount++; + this._lastRecvAt = Date.now(); + // 首包 + 每 20 包打印摘要,debug 时逐包打印,避免高频逐包日志阻塞接收 + if (this.config.debug || this._recvCount === 1 || this._recvCount % 20 === 0) { + console.log(`[BLE] 收到数据 #${this._recvCount} (${hexData.length / 2}字节):`, hexData); + } + if (this.onDataCallback) { + this.onDataCallback({ + hex: hexData, + buffer: res.value, + deviceId: res.deviceId, + serviceId: res.serviceId, + characteristicId: res.characteristicId, + }); + } + }); + + this._listenersRegistered = true; + console.log('[BLE] 已注册全局监听'); + }; + + if (this._openedAdapter) { + registerListeners(); + resolve(); + return; } wx.openBluetoothAdapter({ success: () => { console.log('[BLE] 初始化成功'); - - // 监听连接状态变化(只注册一次) - wx.onBLEConnectionStateChange((res) => { - console.log('[BLE] 连接状态变化:', res); - if (!res.connected) { - this._setState(BLE_STATE.DISCONNECTED); - this.deviceId = null; - } - }); - - // 监听特征值变化(只注册一次) - wx.onBLECharacteristicValueChange((res) => { - const hexData = hex.arrayBufferToHex(res.value); - console.log('[BLE] 收到数据:', hexData); - if (this.onDataCallback) { - this.onDataCallback({ - hex: hexData, - buffer: res.value, - deviceId: res.deviceId, - serviceId: res.serviceId, - characteristicId: res.characteristicId, - }); - } - }); - - this._listenersRegistered = true; + this._openedAdapter = true; + registerListeners(); resolve(); }, fail: (err) => { - console.error('[BLE] 初始化失败:', err); - reject(new Error('蓝牙初始化失败: ' + (err.errMsg || '未知错误'))); + const msg = (err && err.errMsg) || ''; + // 部分平台适配器已开启时 open 会报 already 类错误,视为成功 + if (/already/i.test(msg)) { + console.log('[BLE] 适配器已开启(忽略 already)'); + this._openedAdapter = true; + registerListeners(); + resolve(); + } else { + console.error('[BLE] 初始化失败:', err); + reject(new Error('蓝牙初始化失败: ' + msg || '未知错误')); + } }, }); }); } + /** + * 获取平台合适的 MTU 值 + * @returns {number} + */ + _getMtu() { + try { + const platform = wx.getSystemInfoSync().platform; + // iOS 最大 185,超出会协商失败;Android 建议 247 + return platform === 'ios' ? 185 : 247; + } catch (e) { + return 247; + } + } + /** * 扫描蓝牙设备 * @param {Array} serviceUUIDs - 服务 UUID 列表(可选) @@ -231,6 +293,12 @@ class BleService { this._setState(BLE_STATE.CONNECTING); this.deviceId = deviceId; + // 记录连接目标,供自动重连使用 + this._lastDeviceId = deviceId; + this._lastServiceId = serviceId; + this._manuallyDisconnected = false; + this._clearReconnectTimer(); + const connectTimeout = this.config.connectTimeout; let timeoutId = null; @@ -251,12 +319,14 @@ class BleService { success: () => { clearTimeout(timeoutId); console.log('[BLE] 连接成功:', deviceId); + // 连接成功,重置重连退避计数 + this._reconnectAttempts = 0; this._setState(BLE_STATE.CONNECTED); // 获取 MTU(提高传输效率,iOS 最大 185,Android 建议 247) wx.setBLEMTU({ deviceId: deviceId, - mtu: 247, + mtu: this._getMtu(), complete: () => { // 发现服务 this._discoverServices(deviceId, serviceId) @@ -267,9 +337,47 @@ class BleService { }, fail: (err) => { clearTimeout(timeoutId); + const errMsg = (err && err.errMsg) || ''; + + // 如果报错 "already connect",先断开再重连 + if (/already connect/i.test(errMsg)) { + console.warn('[BLE] 检测到已存在连接,先断开再重连'); + wx.closeBLEConnection({ + deviceId: deviceId, + complete: () => { + // 断开后重新连接 + setTimeout(() => { + wx.createBLEConnection({ + deviceId: deviceId, + success: () => { + console.log('[BLE] 重连成功:', deviceId); + this._reconnectAttempts = 0; + this._setState(BLE_STATE.CONNECTED); + wx.setBLEMTU({ + deviceId: deviceId, + mtu: this._getMtu(), + complete: () => { + this._discoverServices(deviceId, serviceId) + .then(resolve) + .catch(reject); + }, + }); + }, + fail: (err2) => { + this._setState(BLE_STATE.DISCONNECTED); + console.error('[BLE] 重连失败:', err2); + reject(new Error('重连失败: ' + (err2.errMsg || '未知错误'))); + }, + }); + }, 500); // 等待 500ms 确保断开完成 + }, + }); + return; + } + this._setState(BLE_STATE.DISCONNECTED); console.error('[BLE] 连接失败:', err); - reject(new Error('连接失败: ' + (err.errMsg || '未知错误'))); + reject(new Error('连接失败: ' + errMsg || '未知错误')); }, }); }); @@ -331,6 +439,8 @@ class BleService { /** * 发现特征值 + * 目标服务下所有 notify/indicate 特征都尝试启用(数据特征未必是第一个 notify 特征), + * 单个特征启用失败仅告警跳过,至少一个成功即视为就绪。 * @param {string} deviceId * @param {string} serviceId * @returns {Promise} @@ -341,55 +451,58 @@ class BleService { deviceId: deviceId, serviceId: serviceId, success: (res) => { - console.log('[BLE] 发现特征值:', res.characteristics); + console.log('[BLE] 发现特征值:', res.characteristics.map(c => ({ + uuid: c.uuid, + properties: c.properties, + }))); const characteristics = res.characteristics; - // 查找通知特征值(通常具有 notify 或 indicate 属性) - const notifyChar = characteristics.find(c => c.properties.notify || c.properties.indicate); - // 查找写入特征值 + const notifyChars = characteristics.filter(c => c.properties.notify || c.properties.indicate); const writeChar = characteristics.find(c => c.properties.write || c.properties.writeNoResponse); + if (writeChar) { + this.writeCharacteristicId = writeChar.uuid; + console.log('[BLE] 写入特征值:', this.writeCharacteristicId); + } - if (notifyChar) { - this.notifyCharacteristicId = notifyChar.uuid; - console.log('[BLE] 通知特征值:', this.notifyCharacteristicId); - - // 启用通知 - wx.notifyBLECharacteristicValueChange({ - deviceId: deviceId, - serviceId: serviceId, - characteristicId: this.notifyCharacteristicId, - state: true, - success: () => { - console.log('[BLE] 已启用通知'); - if (writeChar) { - this.writeCharacteristicId = writeChar.uuid; - console.log('[BLE] 写入特征值:', this.writeCharacteristicId); - } - this._setState(BLE_STATE.READY); - resolve({ - serviceId: this.serviceId, - notifyCharacteristicId: this.notifyCharacteristicId, - writeCharacteristicId: this.writeCharacteristicId, - }); - }, - fail: (err) => { - console.error('[BLE] 启用通知失败:', err); - reject(new Error('启用通知失败')); - }, - }); - } else { + if (notifyChars.length === 0) { // 没有通知特征值,但可能有写入特征值 - if (writeChar) { - this.writeCharacteristicId = writeChar.uuid; - console.log('[BLE] 写入特征值:', this.writeCharacteristicId); - } + console.warn('[BLE] ⚠️ 未找到 notify/indicate 特征值!设备将无法主动上报数据,只能被动写入。请核对设备的 GATT 服务/特征值。'); + this._notifyCharacteristicIds = []; + this.notifyCharacteristicId = null; + this._lastRecvAt = Date.now(); this._setState(BLE_STATE.READY); resolve({ serviceId: this.serviceId, + notifyCharacteristicIds: [], notifyCharacteristicId: null, writeCharacteristicId: this.writeCharacteristicId, }); + return; } + + // 串行逐个启用 notify + this._enableAllNotify(deviceId, serviceId, notifyChars).then((enabled) => { + this._notifyCharacteristicIds = enabled; + this.notifyCharacteristicId = enabled[0] || null; + if (enabled.length === 0) { + console.error('[BLE] 所有 notify 特征启用均失败'); + reject(new Error('启用通知失败')); + return; + } + console.log('[BLE] 已启用 notify 特征:', enabled); + // 作为后续“静默/掉线”探测的基线 + this._lastRecvAt = Date.now(); + this._setState(BLE_STATE.READY); + resolve({ + serviceId: this.serviceId, + notifyCharacteristicIds: enabled, + notifyCharacteristicId: this.notifyCharacteristicId, + writeCharacteristicId: this.writeCharacteristicId, + }); + }).catch((err) => { + console.error('[BLE] 启用通知异常:', err); + reject(new Error('启用通知失败')); + }); }, fail: (err) => { console.error('[BLE] 获取特征值失败:', err); @@ -399,6 +512,47 @@ class BleService { }); } + /** + * 串行启用多个 notify/indicate 特征 + * @param {string} deviceId + * @param {string} serviceId + * @param {Array} chars - 特征数组(含 uuid) + * @returns {Promise>} 成功启用的特征 UUID 列表 + */ + _enableAllNotify(deviceId, serviceId, chars) { + return chars.reduce((chain, c) => { + return chain.then((enabled) => { + return this._enableNotify(deviceId, serviceId, c.uuid).then((ok) => { + if (ok) enabled.push(c.uuid); + return enabled; + }); + }); + }, Promise.resolve([])); + } + + /** + * 启用单个特征的 notify/indicate + * @returns {Promise} 成功返回特征 UUID,失败返回 null(不抛错) + */ + _enableNotify(deviceId, serviceId, characteristicId) { + return new Promise((resolve) => { + wx.notifyBLECharacteristicValueChange({ + deviceId: deviceId, + serviceId: serviceId, + characteristicId: characteristicId, + state: true, + success: () => { + console.log('[BLE] 启用通知:', characteristicId); + resolve(characteristicId); + }, + fail: (err) => { + console.warn('[BLE] 启用通知失败(跳过):', characteristicId, err); + resolve(null); + }, + }); + }); + } + /** * 写入数据 * @param {string} hexData - 十六进制字符串 @@ -430,12 +584,47 @@ class BleService { }); } + /** + * 清除自动重连定时器 + */ + _clearReconnectTimer() { + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer); + this._reconnectTimer = null; + } + } + + /** + * 调度自动重连(指数退避:1s → 2s → 4s ... 最大 15s) + */ + _scheduleReconnect() { + if (this._reconnectTimer) return; + const delay = Math.min(1000 * Math.pow(2, this._reconnectAttempts), 15000); + this._reconnectAttempts++; + console.log(`[BLE] ${delay}ms 后自动重连(第 ${this._reconnectAttempts} 次)`); + this._reconnectTimer = setTimeout(() => { + this._reconnectTimer = null; + if (!this._lastDeviceId) return; + this.connectDevice(this._lastDeviceId, this._lastServiceId).catch((err) => { + console.error('[BLE] 自动重连失败:', err.message); + this._scheduleReconnect(); + }); + }, delay); + } + /** * 断开连接 */ disconnect() { return new Promise((resolve) => { + // 用户主动断开,停止自动重连 + this._manuallyDisconnected = true; + this._clearReconnectTimer(); + if (!this.deviceId) { + if (this.state !== BLE_STATE.DISCONNECTED) { + this._setState(BLE_STATE.DISCONNECTED); + } resolve(); return; } @@ -451,6 +640,8 @@ class BleService { this.serviceId = null; this.notifyCharacteristicId = null; this.writeCharacteristicId = null; + this._notifyCharacteristicIds = []; + this._lastRecvAt = 0; resolve(); }, }); @@ -491,6 +682,67 @@ class BleService { return this.state === BLE_STATE.READY || this.state === BLE_STATE.CONNECTED; } + /** + * 是否处于可接收数据的就绪态 + * @returns {boolean} + */ + isReady() { + return this.state === BLE_STATE.READY || this.state === BLE_STATE.CONNECTED; + } + + /** + * 是否正处于连接流程中(用于避免与自动重连并发发起 createBLEConnection) + * @returns {boolean} + */ + isConnecting() { + return this.state === BLE_STATE.CONNECTING || this.state === BLE_STATE.DISCOVERING; + } + + /** + * 强制重连当前目标设备(用于静默/掉线恢复) + * @returns {Promise} + */ + forceReconnect() { + if (!this._lastDeviceId) { + return Promise.reject(new Error('无重连目标')); + } + this._reconnectAttempts = 0; + this._manuallyDisconnected = false; + this._clearReconnectTimer(); + console.log('[BLE] 强制重连:', this._lastDeviceId); + return this.connectDevice(this._lastDeviceId, this._lastServiceId); + } + + /** + * 探测连接是否仍活跃(超过 silentMs 无收包视为不活跃) + * @param {number} silentMs - 静默阈值(毫秒) + * @returns {boolean} + */ + probeConnection(silentMs = 30000) { + if (!this.isReady()) return false; + if (!this._lastRecvAt) return true; // 尚未收到任何包,不做静默判定 + return (Date.now() - this._lastRecvAt) <= silentMs; + } + + /** + * 诊断信息(真机排查“收不到/收一条即停”用) + * @returns {Object} + */ + getDiagnostics() { + return { + state: this.state, + adapterOpen: !!this._openedAdapter, + deviceId: this.deviceId, + serviceId: this.serviceId, + notifyCharacteristicId: this.notifyCharacteristicId, + notifyCharacteristicIds: this._notifyCharacteristicIds || [], + writeCharacteristicId: this.writeCharacteristicId, + recvCount: this._recvCount, + lastRecvAt: this._lastRecvAt, + autoReconnect: this.config.autoReconnect, + }; + } + /** * 清除数据回调(页面卸载时调用) */ diff --git a/yack-mini/miniprogram/services/command-manager.js b/yack-mini/miniprogram/services/command-manager.js new file mode 100644 index 00000000..083c24f1 --- /dev/null +++ b/yack-mini/miniprogram/services/command-manager.js @@ -0,0 +1,179 @@ +/** + * 命令管理服务 + */ +const { bleService } = require('./ble.js'); +const cmdStorage = require('../utils/command-storage.js'); +const { LIMITS } = require('../utils/constants.js'); + +class CommandManager { + constructor() { + this._lastSendTime = 0; + this._minSendInterval = 500; // 最小发送间隔(毫秒) + } + + /** + * 发送命令(固定使用BLE) + * @param {string} hex - 十六进制命令 + * @param {string} method - 发送方式:'BLE'(保留参数兼容性) + * @param {Object} device - 设备信息 + * @param {Object} options - 可选参数 + * @returns {Promise} + */ + async sendCommand(hex, method, device, options = {}) { + // 1. 验证命令格式 + if (!this._validateHex(hex)) { + throw new Error('命令格式错误:必须是有效的十六进制字符串'); + } + + // 2. 发送频率限制 + const now = Date.now(); + if (now - this._lastSendTime < this._minSendInterval) { + throw new Error('发送频率过快,请稍后再试'); + } + this._lastSendTime = now; + + // 3. 使用BLE发送 + let result; + try { + result = await this._sendViaBle(hex, device); + + // 4. 记录发送历史 + this._recordHistory(hex, 'BLE', device, 'success', options.name); + + return result; + } catch (err) { + // 记录失败历史 + this._recordHistory(hex, 'BLE', device, 'failed', options.name, err.message); + throw err; + } + } + + /** + * 通过 BLE 发送 + * @param {string} hex + * @param {Object} device + * @returns {Promise} + */ + async _sendViaBle(hex, device) { + // 检查 BLE 连接状态 + if (!bleService.isReady()) { + throw new Error('BLE 未连接,请先连接设备'); + } + + // 检查是否连接的是目标设备 + if (bleService.deviceId !== device.deviceId) { + throw new Error('BLE 连接的设备不匹配'); + } + + // 发送命令 + await bleService.writeCommand(hex); + return { + success: true, + method: 'BLE', + message: 'BLE 发送成功', + }; + } + + /** + * 验证十六进制格式 + * @param {string} hex + * @returns {boolean} + */ + _validateHex(hex) { + if (!hex || typeof hex !== 'string') { + return false; + } + // 移除空格 + hex = hex.replace(/\s+/g, ''); + // 检查是否只包含 0-9, A-F, a-f + return /^[0-9A-Fa-f]+$/.test(hex) && hex.length % 2 === 0; + } + + /** + * 记录发送历史 + */ + _recordHistory(hex, method, device, status, name, error) { + cmdStorage.addHistory({ + hex, + method, + deviceId: device.serialNumber, + deviceName: device.name, + name: name || '自定义命令', + status, + error: error || null, + }); + } + + /** + * 获取预设命令列表 + * @param {string} deviceId + * @returns {Array} + */ + getCommands(deviceId) { + return cmdStorage.getCommands(deviceId); + } + + /** + * 添加预设命令 + * @param {Object} command + * @returns {boolean} + */ + addCommand(command) { + return cmdStorage.addCommand(command); + } + + /** + * 更新预设命令 + * @param {Object} command + * @returns {boolean} + */ + updateCommand(command) { + return cmdStorage.updateCommand(command); + } + + /** + * 删除预设命令 + * @param {string} id + */ + removeCommand(id) { + cmdStorage.removeCommand(id); + } + + /** + * 获取发送历史(只返回当天的) + * @param {string} deviceId + * @returns {Array} + */ + getHistory(deviceId) { + return cmdStorage.getHistory(deviceId); + } + + /** + * 清空发送历史 + */ + clearHistory() { + cmdStorage.clearHistory(); + } + + /** + * 更新命令使用次数 + * @param {string} id + */ + incrementUseCount(id) { + const commands = cmdStorage.getCommands(); + const command = commands.find(c => c.id === id); + if (command) { + command.useCount = (command.useCount || 0) + 1; + command.lastUsedTime = Date.now(); + cmdStorage.saveCommands(commands); + } + } +} + +// 导出单例 +const commandManager = new CommandManager(); + +module.exports = { + CommandManager, + commandManager, +}; \ No newline at end of file diff --git a/yack-mini/miniprogram/services/gateway.js b/yack-mini/miniprogram/services/gateway.js new file mode 100644 index 00000000..60a48ab0 --- /dev/null +++ b/yack-mini/miniprogram/services/gateway.js @@ -0,0 +1,590 @@ +/** + * 网关采集服务(应用级常驻,单 enabled 设备) + * + * 职责:对当前唯一 enabled 的设备,自动连接 BLE + MQTT, + * 持续接收 BLE notify 帧并透传 publish 到 yack 平台, + * 跨页面/跨 tab 运行;MQTT 不可用时帧进离线缓存,恢复后自动补传。 + * + * 页面通过 gateway.subscribe(cb) + gateway.getSnapshot() 订阅实时状态与数据, + * 不再由页面各自持有 BLE/MQTT 连接。 + * + * 并发模型:所有启停/切换操作经 _chain() 串行排队,避免“旧 boot 未结束即切新设备” + * 造成漏启/双连。数据通路(收帧→上屏/上报)独立于该队列。 + */ +const { bleService, BLE_STATE } = require('./ble.js'); +const { mqttClient, MQTT_STATE } = require('./mqtt-client.js'); +const auth = require('../utils/auth.js'); +const storage = require('../utils/storage.js'); +const { TIMEOUT, LIMITS, RETRY } = require('../utils/constants.js'); + +// 网关阶段 +const GATEWAY_PHASE = { + IDLE: 'idle', // 无目标设备 + STARTING: 'starting', // 正在连接(BLE/MQTT 未就绪) + RUNNING: 'running', // BLE 已就绪,正在采集(MQTT 可选) + ERROR: 'error', // 启动失败,等待重试 + STOPPED: 'stopped', // 已停止 +}; + +const GATEWAY_PHASE_TEXT = { + idle: '未启用', + starting: '连接中', + running: '采集中', + error: '异常', + stopped: '已停止', +}; + +const DEFAULTS = { + FLUSH_MS: TIMEOUT.FLUSH_INTERVAL, // 收帧上屏/上报节流 + MAX_LIST: LIMITS.MAX_DATA_LIST, // 屏幕保留条数 + INIT_RETRY_MS: RETRY.INIT_RETRY_INTERVAL,// 启动失败自动重试间隔 + STALL_MS: 30000, // BLE 静默阈值 + MQTT_CONNECT_TIMEOUT: TIMEOUT.MQTT_CONNECT, // MQTT 首连超时保护 +}; + +class Gateway { + constructor() { + this._phase = GATEWAY_PHASE.IDLE; + this._device = null; // 正在采集的设备对象 + this._bleReady = false; + this._mqttReady = false; + + // 数据与统计 + this._pending = []; // 待上屏/上报缓冲 + this._dataList = []; // 上屏列表(最新在前) + this._stats = { received: 0, uploaded: 0, failed: 0 }; + this._offlineCount = 0; + + // 诊断/生命周期 + this._lastDataAt = 0; + this._hiddenAt = 0; + this._lastError = null; + + // 定时器与串行队列 + this._flushTimer = null; + this._initRetryTimer = null; + this._flushingOffline = false; + this._chainTail = Promise.resolve(); + + // 页面订阅 + this._subs = new Set(); + + // 唯一数据来源:网关持有 BLE/MQTT 回调 + bleService.onData((d) => this._onBleData(d)); + bleService.onStateChange((s) => this._onBleState(s)); + mqttClient.onStateChange((s) => this._onMqttState(s)); + } + + /* ===================== 对外主流程(经串行队列) ===================== */ + + /** + * 确保采集器运行:以“唯一 enabled 设备”为目标。 + */ + ensureStarted() { + return this._chain(() => this._ensureStartedNow()); + } + + /** + * 启动采集(对目标设备)。不同设备会自动先拆旧会话并重置展示数据。 + * @param {Object} device - 设备配置(serialNumber/productId/productSecret/deviceId/serviceUUID/enabled...) + */ + start(device) { + return this._chain(() => this._startNow(device || this._device)); + } + + /** + * 停止采集(保留已上屏数据与统计,便于回看;拆设备时才 _resetFeed)。 + */ + stop() { + return this._chain(() => this._stopNow()); + } + + /** 切后台(onHide):记录时间,不 teardown */ + suspend() { + this._hiddenAt = Date.now(); + this._clearInitRetry(); + } + + /** 回前台(onShow):补起连接、探测静默并重连、补传离线 */ + resume() { + const bgMs = this._hiddenAt ? Date.now() - this._hiddenAt : 0; + this._hiddenAt = 0; + this._flush(); + + if (!this._hasUser()) return Promise.resolve(); + + const enabled = storage.getEnabledDevice(); + // 无目标 → 若在跑则停;目标变化/未就绪 → 交给队列处理 + if (!enabled || !this._device || this._device.serialNumber !== enabled.serialNumber || !this._bleReady) { + return this.ensureStarted(); + } + + // 已就绪但静默过久 / 后台超过 5s → 探测并强制重连 + const silentMs = Date.now() - (this._lastDataAt || Date.now()); + if (bgMs > 5000 || silentMs > DEFAULTS.STALL_MS) { + console.log('[GW] 后台时长:', bgMs, 'ms, 静默时长:', silentMs, 'ms'); + + // 检查 BLE 连接是否真的断开了 + if (!bleService.isReady()) { + console.warn('[GW] BLE 未就绪,尝试重连'); + return bleService.forceReconnect().catch((err) => { + this._lastError = err.message || '重连失败'; + this._notify(); + console.error('[GW] BLE 重连失败:', err); + }); + } + + // 探测连接是否仍然活跃 + if (!bleService.probeConnection(DEFAULTS.STALL_MS)) { + console.warn('[GW] BLE 静默/掉线,强制重连'); + return bleService.forceReconnect().catch((err) => { + this._lastError = err.message || '重连失败'; + this._notify(); + console.error('[GW] BLE 重连失败:', err); + }); + } + } + + // MQTT 若断开:底层 lib 会按 reconnectPeriod 自连,重连成功后 + // 经 _onMqttState(CONNECTED) 自动补传离线,此处不重复 connect 以免建重复连接。 + if (this._mqttReady) this._flushOffline(); + return Promise.resolve(); + } + + /** 清空展示数据与统计(不触碰离线缓存) */ + clearHistory() { + this._pending = []; + this._dataList = []; + this._stats = { received: 0, uploaded: 0, failed: 0 }; + this._lastDataAt = 0; + this._notify(); + } + + /** 手动补传离线缓存(当前设备) */ + flushOffline() { + return this._flushOffline(); + } + + /* ===================== 页面订阅 ===================== */ + + /** + * 订阅网关快照 + * @param {Function} cb - (snapshot) => void + * @returns {Function} 退订函数 + */ + subscribe(cb) { + if (typeof cb !== 'function') return () => {}; + this._subs.add(cb); + return () => this._subs.delete(cb); + } + + /** + * 当前快照 + * @returns {Object} + */ + getSnapshot() { + return { + phase: this._phase, + phaseText: GATEWAY_PHASE_TEXT[this._phase] || this._phase, + device: this._device ? { ...this._device } : null, + bleConnected: this._bleReady, + mqttConnected: this._mqttReady, + mqttState: mqttClient.getState(), + bleState: bleService.getState(), + // 浅拷贝元素,确保 setData diff 能识别 uploaded 等字段变化并触发视图刷新 + dataList: this._dataList.map(it => ({ ...it })), + stats: { ...this._stats }, + offlineCount: this._offlineCount, + lastError: this._lastError, + }; + } + + /** 当前采集设备(浅拷贝) */ + getCurrentDevice() { + return this._device ? { ...this._device } : null; + } + + /* ===================== 内部:串行队列 ===================== */ + + _chain(task) { + const result = this._chainTail.then(() => task()); + // 队列尾不因某次操作失败而中断 + this._chainTail = result.catch(() => {}); + return result; + } + + async _ensureStartedNow() { + if (!this._hasUser()) return; + const enabled = storage.getEnabledDevice(); + if (!enabled) { + await this._stopNow(); + return; + } + await this._startNow(enabled); + } + + async _startNow(device) { + if (!device || !device.serialNumber) return; + this._clearInitRetry(); + + const same = !!(this._device && this._device.serialNumber === device.serialNumber); + if (same) { + // 已在目标会话:BLE 就绪且已运行则幂等返回(MQTT 断开交给 lib 自连 + 失败重试) + if (this._bleReady && this._phase === GATEWAY_PHASE.RUNNING) return; + await this._boot(); + return; + } + if (this._device) { + // 切设备:先拆旧会话(保留旧 feed),再启动新会话并重置展示 + await this._teardown(); + this._resetFeed(); + } + this._device = device; + this._lastError = null; + if (this._phase !== GATEWAY_PHASE.STARTING) this._setPhase(GATEWAY_PHASE.STARTING); + this._notify(); + await this._boot(); + } + + async _stopNow() { + this._clearInitRetry(); + if (!this._device && this._phase === GATEWAY_PHASE.STOPPED) return; + await this._teardown(); + } + + /* ===================== 内部:启动/停止 ===================== */ + + async _boot() { + const device = this._device; + if (!device) return; + const alive = () => !!(this._device && this._device.serialNumber === device.serialNumber); + + try { + // 1. BLE:确保适配器打开并连接目标(已就绪同设备则复用) + await bleService.initBluetooth(); + if (!alive()) return; + if (!bleService.isReady() || bleService.deviceId !== device.deviceId) { + // 若已有连接/自动重连在途,不重复发起,等其就绪或中止 + if (bleService.isConnecting()) { + await this._waitBleReady(); + } else { + await bleService.connectDevice(device.deviceId, device.serviceUUID || null); + } + if (!alive()) return; + // 等待/连接后仍未就绪且无连接在途 → 显式再连一次 + if (!bleService.isReady() && !bleService.isConnecting()) { + await bleService.connectDevice(device.deviceId, device.serviceUUID || null); + } + } + if (!alive()) return; + this._bleReady = true; + this._setPhase(GATEWAY_PHASE.RUNNING); + this._notify(); + + // 2. MQTT:已连同一 clientId 时 connect() 内部复用;带超时保护 + const creds = auth.getMqttCredentialsFromDevice(device); + if (!creds) { + throw new Error('获取 MQTT 凭证失败,请检查设备配置'); + } + + console.log('[GW] 开始连接 MQTT...'); + await this._connectMqtt(creds); + + if (!alive()) return; + this._mqttReady = true; + this._flushOffline(); + console.log('[GW] 采集器运行中:', device.serialNumber); + } catch (err) { + if (!alive()) return; + console.error('[GW] 启动失败:', err); + + // 提供更友好的错误提示 + let errorMsg = '启动失败'; + if (err.message) { + if (err.message.includes('MQTT')) { + errorMsg = err.message; + } else if (err.message.includes('BLE') || err.message.includes('蓝牙')) { + errorMsg = '蓝牙连接失败: ' + err.message; + } else if (err.message.includes('凭证')) { + errorMsg = err.message; + } else { + errorMsg = '启动失败: ' + err.message; + } + } + + this._lastError = errorMsg; + this._setPhase(GATEWAY_PHASE.ERROR); + this._scheduleInitRetry(); + } + } + + /** + * MQTT 连接(带超时保护,避免首连卡死阻塞切换队列) + */ + _connectMqtt(creds) { + return new Promise((resolve, reject) => { + let timedOut = false; + let timer = null; + + timer = setTimeout(() => { + timedOut = true; + timer = null; + console.warn('[GW] MQTT 连接超时,断开重试'); + mqttClient.disconnect().catch(() => {}); + reject(new Error('MQTT 连接超时')); + }, DEFAULTS.MQTT_CONNECT_TIMEOUT); + + mqttClient.connect(creds) + .then(() => { + if (timer) clearTimeout(timer); + if (!timedOut) { + resolve(); + } + }) + .catch((err) => { + if (timer) clearTimeout(timer); + if (!timedOut) { + reject(err); + } + }); + }); + } + + /** + * 轮询等待 BLE 就绪或连接中止(避免与自动重连并发) + * @param {number} timeoutMs + * @returns {Promise} + */ + _waitBleReady(timeoutMs = 15000) { + return new Promise((resolve) => { + const start = Date.now(); + const tick = () => { + if (!this._device) { resolve(); return; } + if (bleService.isReady() || !bleService.isConnecting() || Date.now() - start > timeoutMs) { + resolve(); + return; + } + setTimeout(tick, 200); + }; + tick(); + }); + } + + async _teardown() { + this._clearInitRetry(); + this._flush(); // 先处理缓冲(此时 _device 仍在) + this._bleReady = false; + this._mqttReady = false; + this._device = null; // 先置空,避免断开回调把 phase 打回 starting + try { await bleService.disconnect(); } catch (e) { /* 忽略 */ } + try { await mqttClient.disconnect(); } catch (e) { /* 忽略 */ } + this._lastError = null; + this._setPhase(GATEWAY_PHASE.STOPPED); + this._notify(); + } + + _resetFeed() { + this._pending = []; + this._dataList = []; + this._stats = { received: 0, uploaded: 0, failed: 0 }; + this._lastDataAt = 0; + this._notify(); + } + + /* ===================== 内部:状态回接 ===================== */ + + _onBleState(s) { + if (!this._device) return; + const ready = s === BLE_STATE.READY || s === BLE_STATE.CONNECTED; + this._bleReady = ready; + if (ready) { + if (this._phase !== GATEWAY_PHASE.RUNNING) this._setPhase(GATEWAY_PHASE.RUNNING); + } else { + // 目标会话仍在但 BLE 断开 → 自动重连中 + if (this._phase !== GATEWAY_PHASE.ERROR) this._setPhase(GATEWAY_PHASE.STARTING); + } + this._notify(); + } + + _onMqttState(s) { + this._mqttReady = s === MQTT_STATE.CONNECTED; + if (this._mqttReady) this._flushOffline(); + this._notify(); + } + + /* ===================== 内部:数据通路 ===================== */ + + _onBleData(d) { + if (!this._device || !d || !d.hex) return; + this._stats.received++; + this._lastDataAt = Date.now(); + this._pending.push({ + id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + hex: d.hex, + time: this._fmtTime(new Date()), + uploaded: false, + }); + if (!this._flushTimer) { + this._flushTimer = setTimeout(() => { + this._flushTimer = null; + this._flush(); + }, DEFAULTS.FLUSH_MS); + } + } + + /** 节流上屏 + 上报/离线缓存,一次处理一批 */ + _flush() { + if (this._flushTimer) { + clearTimeout(this._flushTimer); + this._flushTimer = null; + } + const batch = this._pending; + this._pending = []; + if (batch.length === 0) return; + + this._dataList = batch.concat(this._dataList).slice(0, DEFAULTS.MAX_LIST); + + const device = this._device; + const offline = []; + for (const item of batch) { + if (this._mqttReady && device && device.enabled) { + this._upload(item); // fire-and-forget + } else { + offline.push(this._toOfflineItem(item, device)); + } + } + if (offline.length > 0) { + storage.cachePendingDataBatch(offline); + this._refreshOfflineCount(); + } + this._notify(); + } + + _upload(item) { + const device = this._device; + if (!device) return; + const topic = auth.buildPropertyPostTopic(device.productId, device.serialNumber); + mqttClient.publish(topic, item.hex) + .then(() => { + item.uploaded = true; + this._stats.uploaded++; + this._notify(); + }) + .catch((err) => { + console.warn('[GW] 上报失败转离线:', err && err.message); + this._stats.failed++; + if (this._device) { + storage.cachePendingData(this._toOfflineItem(item, this._device)); + this._refreshOfflineCount(); + } + this._notify(); + }); + } + + _toOfflineItem(item, device) { + if (!device) return null; + return { + deviceId: device.serialNumber, + productId: device.productId, + hex: item.hex, + dataId: item.id, + }; + } + + /** 补传当前设备的离线缓存(成功后仅移除本设备项,其他设备残留保留) */ + async _flushOffline() { + if (this._flushingOffline) return; + this._flushingOffline = true; + try { + const device = this._device; + if (!device || !this._mqttReady) return; + const list = storage.getPendingData(); + if (!list || list.length === 0) return; + + const topic = auth.buildPropertyPostTopic(device.productId, device.serialNumber); + const remaining = []; + for (const it of list) { + if (it.deviceId === device.serialNumber) { + try { + await mqttClient.publish(topic, it.hex); + this._stats.uploaded++; + } catch (err) { + console.warn('[GW] 离线补传失败,保留:', it.hex, err && err.message); + remaining.push(it); + } + } else { + remaining.push(it); + } + } + storage.setPendingData(remaining); + this._refreshOfflineCount(); + this._notify(); + } finally { + this._flushingOffline = false; + } + } + + /* ===================== 内部:工具 ===================== */ + + _hasUser() { + const info = storage.getUserInfo(); + return !!(info && info.userId); + } + + _setPhase(p) { + if (this._phase !== p) { + this._phase = p; + console.log('[GW] 阶段变更:', p); + } + } + + _notify() { + const snap = this.getSnapshot(); + this._subs.forEach((cb) => { + try { + cb(snap); + } catch (e) { + console.error('[GW] 订阅回调异常:', e); + } + }); + } + + _refreshOfflineCount() { + this._offlineCount = storage.getPendingData().length; + } + + _scheduleInitRetry() { + if (this._initRetryTimer) return; + if (!this._device) return; + this._initRetryTimer = setTimeout(() => { + this._initRetryTimer = null; + if (!this._device) return; + console.log('[GW] 启动失败,自动重试...'); + this.start(this._device); + }, DEFAULTS.INIT_RETRY_MS); + } + + _clearInitRetry() { + if (this._initRetryTimer) { + clearTimeout(this._initRetryTimer); + this._initRetryTimer = null; + } + } + + _fmtTime(date) { + const h = date.getHours().toString().padStart(2, '0'); + const m = date.getMinutes().toString().padStart(2, '0'); + const s = date.getSeconds().toString().padStart(2, '0'); + return `${h}:${m}:${s}`; + } +} + +// 导出单例 +const gateway = new Gateway(); + +module.exports = { + GATEWAY_PHASE, + GATEWAY_PHASE_TEXT, + Gateway, + gateway, +}; diff --git a/yack-mini/miniprogram/services/mqtt-client.js b/yack-mini/miniprogram/services/mqtt-client.js index 615ec022..a8e7c0a7 100644 --- a/yack-mini/miniprogram/services/mqtt-client.js +++ b/yack-mini/miniprogram/services/mqtt-client.js @@ -19,7 +19,6 @@ class MqttClient { this.onMessageCallback = null; this.onStateChangeCallback = null; this.reconnectAttempts = 0; - this.maxReconnectAttempts = 5; // 当前连接的 clientId(用于检测连接复用) this.currentClientId = null; } @@ -65,6 +64,20 @@ class MqttClient { return new Promise((resolve, reject) => { const newClientId = options.clientId; + // 检查网络连接 + wx.getNetworkType({ + success: (res) => { + if (res.networkType === 'none') { + reject(new Error('网络未连接,请检查网络设置')); + return; + } + console.log('[MQTT] 网络类型:', res.networkType); + }, + fail: () => { + console.warn('[MQTT] 无法获取网络状态'); + } + }); + // 检查是否需要重建连接(clientId 变化或未连接) if (this.client && this.client.connected) { if (this.currentClientId === newClientId) { @@ -96,6 +109,11 @@ class MqttClient { console.log('[MQTT] 正在连接:', server, 'clientId:', options.clientId); + // 检查服务器地址协议 + if (server.startsWith('ws://') && !__wxConfig) { + console.warn('[MQTT] 真机环境建议使用 wss:// 或 wxs:// 协议'); + } + try { this.client = mqtt.connect(server, connectOptions); this.currentClientId = options.clientId; @@ -113,13 +131,28 @@ class MqttClient { // 连接错误 this.client.on('error', (err) => { console.error('[MQTT] 连接错误:', err); - // 强制关闭客户端,停止重连 - if (this.client) { + const errMsg = err.message || '未知错误'; + + // 提供更友好的错误提示 + let userMsg = 'MQTT 连接失败'; + if (errMsg.includes('ECONNREFUSED')) { + userMsg = 'MQTT 服务器拒绝连接,请检查服务器配置'; + } else if (errMsg.includes('ENOTFOUND')) { + userMsg = 'MQTT 服务器地址解析失败,请检查地址'; + } else if (errMsg.includes('ETIMEDOUT') || errMsg.includes('timeout')) { + userMsg = 'MQTT 连接超时,请检查网络或服务器状态'; + } else if (errMsg.includes('WebSocket')) { + userMsg = 'WebSocket 连接失败,请检查协议(wss:// 或 wxs://)'; + } + + // 仅首次连接失败时关闭并交由调用方(gateway)重试; + // lib 自动重连期间(reconnecting=true)的 error 不拆客户端,让 reconnectPeriod 自愈 + if (this.client && !this.client.reconnecting) { this.client.end(true); this.client = null; } this._setState(MQTT_STATE.DISCONNECTED); - reject(new Error('MQTT 连接失败: ' + (err.message || '未知错误'))); + reject(new Error(userMsg + ': ' + errMsg)); }); // 断开连接 @@ -128,15 +161,12 @@ class MqttClient { this._setState(MQTT_STATE.DISCONNECTED); }); - // 重连 + // 重连(底层 mqtt lib 按 reconnectPeriod 自连,网关语义永不放弃; + // 首次连接失败后的主动重试由 gateway 负责) this.client.on('reconnect', () => { this.reconnectAttempts++; console.log('[MQTT] 正在重连, 尝试次数:', this.reconnectAttempts); this._setState(MQTT_STATE.CONNECTING); - if (this.reconnectAttempts > this.maxReconnectAttempts) { - console.log('[MQTT] 重连次数超限,停止重连'); - this.disconnect(); - } }); // 收到消息 @@ -162,7 +192,7 @@ class MqttClient { } catch (err) { console.error('[MQTT] 创建客户端失败:', err); this._setState(MQTT_STATE.DISCONNECTED); - reject(new Error('创建 MQTT 客户端失败')); + reject(new Error('创建 MQTT 客户端失败: ' + (err.message || '未知错误'))); } }); } diff --git a/yack-mini/miniprogram/utils/command-storage.js b/yack-mini/miniprogram/utils/command-storage.js new file mode 100644 index 00000000..c9f5d35e --- /dev/null +++ b/yack-mini/miniprogram/utils/command-storage.js @@ -0,0 +1,199 @@ +/** + * 命令存储工具 + */ + +const COMMAND_KEY = 'yack_commands'; // 预设命令 +const HISTORY_KEY = 'yack_command_history'; // 发送历史 + +/** + * 获取预设命令列表 + * @param {string} deviceId - 设备编号(可选,不传则获取所有) + * @returns {Array} + */ +function getCommands(deviceId) { + try { + const commands = wx.getStorageSync(COMMAND_KEY) || []; + if (deviceId) { + return commands.filter(cmd => cmd.deviceId === deviceId); + } + return commands; + } catch (e) { + console.error('[CommandStorage] getCommands error:', e); + return []; + } +} + +/** + * 保存预设命令列表 + * @param {Array} commands + */ +function saveCommands(commands) { + try { + wx.setStorageSync(COMMAND_KEY, commands || []); + } catch (e) { + console.error('[CommandStorage] saveCommands error:', e); + } +} + +/** + * 添加预设命令 + * @param {Object} command + */ +function addCommand(command) { + const commands = getCommands(); + // 检查是否已存在相同名称的命令 + const exists = commands.find(c => + c.name === command.name && c.deviceId === command.deviceId + ); + if (exists) { + console.warn('[CommandStorage] 命令名称已存在'); + return false; + } + + command.id = `cmd_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + command.createTime = Date.now(); + command.useCount = 0; + // 移除 description 字段,只保留必要信息 + const cleanCommand = { + id: command.id, + name: command.name, + hex: command.hex, + deviceId: command.deviceId, + createTime: command.createTime, + useCount: command.useCount, + lastUsedTime: command.lastUsedTime || 0, + }; + commands.push(cleanCommand); + saveCommands(commands); + return true; +} + +/** + * 更新预设命令 + * @param {Object} command + */ +function updateCommand(command) { + const commands = getCommands(); + const index = commands.findIndex(c => c.id === command.id); + if (index >= 0) { + commands[index] = { ...commands[index], ...command }; + saveCommands(commands); + return true; + } + return false; +} + +/** + * 删除预设命令 + * @param {string} id + */ +function removeCommand(id) { + const commands = getCommands(); + const newCommands = commands.filter(c => c.id !== id); + saveCommands(newCommands); + console.log('[CommandStorage] 已删除命令:', id); +} + +/** + * 获取发送历史(只返回当天的) + * @param {string} deviceId - 设备编号(可选) + * @returns {Array} + */ +function getHistory(deviceId) { + try { + const history = wx.getStorageSync(HISTORY_KEY) || []; + + // 过滤:只保留当天的记录 + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayTimestamp = today.getTime(); + + const todayHistory = history.filter(item => { + const sendTime = item.sendTime || 0; + return sendTime >= todayTimestamp; + }); + + // 如果过滤后的数据有变化,更新存储 + if (todayHistory.length !== history.length) { + setHistory(todayHistory); + } + + // 如果指定了设备,只返回该设备的记录 + if (deviceId) { + return todayHistory.filter(item => item.deviceId === deviceId); + } + + return todayHistory; + } catch (e) { + console.error('[CommandStorage] getHistory error:', e); + return []; + } +} + +/** + * 保存发送历史 + * @param {Array} history + */ +function setHistory(history) { + try { + wx.setStorageSync(HISTORY_KEY, history || []); + } catch (e) { + console.error('[CommandStorage] setHistory error:', e); + } +} + +/** + * 添加发送历史 + * @param {Object} historyItem + */ +function addHistory(historyItem) { + const history = getHistory(); // 自动过滤当天的 + historyItem.id = `history_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + historyItem.sendTime = Date.now(); + history.unshift(historyItem); // 最新的在前面 + + // 限制历史记录数量(最多 100 条) + if (history.length > 100) { + history.splice(100); + } + + setHistory(history); +} + +/** + * 清空发送历史 + */ +function clearHistory() { + try { + wx.removeStorageSync(HISTORY_KEY); + console.log('[CommandStorage] 已清空发送历史'); + } catch (e) { + console.error('[CommandStorage] clearHistory error:', e); + } +} + +/** + * 清空所有命令数据 + */ +function clearAll() { + try { + wx.removeStorageSync(COMMAND_KEY); + wx.removeStorageSync(HISTORY_KEY); + console.log('[CommandStorage] 已清空所有命令数据'); + } catch (e) { + console.error('[CommandStorage] clearAll error:', e); + } +} + +module.exports = { + getCommands, + saveCommands, + addCommand, + updateCommand, + removeCommand, + getHistory, + setHistory, + addHistory, + clearHistory, + clearAll, +}; \ No newline at end of file diff --git a/yack-mini/miniprogram/utils/constants.js b/yack-mini/miniprogram/utils/constants.js new file mode 100644 index 00000000..14ca30f7 --- /dev/null +++ b/yack-mini/miniprogram/utils/constants.js @@ -0,0 +1,41 @@ +/** + * 应用常量配置 + */ + +// 超时配置(毫秒) +const TIMEOUT = { + BLE_SCAN: 10000, + BLE_CONNECT: 10000, + MQTT_CONNECT: 30000, // 增加到 30 秒 + FLUSH_INTERVAL: 100, + MQTT_RECONNECT_DELAY: 3000, // MQTT 重连延迟 +}; + +// 限制配置 +const LIMITS = { + MAX_PENDING_DATA: 1000, // 最大离线缓存数量 + MAX_DEVICE_COUNT: 50, // 最大设备数量 + MAX_DATA_LIST: 50, // 最大数据列表显示数量 +}; + +// 过期配置(天) +const EXPIRE = { + PENDING_DATA: 7, // 离线数据过期天数 +}; + +// 重试配置(毫秒) +const RETRY = { + BLE_RECONNECT_DELAY: 1000, + INIT_RETRY_INTERVAL: 5000, +}; + +// 加密密钥(生产环境应从服务端动态获取) +const CRYPTO_KEY = 'fastbee-mini-2026'; + +module.exports = { + TIMEOUT, + LIMITS, + EXPIRE, + RETRY, + CRYPTO_KEY, +}; \ No newline at end of file diff --git a/yack-mini/miniprogram/utils/crypto.js b/yack-mini/miniprogram/utils/crypto.js new file mode 100644 index 00000000..8c3b85ad --- /dev/null +++ b/yack-mini/miniprogram/utils/crypto.js @@ -0,0 +1,140 @@ +/** + * 加密工具 + * 用于加密存储敏感信息(如产品密钥) + * + * 注意:这是一个简单的加密实现,适用于小程序场景。 + * 生产环境建议使用更安全的加密算法或从服务端动态获取密钥。 + */ + +const { CRYPTO_KEY } = require('./constants.js'); + +/** + * Base64 编码(兼容小程序环境) + * @param {string} str - 字符串 + * @returns {string} Base64 编码字符串 + */ +function base64Encode(str) { + try { + // 使用小程序提供的 ArrayBuffer 方式 + const buffer = new ArrayBuffer(str.length); + const dataView = new DataView(buffer); + for (let i = 0; i < str.length; i++) { + dataView.setUint8(i, str.charCodeAt(i)); + } + return wx.arrayBufferToBase64(buffer); + } catch (e) { + console.error('[Crypto] Base64 编码失败:', e); + return str; + } +} + +/** + * Base64 解码(兼容小程序环境) + * @param {string} base64 - Base64 编码字符串 + * @returns {string} 解码后的字符串 + */ +function base64Decode(base64) { + try { + // 使用小程序提供的 ArrayBuffer 方式 + const buffer = wx.base64ToArrayBuffer(base64); + const bytes = new Uint8Array(buffer); + let str = ''; + for (let i = 0; i < bytes.length; i++) { + str += String.fromCharCode(bytes[i]); + } + return str; + } catch (e) { + console.error('[Crypto] Base64 解码失败:', e); + return base64; + } +} + +/** + * 简单对称加密(XOR + Base64) + * @param {string} text - 明文 + * @param {string} key - 密钥 + * @returns {string} 密文(Base64 编码) + */ +function encrypt(text, key = CRYPTO_KEY) { + if (!text) return ''; + try { + // XOR 加密 + let encrypted = ''; + for (let i = 0; i < text.length; i++) { + const charCode = text.charCodeAt(i) ^ key.charCodeAt(i % key.length); + encrypted += String.fromCharCode(charCode); + } + // Base64 编码 + return base64Encode(encrypted); + } catch (e) { + console.error('[Crypto] 加密失败:', e); + return text; + } +} + +/** + * 解密 + * @param {string} ciphertext - 密文(Base64 编码) + * @param {string} key - 密钥 + * @returns {string} 明文 + */ +function decrypt(ciphertext, key = CRYPTO_KEY) { + if (!ciphertext) return ''; + try { + // Base64 解码 + const encrypted = base64Decode(ciphertext); + // XOR 解密(与加密过程相同) + let decrypted = ''; + for (let i = 0; i < encrypted.length; i++) { + const charCode = encrypted.charCodeAt(i) ^ key.charCodeAt(i % key.length); + decrypted += String.fromCharCode(charCode); + } + return decrypted; + } catch (e) { + console.error('[Crypto] 解密失败:', e); + return ciphertext; + } +} + +/** + * 加密对象中的指定字段 + * @param {Object} obj - 对象 + * @param {Array} fields - 需要加密的字段名数组 + * @returns {Object} 加密后的对象 + */ +function encryptFields(obj, fields) { + if (!obj || typeof obj !== 'object') return obj; + const result = { ...obj }; + fields.forEach(field => { + if (result[field]) { + result[field] = encrypt(result[field]); + result[`_${field}_encrypted`] = true; // 标记已加密 + } + }); + return result; +} + +/** + * 解密对象中的指定字段 + * @param {Object} obj - 对象 + * @param {Array} fields - 需要解密的字段名数组 + * @returns {Object} 解密后的对象 + */ +function decryptFields(obj, fields) { + if (!obj || typeof obj !== 'object') return obj; + const result = { ...obj }; + fields.forEach(field => { + if (result[field] && result[`_${field}_encrypted`]) { + result[field] = decrypt(result[field]); + delete result[`_${field}_encrypted`]; // 移除标记 + } + }); + return result; +} + +module.exports = { + encrypt, + decrypt, + encryptFields, + decryptFields, +}; \ No newline at end of file diff --git a/yack-mini/miniprogram/utils/storage.js b/yack-mini/miniprogram/utils/storage.js index 92d22cb8..97bb67c3 100644 --- a/yack-mini/miniprogram/utils/storage.js +++ b/yack-mini/miniprogram/utils/storage.js @@ -2,11 +2,17 @@ * 本地存储工具 */ +const { LIMITS, EXPIRE } = require('./constants.js'); +const { encryptFields, decryptFields } = require('./crypto.js'); + const TOKEN_KEY = 'yack_token'; const USER_INFO_KEY = 'yack_user_info'; const DEVICE_LIST_KEY = 'yack_device_list'; const PENDING_DATA_KEY = 'yack_pending_data'; +// 需要加密的字段 +const ENCRYPT_FIELDS = ['productSecret']; + /** * 获取 Token * @returns {string|null} @@ -85,7 +91,9 @@ function removeUserInfo() { */ function getDeviceList() { try { - return wx.getStorageSync(DEVICE_LIST_KEY) || []; + const devices = wx.getStorageSync(DEVICE_LIST_KEY) || []; + // 解密敏感字段 + return devices.map(device => decryptFields(device, ENCRYPT_FIELDS)); } catch (e) { console.error('[Storage] getDeviceList error:', e); return []; @@ -93,29 +101,42 @@ function getDeviceList() { } /** - * 保存设备列表 + * 保存设备列表(加密敏感字段) * @param {Array} devices */ function saveDeviceList(devices) { try { - wx.setStorageSync(DEVICE_LIST_KEY, devices); + // 加密敏感字段 + const encryptedDevices = devices.map(device => encryptFields(device, ENCRYPT_FIELDS)); + wx.setStorageSync(DEVICE_LIST_KEY, encryptedDevices); } catch (e) { console.error('[Storage] saveDeviceList error:', e); } } /** - * 添加设备 + * 添加设备(加密敏感字段) * @param {Object} device */ function addDevice(device) { const devices = getDeviceList(); // 检查是否已存在 const index = devices.findIndex(d => d.serialNumber === device.serialNumber); + // 加密敏感字段 + const encryptedDevice = encryptFields(device, ENCRYPT_FIELDS); if (index >= 0) { - devices[index] = device; + devices[index] = encryptedDevice; } else { - devices.push(device); + // 检查设备数量限制 + if (devices.length >= LIMITS.MAX_DEVICE_COUNT) { + console.warn('[Storage] 设备数量已达上限:', LIMITS.MAX_DEVICE_COUNT); + wx.showToast({ + title: `最多添加 ${LIMITS.MAX_DEVICE_COUNT} 个设备`, + icon: 'none', + }); + return; + } + devices.push(encryptedDevice); } saveDeviceList(devices); } @@ -144,7 +165,7 @@ function updateDevice(device) { } /** - * 获取单个设备 + * 获取单个设备(解密敏感字段) * @param {string} serialNumber * @returns {Object|null} */ @@ -154,12 +175,93 @@ function getDevice(serialNumber) { } /** - * 缓存待上报数据 + * 激活设备为唯一启用(其余全部禁用) + * 保证全列表最多一个 enabled=true + * @param {string} serialNumber + */ +function activateDevice(serialNumber) { + // 直接读取加密数据并解密 + const devices = getDeviceList(); + let found = false; + devices.forEach((d) => { + if (d.serialNumber === serialNumber) { + d.enabled = true; + found = true; + } else if (d.enabled) { + d.enabled = false; + } + }); + if (found) { + saveDeviceList(devices); + console.log('[Storage] 激活设备为唯一启用:', serialNumber); + } +} + +/** + * 禁用设备 + * @param {string} serialNumber + */ +function deactivateDevice(serialNumber) { + const devices = getDeviceList(); + const index = devices.findIndex(d => d.serialNumber === serialNumber); + if (index >= 0) { + devices[index].enabled = false; + saveDeviceList(devices); + console.log('[Storage] 禁用设备:', serialNumber); + } +} + +/** + * 获取当前启用的设备(最多一个) + * 存量数据若出现多个 enabled,自动归一化:保留列表中第一个,其余置 false + * @returns {Object|null} + */ +function getEnabledDevice() { + const devices = getDeviceList(); + const enabledList = devices.filter(d => d.enabled); + if (enabledList.length === 0) return null; + if (enabledList.length > 1) { + console.warn('[Storage] 检测到多个启用设备,自动归一化为第一个:', enabledList.map(d => d.serialNumber)); + const keep = enabledList[0].serialNumber; + devices.forEach((d) => { + if (d.serialNumber !== keep) d.enabled = false; + }); + saveDeviceList(devices); + return devices.find(d => d.serialNumber === keep) || null; + } + return enabledList[0]; +} + +/** + * 清理过期的待上报数据 + * @param {Array} pendingList - 待上报数据列表 + * @returns {Array} 清理后的列表 + */ +function _cleanExpiredData(pendingList) { + if (!pendingList || pendingList.length === 0) return []; + const expireTime = EXPIRE.PENDING_DATA * 24 * 60 * 60 * 1000; // 转换为毫秒 + const now = Date.now(); + return pendingList.filter(item => { + const cacheTime = item.cacheTime || 0; + return (now - cacheTime) < expireTime; + }); +} + +/** + * 缓存待上报数据(带限制和过期清理) * @param {Object} data */ function cachePendingData(data) { try { - const pendingList = wx.getStorageSync(PENDING_DATA_KEY) || []; + let pendingList = wx.getStorageSync(PENDING_DATA_KEY) || []; + // 清理过期数据 + pendingList = _cleanExpiredData(pendingList); + // 检查数量限制 + if (pendingList.length >= LIMITS.MAX_PENDING_DATA) { + console.warn('[Storage] 离线缓存已达上限,移除最旧数据'); + pendingList = pendingList.slice(-LIMITS.MAX_PENDING_DATA + 1); + } + // 添加新数据 pendingList.push({ ...data, cacheTime: Date.now(), @@ -172,18 +274,72 @@ function cachePendingData(data) { } /** - * 获取待上报数据 + * 批量缓存待上报数据(一次 setStorageSync,避免高频逐条写磁盘阻塞 BLE 接收) + * 带限制和过期清理 + * @param {Array} items + */ +function cachePendingDataBatch(items) { + if (!items || items.length === 0) return; + try { + let pendingList = wx.getStorageSync(PENDING_DATA_KEY) || []; + // 清理过期数据 + pendingList = _cleanExpiredData(pendingList); + // 添加新数据 + items.forEach((data) => { + // 检查数量限制 + if (pendingList.length >= LIMITS.MAX_PENDING_DATA) { + console.warn('[Storage] 离线缓存已达上限,移除最旧数据'); + pendingList = pendingList.slice(-LIMITS.MAX_PENDING_DATA + 1); + } + pendingList.push({ + ...data, + cacheTime: Date.now(), + }); + }); + // 最终限制检查(确保不超过上限) + if (pendingList.length > LIMITS.MAX_PENDING_DATA) { + pendingList = pendingList.slice(-LIMITS.MAX_PENDING_DATA); + } + wx.setStorageSync(PENDING_DATA_KEY, pendingList); + } catch (e) { + console.error('[Storage] cachePendingDataBatch error:', e); + } +} + +/** + * 获取待上报数据(自动清理过期数据) * @returns {Array} */ function getPendingData() { try { - return wx.getStorageSync(PENDING_DATA_KEY) || []; + let pendingList = wx.getStorageSync(PENDING_DATA_KEY) || []; + // 清理过期数据 + pendingList = _cleanExpiredData(pendingList); + // 如果清理后数据有变化,更新存储 + const originalList = wx.getStorageSync(PENDING_DATA_KEY) || []; + if (pendingList.length !== originalList.length) { + wx.setStorageSync(PENDING_DATA_KEY, pendingList); + console.log('[Storage] 已清理过期离线数据'); + } + return pendingList; } catch (e) { console.error('[Storage] getPendingData error:', e); return []; } } +/** + * 覆写待上报数据(用于补传成功后只移除本设备已上传项) + * @param {Array} list + */ +function setPendingData(list) { + try { + wx.setStorageSync(PENDING_DATA_KEY, list || []); + } catch (e) { + console.error('[Storage] setPendingData error:', e); + } +} + /** * 清除待上报数据 */ @@ -220,8 +376,13 @@ module.exports = { removeDevice, updateDevice, getDevice, + activateDevice, + deactivateDevice, + getEnabledDevice, cachePendingData, + cachePendingDataBatch, getPendingData, + setPendingData, clearPendingData, clearAll, }; \ No newline at end of file