20260905:微信小程序-蓝牙采集网关-支持发送命令

This commit is contained in:
zhugongping 2026-09-06 15:00:19 +08:00
parent 7b611a1334
commit 63d836dd05
27 changed files with 3117 additions and 512 deletions

View File

@ -16,6 +16,11 @@
│ BLE 设备 │ ←──────→ │ yack-mini │ ←──────→ │ yack 平台 │
│ (传感器) │ │ (微信小程序) │ MQTT │ (数据存储) │
└─────────────┘ └──────────────────┘ └─────────────────┘
|
|
——————————————————
| 本地缓存/队列 |
——————————————————
```
## 项目结构

View File

@ -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;

View File

@ -4,6 +4,7 @@
"pages/index/index",
"pages/device/scan/scan",
"pages/device/detail/detail",
"pages/device/command/command",
"pages/mine/mine"
],
"window": {

View File

@ -8,6 +8,11 @@ Component({
type: Object,
value: {},
},
// 网关是否正在采集该设备(首页由 gateway 快照算出传入)
running: {
type: Boolean,
value: false,
},
},
methods: {

View File

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

View File

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

View File

@ -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',
};

View 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}`;
},
});

View File

@ -0,0 +1,4 @@
{
"navigationBarTitleText": "发送命令",
"usingComponents": {}
}

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

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

View File

