20260905:微信小程序-蓝牙采集网关-支持发送命令
This commit is contained in:
parent
7b611a1334
commit
63d836dd05
@ -16,6 +16,11 @@
|
||||
│ BLE 设备 │ ←──────→ │ yack-mini │ ←──────→ │ yack 平台 │
|
||||
│ (传感器) │ │ (微信小程序) │ MQTT │ (数据存储) │
|
||||
└─────────────┘ └──────────────────┘ └─────────────────┘
|
||||
|
|
||||
|
|
||||
——————————————————
|
||||
| 本地缓存/队列 |
|
||||
——————————————————
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
"pages/index/index",
|
||||
"pages/device/scan/scan",
|
||||
"pages/device/detail/detail",
|
||||
"pages/device/command/command",
|
||||
"pages/mine/mine"
|
||||
],
|
||||
"window": {
|
||||
|
||||
@ -8,6 +8,11 @@ Component({
|
||||
type: Object,
|
||||
value: {},
|
||||
},
|
||||
// 网关是否正在采集该设备(首页由 gateway 快照算出传入)
|
||||
running: {
|
||||
type: Boolean,
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
<view class="device-main">
|
||||
<view class="device-header">
|
||||
<text class="device-name">{{device.name || device.deviceName || '未命名设备'}}</text>
|
||||
<view class="status-tag {{device.connected ? 'online' : 'offline'}}">
|
||||
{{device.connected ? '已连接' : '未连接'}}
|
||||
<view class="status-tag {{running ? 'online' : 'offline'}}">
|
||||
{{running ? '采集中' : (device.enabled ? '连接中' : '未启用')}}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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',
|
||||
};
|
||||
|
||||
362
yack-mini/miniprogram/pages/device/command/command.js
Normal file
362
yack-mini/miniprogram/pages/device/command/command.js
Normal file
@ -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}`;
|
||||
},
|
||||
});
|
||||
4
yack-mini/miniprogram/pages/device/command/command.json
Normal file
4
yack-mini/miniprogram/pages/device/command/command.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "发送命令",
|
||||
"usingComponents": {}
|
||||
}
|
||||
195
yack-mini/miniprogram/pages/device/command/command.wxml
Normal file
195
yack-mini/miniprogram/pages/device/command/command.wxml
Normal file
@ -0,0 +1,195 @@
|
||||
<!-- 命令发送页面 -->
|
||||
<view class="container">
|
||||
<!-- Tab 切换 -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
class="tab-item {{activeTab === 0 ? 'active' : ''}}"
|
||||
data-index="{{0}}"
|
||||
bindtap="switchTab"
|
||||
>
|
||||
预设命令
|
||||
</view>
|
||||
<view
|
||||
class="tab-item {{activeTab === 1 ? 'active' : ''}}"
|
||||
data-index="{{1}}"
|
||||
bindtap="switchTab"
|
||||
>
|
||||
自定义
|
||||
</view>
|
||||
<view
|
||||
class="tab-item {{activeTab === 2 ? 'active' : ''}}"
|
||||
data-index="{{2}}"
|
||||
bindtap="switchTab"
|
||||
>
|
||||
发送历史
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Tab 内容 -->
|
||||
<view class="tab-content">
|
||||
<!-- 预设命令 -->
|
||||
<view wx:if="{{activeTab === 0}}" class="commands-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">预设命令</text>
|
||||
<button class="btn-add" size="mini" bindtap="handleAddCommand">+ 添加</button>
|
||||
</view>
|
||||
|
||||
<view class="command-list" wx:if="{{commands.length > 0}}">
|
||||
<view class="command-item" wx:for="{{commands}}" wx:key="id">
|
||||
<view class="command-main" data-index="{{index}}" bindtap="handleSelectCommand">
|
||||
<view class="command-name">{{item.name}}</view>
|
||||
<view class="command-hex">{{item.hex}}</view>
|
||||
<!-- <view class="command-desc" wx:if="{{item.description}}">{{item.description}}</view> -->
|
||||
<view class="command-meta">
|
||||
使用 {{item.useCount || 0}} 次
|
||||
</view>
|
||||
</view>
|
||||
<view class="command-actions">
|
||||
<button
|
||||
class="btn-edit"
|
||||
size="mini"
|
||||
data-index="{{index}}"
|
||||
catchtap="handleEditCommand"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="btn-delete"
|
||||
size="mini"
|
||||
data-index="{{index}}"
|
||||
catchtap="handleDeleteCommand"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty" wx:else>
|
||||
<text class="empty-text">暂无预设命令</text>
|
||||
<text class="empty-hint">点击上方"添加"按钮创建常用命令</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 自定义命令 -->
|
||||
<view wx:if="{{activeTab === 1}}" class="custom-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">自定义命令</text>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="form-label">命令名称(可选)</view>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如:开灯"
|
||||
value="{{customName}}"
|
||||
bindinput="onCustomNameInput"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="form-label">十六进制命令</view>
|
||||
<input
|
||||
class="form-input hex-input"
|
||||
placeholder="如:A1 01 FF"
|
||||
value="{{customHex}}"
|
||||
bindinput="onCustomHexInput"
|
||||
/>
|
||||
<view class="form-hint">示例:A101FF 或 A1 01 FF(空格会自动去除)</view>
|
||||
</view>
|
||||
|
||||
<view class="connection-status">
|
||||
<view class="status-text">BLE 状态:</view>
|
||||
<view class="status-value {{bleConnected ? 'connected' : 'disconnected'}}">
|
||||
{{bleConnected ? '已连接' : '未连接'}}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="btn-send" bindtap="handleSendCustom" disabled="{{!bleConnected}}">
|
||||
发送命令
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 发送历史 -->
|
||||
<view wx:if="{{activeTab === 2}}" class="history-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">今天的发送历史</text>
|
||||
<button class="btn-clear" size="mini" bindtap="handleClearHistory">清空</button>
|
||||
</view>
|
||||
|
||||
<view class="history-list" wx:if="{{history.length > 0}}">
|
||||
<view class="history-item" wx:for="{{history}}" wx:key="id">
|
||||
<view class="history-header">
|
||||
<view class="history-name">{{item.name}}</view>
|
||||
<view class="history-time">{{item.sendTime}}</view>
|
||||
</view>
|
||||
<view class="history-content">
|
||||
<view class="history-hex">{{item.hex}}</view>
|
||||
<view class="history-meta">
|
||||
<text class="method-tag">{{item.method}}</text>
|
||||
<text class="status-tag {{item.status}}">{{item.status === 'success' ? '成功' : '失败'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="history-error" wx:if="{{item.error}}">
|
||||
错误:{{item.error}}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty" wx:else>
|
||||
<text class="empty-text">今天暂无发送记录</text>
|
||||
<text class="empty-hint">发送的命令将在此显示</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 编辑命令弹窗 -->
|
||||
<view class="modal-mask" wx:if="{{showEditModal}}" catchtap="handleCancelEdit">
|
||||
<view class="modal-content" catchtap="stopPropagation">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">{{editMode === 'add' ? '添加命令' : '编辑命令'}}</text>
|
||||
</view>
|
||||
|
||||
<view class="modal-body">
|
||||
<view class="form-item">
|
||||
<view class="form-label">命令名称 *</view>
|
||||
<input
|
||||
class="form-input"
|
||||
placeholder="如:开灯"
|
||||
value="{{editName}}"
|
||||
bindinput="onEditNameInput"
|
||||
catchtap="stopPropagation"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="form-label">十六进制命令 *</view>
|
||||
<input
|
||||
class="form-input hex-input"
|
||||
placeholder="如:A1 01 FF"
|
||||
value="{{editHex}}"
|
||||
bindinput="onEditHexInput"
|
||||
catchtap="stopPropagation"
|
||||
/>
|
||||
<view class="form-hint">示例:A101FF 或 A1 01 FF</view>
|
||||
</view>
|
||||
|
||||
<!-- <view class="form-item">
|
||||
<view class="form-label">命令说明(可选)</view>
|
||||
<textarea
|
||||
class="form-textarea"
|
||||
placeholder="如:打开设备灯光"
|
||||
value="{{editDescription}}"
|
||||
bindinput="onEditDescriptionInput"
|
||||
catchtap="stopPropagation"
|
||||
/>
|
||||
</view> -->
|
||||
</view>
|
||||
|
||||
<view class="modal-footer">
|
||||
<button class="btn-cancel" catchtap="handleCancelEdit">取消</button>
|
||||
<button class="btn-confirm" bindtap="handleSaveCommand">保存</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
439
yack-mini/miniprogram/pages/device/command/command.wxss
Normal file
439
yack-mini/miniprogram/pages/device/command/command.wxss
Normal file
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
wx.navigateTo({
|
||||
url: `/pages/device/command/command?sn=${device.serialNumber}`,
|
||||
});
|
||||
|
||||
// 更新 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;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 连接/断开 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}`;
|
||||
},
|
||||
});
|
||||
@ -1,4 +1,4 @@
|
||||
<!-- 设备详情页 -->
|
||||
<!-- 设备详情页(纯视图,实时数据来自网关) -->
|
||||
<view class="container">
|
||||
<!-- 设备信息 -->
|
||||
<view class="device-info card">
|
||||
@ -32,29 +32,54 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 采集控制 -->
|
||||
<view class="collect-control card">
|
||||
<view class="collect-row">
|
||||
<view class="collect-text">
|
||||
<view class="collect-title">启用网关采集</view>
|
||||
<view class="collect-sub">开启后自动连接本设备并透传上报;同一时间仅一个设备启用</view>
|
||||
</view>
|
||||
<switch checked="{{enabled}}" color="#1890ff" bindchange="handleEnableToggle" />
|
||||
</view>
|
||||
|
||||
<view class="collect-status">
|
||||
<text class="status-label">采集状态</text>
|
||||
<view class="status-tag {{running ? 'online' : 'offline'}}">{{running ? '采集中' : phaseText}}</view>
|
||||
</view>
|
||||
<view class="collect-meta">
|
||||
<text>BLE {{bleConnected ? '已连接' : '未连接'}}</text>
|
||||
<text class="dot">·</text>
|
||||
<text>MQTT {{mqttConnected ? '已连接' : '未连接'}}</text>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{enabled && !isActive && activeSn}}" class="notice-text">
|
||||
网关当前正采集 {{activeSn}},启用本设备后将自动切换
|
||||
</view>
|
||||
<view wx:if="{{lastError}}" class="notice-text error">{{lastError}}</view>
|
||||
</view>
|
||||
|
||||
<!-- 实时数据 -->
|
||||
<view class="data-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">实时数据</text>
|
||||
<text class="clear-btn" bindtap="handleClearData">清空</text>
|
||||
</view>
|
||||
|
||||
<view class="data-list card" wx:if="{{dataList.length > 0}}">
|
||||
<view class="data-item" wx:for="{{dataList}}" wx:key="id">
|
||||
<view class="data-time">{{item.time}}</view>
|
||||
<view class="data-hex">{{item.hex}}</view>
|
||||
<view class="data-action" wx:if="{{item.uploaded}}">
|
||||
<view class="data-flag" wx:if="{{item.uploaded}}">
|
||||
<text class="uploaded">已上报</text>
|
||||
</view>
|
||||
<view class="data-action" wx:else>
|
||||
<text class="upload-btn" bindtap="handleUploadSingle" data-index="{{index}}">上报</text>
|
||||
<view class="data-flag" wx:else>
|
||||
<text class="pending">待上报</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty" wx:else>
|
||||
<text class="empty-text">暂无数据</text>
|
||||
<text class="empty-hint">连接 BLE 设备后,数据将在此显示</text>
|
||||
<text class="empty-hint">{{enabled ? '等待设备上报数据…' : '启用网关采集后,设备数据将在此实时显示'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -62,7 +87,7 @@
|
||||
<view class="stats-section card">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{stats.received}}</text>
|
||||
<text class="stat-label">接收数据</text>
|
||||
<text class="stat-label">接收</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{stats.uploaded}}</text>
|
||||
@ -72,20 +97,18 @@
|
||||
<text class="stat-value">{{stats.failed}}</text>
|
||||
<text class="stat-label">失败</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{offlineCount}}</text>
|
||||
<text class="stat-label">离线</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<!-- 操作 -->
|
||||
<view class="action-group">
|
||||
<button class="btn-connect" bindtap="handleConnect" loading="{{connecting}}" disabled="{{connecting}}">
|
||||
{{bleConnected ? '断开 BLE' : '连接 BLE'}}
|
||||
</button>
|
||||
|
||||
<button class="btn-mqtt" bindtap="handleMqttConnect" loading="{{mqttConnecting}}" disabled="{{mqttConnecting}}">
|
||||
{{mqttConnected ? '断开 MQTT' : '连接 MQTT'}}
|
||||
</button>
|
||||
|
||||
<button class="btn-upload" bindtap="handleUploadAll" disabled="{{!mqttConnected || dataList.length === 0}}">
|
||||
全部上报
|
||||
<button class="btn-command" bindtap="handleSendCommand">发送命令</button>
|
||||
<button class="btn-flush" bindtap="handleFlushOffline" disabled="{{!mqttConnected || offlineCount === 0}}">
|
||||
上传离线({{offlineCount}})
|
||||
</button>
|
||||
<button class="btn-clear" bindtap="handleClearData">清空数据</button>
|
||||
</view>
|
||||
</view>
|
||||
@ -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;
|
||||
}
|
||||
@ -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 连接(让详情页重新连接,防止连接泄漏)
|
||||
// 延迟返回(先断开连接再返回,让详情页重新连接)
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await ble.bleService.disconnect();
|
||||
|
||||
// 延迟返回
|
||||
setTimeout(() => {
|
||||
} 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 });
|
||||
}
|
||||
|
||||
@ -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();
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
wx:for="{{devices}}"
|
||||
wx:key="serialNumber"
|
||||
device="{{item}}"
|
||||
running="{{item.running}}"
|
||||
bind:tap="handleDeviceTap"
|
||||
data-index="{{index}}"
|
||||
binddelete="handleDeviceDelete"
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -23,6 +23,10 @@
|
||||
<text class="label">蓝牙状态</text>
|
||||
<text class="status" style="color: {{bleStatusColor}}">{{bleStatus}}</text>
|
||||
</view>
|
||||
<view class="status-item">
|
||||
<text class="label">采集设备</text>
|
||||
<text class="status">{{activeDevice}}</text>
|
||||
</view>
|
||||
<view class="status-item">
|
||||
<text class="label">离线缓存</text>
|
||||
<text class="status">{{pendingCount}} 条</text>
|
||||
|
||||
@ -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,38 +90,46 @@ 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;
|
||||
}
|
||||
|
||||
wx.openBluetoothAdapter({
|
||||
success: () => {
|
||||
console.log('[BLE] 初始化成功');
|
||||
// 已开适配器但监听未挂(异常恢复路径),补挂监听即可
|
||||
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);
|
||||
this.deviceId = null;
|
||||
// 保留 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);
|
||||
console.log('[BLE] 收到数据:', hexData);
|
||||
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,
|
||||
@ -117,16 +142,53 @@ class BleService {
|
||||
});
|
||||
|
||||
this._listenersRegistered = true;
|
||||
console.log('[BLE] 已注册全局监听');
|
||||
};
|
||||
|
||||
if (this._openedAdapter) {
|
||||
registerListeners();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
wx.openBluetoothAdapter({
|
||||
success: () => {
|
||||
console.log('[BLE] 初始化成功');
|
||||
this._openedAdapter = true;
|
||||
registerListeners();
|
||||
resolve();
|
||||
},
|
||||
fail: (err) => {
|
||||
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('蓝牙初始化失败: ' + (err.errMsg || '未知错误')));
|
||||
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<string>} 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 (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<Array<string>>} 成功启用的特征 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<string|null>} 成功返回特征 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除数据回调(页面卸载时调用)
|
||||
*/
|
||||
|
||||
179
yack-mini/miniprogram/services/command-manager.js
Normal file
179
yack-mini/miniprogram/services/command-manager.js
Normal file
@ -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,
|
||||
};
|
||||
590
yack-mini/miniprogram/services/gateway.js
Normal file
590
yack-mini/miniprogram/services/gateway.js
Normal file
@ -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,
|
||||
};
|
||||
@ -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 || '未知错误')));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
199
yack-mini/miniprogram/utils/command-storage.js
Normal file
199
yack-mini/miniprogram/utils/command-storage.js
Normal file
@ -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,
|
||||
};
|
||||
41
yack-mini/miniprogram/utils/constants.js
Normal file
41
yack-mini/miniprogram/utils/constants.js
Normal file
@ -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,
|
||||
};
|
||||
140
yack-mini/miniprogram/utils/crypto.js
Normal file
140
yack-mini/miniprogram/utils/crypto.js
Normal file
@ -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<string>} 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<string>} 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,
|
||||
};
|
||||
@ -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<Object>} 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,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user