@ -1,31 +1,34 @@
/**
* 设备详情页 - 实时数据
* 设备详情页 - 实时数据(纯视图)
*
* 连接与上报全部由应用级网关(gateway)承担,本页仅:
* - 订阅网关快照,实时展示该设备的 hex 原始帧与统计
* - 提供”启用网关采集”开关(单 enabled,开启即停其他设备)
* - 手动补传离线缓存 / 清空
*/
const ble = require('../../../services/ble.js');
const mqtt = require('../../../services/mqtt-client.js');
const { gateway } = require('../../../services/gateway.js');
const storage = require('../../../utils/storage.js');
const auth = require('../../../utils/auth.js');
const app = getApp();
Page({
data: {
device: null,
enabled: false,
// 网关快照(该设备是否为当前采集对象)
isActive: false,
activeSn: '',
running: false,
bleConnected: false,
mqttConnected: false,
connecting: false,
mqttConnecting: false,
phaseText: '未启用',
lastError: null,
// 数据列表
// 数据列表与统计
dataList: [],
maxDataCount: 50,
// 统计
stats: {
received: 0,
uploaded: 0,
failed: 0,
},
stats: { received: 0, uploaded: 0, failed: 0 },
offlineCount: 0,
},
onLoad(options) {
@ -41,314 +44,145 @@ Page({
if (!device || device.serialNumber !== sn) {
device = storage.getDevice(sn);
}
if (!device) {
wx.showToast({ title: '设备不存在', icon: 'none' });
setTimeout(() => wx.navigateBack(), 500);
return;
}
this.setData({ device });
// 监听 BLE 状态变化
ble.bleService.onStateChange((state) => {
const connected = state === ble.BLE_STATE.READY || state === ble.BLE_STATE.CONNECTED;
this.setData({ bleConnected: connected });
this.sn = sn;
this.setData({
device,
enabled: !!device.enabled,
});
},
// 监听 BLE 数据
ble.bleService.onData((data) => {
this._handleBleData(data);
});
onShow() {
// 订阅网关,实时渲染
if (!this._unsub) {
this._unsub = gateway.subscribe((snap) => this._apply(snap));
}
this._apply(gateway.getSnapshot());
// 监听 MQTT 状态变化
mqtt.mqttClient.onStateChange((state) => {
const connected = state === mqtt.MQTT_STATE.CONNECTED;
this.setData({ mqttConnected: connected });
});
// 本设备已启用时,确保网关在采集(幂等)
if (this.data.device && this.data.device.enabled) {
gateway.ensureStarted();
}
},
onHide() {
this._unsubscribe();
},
onUnload() {
// 清除回调,防止页面销毁后 setData 报错
ble.bleService.clearCallbacks();
mqtt.mqttClient.clearCallbacks();
this._unsubscribe();
},
// 断开连接
ble.bleService.disconnect();
mqtt.mqttClient.disconnect();
_unsubscribe() {
if (this._unsub) {
this._unsub();
this._unsub = null;
}
},
/**
* 处理 BLE 数据
* 应用网关快照到页面
*/
_handleBleData(data) {
const now = new Date();
const time = this._formatTime(now);
// 使用唯一 ID 标识数据项(防止重复数据更新错乱)
const dataItem = {
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
hex: data.hex,
time: time,
uploaded: false,
};
let dataList = this.data.dataList;
dataList.unshift(dataItem);
// 限制数量
if (dataList.length > this.data.maxDataCount) {
dataList = dataList.slice(0, this.data.maxDataCount);
}
_apply(snap) {
const dev = this.data.device;
const isActive = !!(snap.device && dev && snap.device.serialNumber === dev.serialNumber);
this.setData({
dataList,
'stats.received': this.data.stats.received + 1,
isActive,
activeSn: snap.device ? snap.device.serialNumber : '',
bleConnected: isActive && snap.bleConnected,
mqttConnected: isActive && snap.mqttConnected,
running: isActive && snap.phase === 'running',
phaseText: snap.phaseText,
lastError: isActive ? snap.lastError : null,
dataList: isActive ? snap.dataList : [],
stats: isActive ? snap.stats : { received: 0, uploaded: 0, failed: 0 },
offlineCount: snap.offlineCount,
});
},
// 如果 MQTT 已连接且设备已启用,自动上报
if (this.data.mqttConnected && this.data.device.enabled) {
this._uploadData(data.hex, dataItem.id);
/**
* 启用/停用网关采集(单 enabled)
*/
async handleEnableToggle(e) {
const enabled = e.detail.value;
const device = this.data.device;
if (!device) return;
if (enabled) {
// 本设备成为唯一启用,并立即启动采集
storage.activateDevice(device.serialNumber);
this.setData({
enabled: true,
device: { ...device, enabled: true },
});
const target = storage.getDevice(device.serialNumber);
gateway.start(target || { ...device, enabled: true });
} else {
// MQTT 未连接,缓存数据(离线缓存)
storage.cachePendingData({
deviceId: this.data.device.serialNumber,
productId: this.data.device.productId,
hex: data.hex,
dataId: dataItem.id,
storage.deactivateDevice(device.serialNumber);
this.setData({
enabled: false,
device: { ...device, enabled: false },
});
// 停用后若无其他启用设备,网关停止
await gateway.ensureStarted();
}
},
/**
* 上报数据到 yack
* @param {string} hexData - 十六进制数据
* @param {string} [dataId] - 数据项 ID(用于更新列表状态)
* @throws 上报失败时抛出异常
* 发送命令 - 跳转命令管理页
*/
async _uploadData(hexData, dataId) {
handleSendCommand() {
const device = this.data.device;
if (!device) return;
const topic = auth.buildPropertyPostTopic(device.productId, device.serialNumber);
try {
await mqtt.mqttClient.publish(topic, hexData);
console.log('[Detail] 上报成功:', hexData);
// 更新状态
this.setData({
'stats.uploaded': this.data.stats.uploaded + 1,
});
// 更新 dataList 中的状态(按唯一 ID 查找)
if (dataId) {
const dataList = this.data.dataList;
const index = dataList.findIndex(d => d.id === dataId);
if (index >= 0) {
this.setData({
[`dataList[${index}].uploaded`]: true,
});
}
}
} catch (err) {
console.error('[Detail] 上报失败:', err);
this.setData({
'stats.failed': this.data.stats.failed + 1,
});
// 抛出异常,供调用方统计失败
throw err;
}
wx.navigateTo({
url: `/pages/device/command/command?sn=${device.serialNumber}`,
});
},
/**
* 连接/断开 BLE
* 手动补传离线缓存
*/
async handleConnect() {
const { device, bleConnected } = this.data;
if (!device) return;
if (bleConnected) {
// 断开连接
await ble.bleService.disconnect();
this.setData({ bleConnected: false });
return;
}
this.setData({ connecting: true });
try {
// 初始化蓝牙
await ble.bleService.initBluetooth();
// 连接设备
await ble.bleService.connectDevice(device.deviceId, device.serviceUUID);
this.setData({ bleConnected: true });
wx.showToast({ title: 'BLE 已连接', icon: 'success' });
} catch (err) {
console.error('[Detail] BLE 连接失败:', err);
wx.showToast({
title: err.message || 'BLE 连接失败',
icon: 'none',
});
} finally {
this.setData({ connecting: false });
}
},
/**
* 连接/断开 MQTT
*/
async handleMqttConnect() {
const { device, mqttConnected } = this.data;
if (!device) return;
if (mqttConnected) {
// 断开连接
await mqtt.mqttClient.disconnect();
this.setData({ mqttConnected: false });
return;
}
this.setData({ mqttConnecting: true });
try {
// 获取 MQTT 凭证
const credentials = auth.getMqttCredentialsFromDevice(device);
if (!credentials) {
throw new Error('获取 MQTT 凭证失败');
}
// 连接 MQTT
await mqtt.mqttClient.connect(credentials);
this.setData({ mqttConnected: true });
wx.showToast({ title: 'MQTT 已连接', icon: 'success' });
// 上传缓存的离线数据
this._uploadPendingData();
} catch (err) {
console.error('[Detail] MQTT 连接失败:', err);
wx.showToast({
title: err.message || 'MQTT 连接失败',
icon: 'none',
});
} finally {
this.setData({ mqttConnecting: false });
}
},
/**
* 上传缓存的离线数据
*/
async _uploadPendingData() {
const pendingList = storage.getPendingData();
if (pendingList.length === 0) return;
console.log('[Detail] 开始上传离线缓存数据:', pendingList.length, '条');
const device = this.data.device;
let uploaded = 0;
let failed = 0;
for (const item of pendingList) {
// 只上传当前设备的数据
if (item.deviceId === device.serialNumber) {
try {
const topic = auth.buildPropertyPostTopic(device.productId, device.serialNumber);
await mqtt.mqttClient.publish(topic, item.hex);
uploaded++;
} catch (err) {
console.error('[Detail] 离线数据上传失败:', item.hex, err);
failed++;
}
}
}
// 清除已处理的缓存数据
storage.clearPendingData();
if (uploaded > 0 || failed > 0) {
console.log('[Detail] 离线数据上传完成:', uploaded, '成功,', failed, '失败');
}
},
/**
* 上报单条数据
*/
async handleUploadSingle(e) {
const index = e.currentTarget.dataset.index;
const data = this.data.dataList[index];
if (!data || data.uploaded) return;
async handleFlushOffline() {
const { offlineCount } = this.data;
if (!this.data.mqttConnected) {
wx.showToast({ title: 'MQTT 未连接', icon: 'none' });
return;
}
try {
await this._uploadData(data.hex, data.id);
} catch (err) {
wx.showToast({ title: err.message || '上报失败', icon: 'none' });
}
},
/**
* 全部上报
*/
async handleUploadAll() {
const { dataList, mqttConnected } = this.data;
if (!mqttConnected || dataList.length === 0) return;
wx.showLoading({ title: '上报中...', mask: true });
let uploaded = 0;
let failed = 0;
for (const data of dataList) {
if (!data.uploaded) {
try {
await this._uploadData(data.hex, data.id);
uploaded++;
} catch (err) {
failed++;
}
}
if (offlineCount === 0) {
wx.showToast({ title: '暂无离线数据', icon: 'none' });
return;
}
wx.showLoading({ title: '上传中...', mask: true });
const before = gateway.getSnapshot().offlineCount;
await gateway.flushOffline();
wx.hideLoading();
const after = gateway.getSnapshot().offlineCount;
wx.showToast({
title: `上报完成: ${uploaded} 成功, ${failed} 失败`,
title: `已上传 ${before - after} 条`,
icon: 'none',
});
},
/**
* 清空数据
* 清空展示数据
*/
handleClearData() {
wx.showModal({
title: '清空数据',
content: '确定清空所有接收到的数据吗?',
content: '确定清空当前实时数据与统计吗?(不影响离线缓存)',
success: (res) => {
if (res.confirm) {
this.setData({
dataList: [],
stats: { received: 0, uploaded: 0, failed: 0 },
});
gateway.clearHistory();
}
},
});
},
/**
* 格式化时间
*/
_formatTime(date) {
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
const s = date.getSeconds().toString().padStart(2, '0');
return `${h}:${m}:${s}`;
},
});
});

View File

@ -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>
</view>

View File

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

View File

@ -3,6 +3,7 @@
*/
const ble = require('../../../services/ble.js');
const storage = require('../../../utils/storage.js');
const { gateway } = require('../../../services/gateway.js');
Page({
data: {
@ -25,8 +26,9 @@ Page({
},
onLoad() {
// 初始化蓝牙
this._initBluetooth();
// 暂停正在跑的网关,避免常驻会话与扫描/校验连接抢占同一 BLE 目标;
// 返回首页后 index.onShow → gateway.ensureStarted() 会自动恢复。
gateway.stop().then(() => this._initBluetooth());
},
onUnload() {
@ -194,14 +196,18 @@ Page({
};
storage.addDevice(deviceConfig);
// 新设备成为唯一启用(单 enabled 不变量,兼修历史多启用数据)
storage.activateDevice(deviceConfig.serialNumber);
wx.showToast({ title: '保存成功', icon: 'success' });
// 断开 BLE 连接(让详情页重新连接,防止连接泄漏)
await ble.bleService.disconnect();
// 延迟返回
setTimeout(() => {
// 延迟返回(先断开连接再返回,让详情页重新连接)
setTimeout(async () => {
try {
await ble.bleService.disconnect();
} catch (e) {
console.warn('[Scan] 断开连接失败:', e);
}
this.setData({ showConfigModal: false });
wx.navigateBack();
}, 500);
@ -213,6 +219,12 @@ Page({
title: err.message || '连接失败',
icon: 'none',
});
// 连接失败时确保断开,避免残留连接
try {
await ble.bleService.disconnect();
} catch (e) {
console.warn('[Scan] 断开连接失败:', e);
}
} finally {
this.setData({ configuring: false });
}

View File

@ -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();
},
/**

View File

@ -16,6 +16,7 @@
wx:for="{{devices}}"
wx:key="serialNumber"
device="{{item}}"
running="{{item.running}}"
bind:tap="handleDeviceTap"
data-index="{{index}}"
binddelete="handleDeviceDelete"

View File

@ -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,
});
}
});
},
});

View File

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

View File

@ -20,6 +20,10 @@ const DEFAULT_CONFIG = {
connectTimeout: 10000,
// 默认服务 UUID(需根据实际设备配置)
serviceUUIDs: [],
// 调试日志开关(高频数据禁止逐包打印,否则阻塞 BLE 接收)
debug: false,
// 掉线自动重连(工业场景建议开启)
autoReconnect: true,
};
class BleService {
@ -34,6 +38,19 @@ class BleService {
this.config = { ...DEFAULT_CONFIG };
// 监听器注册标志(防止叠加)
this._listenersRegistered = false;
// 适配器是否已开启
this._openedAdapter = false;
// 收包计数/最近收包时间(诊断、静默探测用)
this._recvCount = 0;
this._lastRecvAt = 0;
// 当前会话已启用 notify 的特征列表
this._notifyCharacteristicIds = [];
// 自动重连相关
this._lastDeviceId = null;
this._lastServiceId = null;
this._reconnectTimer = null;
this._reconnectAttempts = 0;
this._manuallyDisconnected = false;
}
/**
@ -73,60 +90,105 @@ class BleService {
}
/**
* 初始化蓝牙适配器
* 初始化蓝牙适配器(幂等)
* wx 级全局监听只注册一次,不再每次 off/on 抖动,避免多页/重连切换时的丢包窗口。
* @returns {Promise}
*/
initBluetooth() {
return new Promise((resolve, reject) => {
// 移除旧监听器,防止叠加(微信 API 支持注册多个回调)
if (this._listenersRegistered) {
try {
wx.offBLEConnectionStateChange();
wx.offBLECharacteristicValueChange();
} catch (e) {
// 忽略移除失败
}
// 已开启且监听已挂:直接复用
if (this._openedAdapter && this._listenersRegistered) {
resolve();
return;
}
// 已开适配器但监听未挂(异常恢复路径),补挂监听即可
const registerListeners = () => {
if (this._listenersRegistered) return;
wx.onBLEConnectionStateChange((res) => {
console.log('[BLE] 连接状态变化:', res);
if (!res.connected) {
const wasConnected = this.state === BLE_STATE.READY || this.state === BLE_STATE.CONNECTED;
this._setState(BLE_STATE.DISCONNECTED);
// 保留 deviceId 供重连使用(不再置空),掉线自动重连
if (wasConnected && this.config.autoReconnect && !this._manuallyDisconnected && this._lastDeviceId) {
this._scheduleReconnect();
}
}
});
wx.onBLECharacteristicValueChange((res) => {
// 只处理当前会话设备的值变化,防止切换设备后旧连接/残留连接串包
if (this.deviceId && res.deviceId && res.deviceId !== this.deviceId) {
return;
}
const hexData = hex.arrayBufferToHex(res.value);
this._recvCount++;
this._lastRecvAt = Date.now();
// 首包 + 每 20 包打印摘要,debug 时逐包打印,避免高频逐包日志阻塞接收
if (this.config.debug || this._recvCount === 1 || this._recvCount % 20 === 0) {
console.log(`[BLE] 收到数据 #${this._recvCount} (${hexData.length / 2}字节):`, hexData);
}
if (this.onDataCallback) {
this.onDataCallback({
hex: hexData,
buffer: res.value,
deviceId: res.deviceId,
serviceId: res.serviceId,
characteristicId: res.characteristicId,
});
}
});
this._listenersRegistered = true;
console.log('[BLE] 已注册全局监听');
};
if (this._openedAdapter) {
registerListeners();
resolve();
return;
}
wx.openBluetoothAdapter({
success: () => {
console.log('[BLE] 初始化成功');
// 监听连接状态变化(只注册一次)
wx.onBLEConnectionStateChange((res) => {
console.log('[BLE] 连接状态变化:', res);
if (!res.connected) {
this._setState(BLE_STATE.DISCONNECTED);
this.deviceId = null;
}
});
// 监听特征值变化(只注册一次)
wx.onBLECharacteristicValueChange((res) => {
const hexData = hex.arrayBufferToHex(res.value);
console.log('[BLE] 收到数据:', hexData);
if (this.onDataCallback) {
this.onDataCallback({
hex: hexData,
buffer: res.value,
deviceId: res.deviceId,
serviceId: res.serviceId,
characteristicId: res.characteristicId,
});
}
});
this._listenersRegistered = true;
this._openedAdapter = true;
registerListeners();
resolve();
},
fail: (err) => {
console.error('[BLE] 初始化失败:', err);
reject(new Error('蓝牙初始化失败: ' + (err.errMsg || '未知错误')));
const msg = (err && err.errMsg) || '';
// 部分平台适配器已开启时 open 会报 already 类错误,视为成功
if (/already/i.test(msg)) {
console.log('[BLE] 适配器已开启(忽略 already)');
this._openedAdapter = true;
registerListeners();
resolve();
} else {
console.error('[BLE] 初始化失败:', err);
reject(new Error('蓝牙初始化失败: ' + msg || '未知错误'));
}
},
});
});
}
/**
* 获取平台合适的 MTU 值
* @returns {number}
*/
_getMtu() {
try {
const platform = wx.getSystemInfoSync().platform;
// iOS 最大 185,超出会协商失败;Android 建议 247
return platform === 'ios' ? 185 : 247;
} catch (e) {
return 247;
}
}
/**
* 扫描蓝牙设备
* @param {Array<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 (writeChar) {
this.writeCharacteristicId = writeChar.uuid;
console.log('[BLE] 写入特征值:', this.writeCharacteristicId);
}
if (notifyChar) {
this.notifyCharacteristicId = notifyChar.uuid;
console.log('[BLE] 通知特征值:', this.notifyCharacteristicId);
// 启用通知
wx.notifyBLECharacteristicValueChange({
deviceId: deviceId,
serviceId: serviceId,
characteristicId: this.notifyCharacteristicId,
state: true,
success: () => {
console.log('[BLE] 已启用通知');
if (writeChar) {
this.writeCharacteristicId = writeChar.uuid;
console.log('[BLE] 写入特征值:', this.writeCharacteristicId);
}
this._setState(BLE_STATE.READY);
resolve({
serviceId: this.serviceId,
notifyCharacteristicId: this.notifyCharacteristicId,
writeCharacteristicId: this.writeCharacteristicId,
});
},
fail: (err) => {
console.error('[BLE] 启用通知失败:', err);
reject(new Error('启用通知失败'));
},
});
} else {
if (notifyChars.length === 0) {
// 没有通知特征值,但可能有写入特征值
if (writeChar) {
this.writeCharacteristicId = writeChar.uuid;
console.log('[BLE] 写入特征值:', this.writeCharacteristicId);
}
console.warn('[BLE] ⚠️ 未找到 notify/indicate 特征值!设备将无法主动上报数据,只能被动写入。请核对设备的 GATT 服务/特征值。');
this._notifyCharacteristicIds = [];
this.notifyCharacteristicId = null;
this._lastRecvAt = Date.now();
this._setState(BLE_STATE.READY);
resolve({
serviceId: this.serviceId,
notifyCharacteristicIds: [],
notifyCharacteristicId: null,
writeCharacteristicId: this.writeCharacteristicId,
});
return;
}
// 串行逐个启用 notify
this._enableAllNotify(deviceId, serviceId, notifyChars).then((enabled) => {
this._notifyCharacteristicIds = enabled;
this.notifyCharacteristicId = enabled[0] || null;
if (enabled.length === 0) {
console.error('[BLE] 所有 notify 特征启用均失败');
reject(new Error('启用通知失败'));
return;
}
console.log('[BLE] 已启用 notify 特征:', enabled);
// 作为后续“静默/掉线”探测的基线
this._lastRecvAt = Date.now();
this._setState(BLE_STATE.READY);
resolve({
serviceId: this.serviceId,
notifyCharacteristicIds: enabled,
notifyCharacteristicId: this.notifyCharacteristicId,
writeCharacteristicId: this.writeCharacteristicId,
});
}).catch((err) => {
console.error('[BLE] 启用通知异常:', err);
reject(new Error('启用通知失败'));
});
},
fail: (err) => {
console.error('[BLE] 获取特征值失败:', err);
@ -399,6 +512,47 @@ class BleService {
});
}
/**
* 串行启用多个 notify/indicate 特征
* @param {string} deviceId
* @param {string} serviceId
* @param {Array} chars - 特征数组(含 uuid)
* @returns {Promise<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,
};
}
/**
* 清除数据回调(页面卸载时调用)
*/

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

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

View File

@ -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 || '未知错误')));
}
});
}

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

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

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

View File

@ -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,
};