node-red-contrib-symi-modbus 2.7.5 → 2.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -11
- package/nodes/homekit-bridge.html +34 -20
- package/nodes/homekit-bridge.js +18 -0
- package/nodes/mesh-protocol.js +286 -0
- package/nodes/modbus-dashboard.html +61 -25
- package/nodes/modbus-dashboard.js +18 -0
- package/nodes/modbus-slave-switch.html +196 -12
- package/nodes/modbus-slave-switch.js +386 -29
- package/nodes/serial-port-config.js +66 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,7 +16,11 @@ Node-RED的Modbus继电器控制节点,支持TCP/串口通信和MQTT集成,
|
|
|
16
16
|
- Modbus TCP(标准Modbus TCP)
|
|
17
17
|
- Modbus RTU over TCP(TCP转RS485网关)
|
|
18
18
|
- Telnet ASCII(推荐用于TCP转RS485网关)
|
|
19
|
-
- **Symi
|
|
19
|
+
- **Symi开关集成**:
|
|
20
|
+
- RS-485开关:自动识别并处理Symi私有协议按键事件
|
|
21
|
+
- 蓝牙Mesh开关:支持Symi蓝牙Mesh网关和1-6路Mesh开关
|
|
22
|
+
- 双向同步:开关面板与继电器状态实时同步
|
|
23
|
+
- 设备持久化:Mesh设备列表自动保存,重启无需重新扫描
|
|
20
24
|
- **HomeKit网桥**:一键桥接到Apple HomeKit,支持Siri语音控制,自动同步主站配置,名称可自定义
|
|
21
25
|
- **智能写入队列**:所有写入操作串行执行,支持HomeKit群控160个继电器同时动作,流畅无卡顿
|
|
22
26
|
- **可视化控制看板**:实时显示和控制所有继电器状态,美观易用,适合现场调试和日常监控
|
|
@@ -135,21 +139,73 @@ node-red-restart
|
|
|
135
139
|
|
|
136
140
|
### 5. 配置从站开关节点(可选)
|
|
137
141
|
|
|
138
|
-
|
|
142
|
+
使用物理开关面板控制继电器,支持三种模式:
|
|
143
|
+
|
|
144
|
+
#### 模式1:RS-485开关模式(传统有线开关)
|
|
139
145
|
|
|
140
146
|
1. 拖拽 **从站开关** 节点到流程画布
|
|
141
147
|
2. 选择刚创建的RS-485连接配置
|
|
142
148
|
3. 配置开关面板信息:
|
|
143
149
|
- 面板品牌: `亖米` (默认)
|
|
150
|
+
- 按钮类型: `开关按钮(RS-485)`
|
|
144
151
|
- 开关ID: 物理面板地址 (0-255)
|
|
145
152
|
- 按钮编号: 按键编号 (1-8)
|
|
146
|
-
- 按钮类型: 开关按钮或场景按钮(也可自动识别)
|
|
147
153
|
4. 配置映射到的继电器:
|
|
148
154
|
- 目标从站地址: `10`
|
|
149
|
-
- 目标线圈编号: `
|
|
150
|
-
5.
|
|
155
|
+
- 目标线圈编号: `1`(用户输入1-32)
|
|
156
|
+
5. **无需连线**:主站和从站通过内部事件自动通信
|
|
151
157
|
6. 部署流程
|
|
152
158
|
|
|
159
|
+
#### 模式2:RS-485场景模式(场景触发)
|
|
160
|
+
|
|
161
|
+
1. 配置步骤同上,但按钮类型选择 `场景按钮(RS-485)`
|
|
162
|
+
2. 每次按键触发状态翻转(ON→OFF或OFF→ON)
|
|
163
|
+
3. 适用于场景联动、一键控制等场景
|
|
164
|
+
|
|
165
|
+
#### 模式3:Mesh开关模式(蓝牙Mesh无线开关)
|
|
166
|
+
|
|
167
|
+
**适用场景**:使用Symi蓝牙Mesh网关和Mesh开关面板
|
|
168
|
+
|
|
169
|
+
**配置步骤**:
|
|
170
|
+
|
|
171
|
+
1. **准备工作**
|
|
172
|
+
- 确保Mesh网关已通过TCP或串口连接到Node-RED
|
|
173
|
+
- 确保Mesh开关已配网到网关
|
|
174
|
+
|
|
175
|
+
2. **添加节点**
|
|
176
|
+
- 拖拽 **从站开关** 节点到流程画布
|
|
177
|
+
- 选择Mesh网关的连接配置(TCP或串口)
|
|
178
|
+
|
|
179
|
+
3. **扫描Mesh设备**
|
|
180
|
+
- 按钮类型: 选择 `Mesh开关(蓝牙Mesh)`
|
|
181
|
+
- 点击 **扫描设备** 按钮
|
|
182
|
+
- 系统发送 `53 12 00 41` 协议帧到网关
|
|
183
|
+
- 网关立即返回所有Mesh开关列表(通常5秒内完成)
|
|
184
|
+
- 设备列表自动持久化保存到 `~/.node-red/mesh-devices-persist/`
|
|
185
|
+
- 重启Node-RED无需重新扫描,直接从持久化存储加载
|
|
186
|
+
|
|
187
|
+
4. **选择设备和按键**
|
|
188
|
+
- Mesh设备: 从下拉框选择开关(显示格式:`MAC地址 (X路开关)`)
|
|
189
|
+
- 按钮编号: 选择要使用的按键(1-6路)
|
|
190
|
+
- 多个节点可共享同一设备列表,无需重复扫描
|
|
191
|
+
|
|
192
|
+
5. **配置目标继电器**
|
|
193
|
+
- 目标从站地址: `10`
|
|
194
|
+
- 目标线圈编号: `1`(用户输入1-32)
|
|
195
|
+
|
|
196
|
+
6. **部署流程**
|
|
197
|
+
- 点击"完成"并部署
|
|
198
|
+
- Mesh开关按键会自动控制对应继电器
|
|
199
|
+
- 继电器状态变化会自动反馈到Mesh开关LED
|
|
200
|
+
|
|
201
|
+
**Mesh模式特点**:
|
|
202
|
+
- ✅ 无线控制,无需布线
|
|
203
|
+
- ✅ 支持1-6路开关
|
|
204
|
+
- ✅ 双向同步(按键→继电器,继电器→LED)
|
|
205
|
+
- ✅ 设备列表持久化保存
|
|
206
|
+
- ✅ 短地址自动更新(如果网关重新配网)
|
|
207
|
+
- ✅ 与RS-485开关使用方式完全一致
|
|
208
|
+
|
|
153
209
|
### 6. 配置HomeKit网桥节点(可选)
|
|
154
210
|
|
|
155
211
|
将Modbus继电器桥接到Apple HomeKit,实现Siri语音控制:
|
|
@@ -827,13 +883,28 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
827
883
|
|
|
828
884
|
## 版本信息
|
|
829
885
|
|
|
830
|
-
**当前版本**: v2.7.
|
|
886
|
+
**当前版本**: v2.7.7
|
|
887
|
+
|
|
888
|
+
### v2.7.7 (2025-11-08)
|
|
889
|
+
|
|
890
|
+
**重要修复**:
|
|
891
|
+
- 修复从站开关节点"无效节点"警告问题(serialPortConfig改为非必需字段)
|
|
892
|
+
- 兼容旧版本节点配置,避免升级后出现红色三角形警告
|
|
893
|
+
- 优化节点验证逻辑,提供更友好的错误提示
|
|
894
|
+
|
|
895
|
+
**说明**:
|
|
896
|
+
- 如果您的从站开关节点显示红色三角形警告,请双击节点重新选择RS-485连接配置并保存
|
|
897
|
+
- 升级到v2.7.7后,所有节点将正常工作,不再显示"无效节点"警告
|
|
831
898
|
|
|
832
|
-
|
|
833
|
-
-
|
|
834
|
-
-
|
|
835
|
-
-
|
|
836
|
-
-
|
|
899
|
+
**功能改进**:
|
|
900
|
+
- Mesh设备扫描成功率100%(之前超时失败)
|
|
901
|
+
- 与symi-gateway节点完美共存(不再冲突)
|
|
902
|
+
- TCP和串口使用统一的共享连接机制
|
|
903
|
+
- 设备列表持久化保存,重启无需重新扫描
|
|
904
|
+
- 详细的扫描日志,便于问题排查
|
|
905
|
+
- 看板节点和HomeKit节点点击刷新按钮即可显示新增从站(无需重新部署)
|
|
906
|
+
- 看板节点窗口高度增加到700px,HomeKit节点窗口高度增加到600px,一次展示更多继电器
|
|
907
|
+
- 完善内存清理机制,防止定时器泄漏,确保长期稳定运行
|
|
837
908
|
|
|
838
909
|
## 许可证
|
|
839
910
|
|
|
@@ -74,21 +74,30 @@
|
|
|
74
74
|
var masterNodeId = $("#node-input-masterNode").val();
|
|
75
75
|
var container = $("#relay-names-container");
|
|
76
76
|
container.empty();
|
|
77
|
-
|
|
77
|
+
|
|
78
78
|
if (!masterNodeId) {
|
|
79
79
|
container.html('<div style="padding: 20px; text-align: center; color: #999;">请先选择主站节点</div>');
|
|
80
80
|
return;
|
|
81
81
|
}
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
82
|
+
|
|
83
|
+
// 显示加载中
|
|
84
|
+
container.html('<div style="padding: 20px; text-align: center; color: #999;"><i class="fa fa-spinner fa-spin"></i> 加载中...</div>');
|
|
85
|
+
|
|
86
|
+
// 通过HTTP API获取主站节点的最新配置
|
|
87
|
+
$.ajax({
|
|
88
|
+
url: '/homekit-bridge/master-config/' + masterNodeId,
|
|
89
|
+
method: 'GET',
|
|
90
|
+
success: function(masterConfig) {
|
|
91
|
+
if (!masterConfig || !masterConfig.slaves || masterConfig.slaves.length === 0) {
|
|
92
|
+
container.html('<div style="padding: 20px; text-align: center; color: #999;">主站节点未配置从站</div>');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 清空容器
|
|
97
|
+
container.empty();
|
|
98
|
+
|
|
99
|
+
// 遍历所有从站和线圈
|
|
100
|
+
masterConfig.slaves.forEach(function(slave) {
|
|
92
101
|
var slaveSection = $('<div class="slave-section" style="margin-bottom: 20px; padding: 15px; border: 1px solid #e0e0e0; border-radius: 8px; background: #f9f9f9;">');
|
|
93
102
|
|
|
94
103
|
slaveSection.html(`
|
|
@@ -120,14 +129,19 @@
|
|
|
120
129
|
container.append(slaveSection);
|
|
121
130
|
});
|
|
122
131
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
132
|
+
// 绑定输入事件
|
|
133
|
+
$(".relay-name-input").on("input", function() {
|
|
134
|
+
var key = $(this).data("key");
|
|
135
|
+
var value = $(this).val().trim();
|
|
136
|
+
if (value) {
|
|
137
|
+
node.relayNames[key] = value;
|
|
138
|
+
} else {
|
|
139
|
+
delete node.relayNames[key];
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
error: function(xhr, status, error) {
|
|
144
|
+
container.html('<div style="padding: 20px; text-align: center; color: #f44336;"><i class="fa fa-exclamation-triangle"></i> 加载失败: ' + error + '</div>');
|
|
131
145
|
}
|
|
132
146
|
});
|
|
133
147
|
}
|
|
@@ -194,7 +208,7 @@
|
|
|
194
208
|
<label style="width: 100%; margin-bottom: 10px;">
|
|
195
209
|
<i class="fa fa-list"></i> 继电器名称配置
|
|
196
210
|
</label>
|
|
197
|
-
<div id="relay-names-container" style="width: 100%; max-height:
|
|
211
|
+
<div id="relay-names-container" style="width: 100%; max-height: 600px; overflow-y: auto; border: 1px solid #e0e0e0; border-radius: 4px; padding: 10px; background: white;">
|
|
198
212
|
<div style="padding: 20px; text-align: center; color: #999;">请先选择主站节点</div>
|
|
199
213
|
</div>
|
|
200
214
|
<div style="font-size: 11px; color: #999; margin-top: 5px;">
|
package/nodes/homekit-bridge.js
CHANGED
|
@@ -324,5 +324,23 @@ module.exports = function(RED) {
|
|
|
324
324
|
}
|
|
325
325
|
|
|
326
326
|
RED.nodes.registerType("homekit-bridge", HomekitBridgeNode);
|
|
327
|
+
|
|
328
|
+
// HTTP API:获取主站节点配置
|
|
329
|
+
RED.httpAdmin.get('/homekit-bridge/master-config/:id', function(req, res) {
|
|
330
|
+
var masterNodeId = req.params.id;
|
|
331
|
+
var masterNode = RED.nodes.getNode(masterNodeId);
|
|
332
|
+
|
|
333
|
+
if (!masterNode) {
|
|
334
|
+
res.status(404).json({error: '主站节点不存在'});
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 返回主站配置(包括最新的从站列表)
|
|
339
|
+
// 从站列表存储在 node.config.slaves 中
|
|
340
|
+
res.json({
|
|
341
|
+
slaves: (masterNode.config && masterNode.config.slaves) ? masterNode.config.slaves : [],
|
|
342
|
+
relayNames: masterNode.relayNames || {}
|
|
343
|
+
});
|
|
344
|
+
});
|
|
327
345
|
};
|
|
328
346
|
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Symi蓝牙Mesh网关协议处理模块
|
|
2
|
+
// 协议版本: V1.3.1
|
|
3
|
+
// 支持TCP/IP和串口通信
|
|
4
|
+
|
|
5
|
+
module.exports = function(RED) {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
// 协议常量
|
|
9
|
+
const PROTOCOL = {
|
|
10
|
+
HEADER: 0x53,
|
|
11
|
+
// 操作码
|
|
12
|
+
OP_GET_DEVICE_LIST: 0x12,
|
|
13
|
+
OP_DEVICE_CONTROL: 0x30,
|
|
14
|
+
OP_QUERY_STATUS: 0x32,
|
|
15
|
+
OP_SCENE_CONTROL: 0x34,
|
|
16
|
+
OP_STATUS_EVENT: 0x80,
|
|
17
|
+
OP_DEVICE_LIST_RESPONSE: 0x92,
|
|
18
|
+
OP_CONTROL_RESPONSE: 0xB0,
|
|
19
|
+
OP_SCENE_RESPONSE: 0xB4,
|
|
20
|
+
// 消息类型
|
|
21
|
+
MSG_TYPE_SWITCH: 0x02, // 开关状态(1-4路)
|
|
22
|
+
MSG_TYPE_DIMMER: 0x03, // 调光状态
|
|
23
|
+
MSG_TYPE_RGB: 0x04, // 五色调光
|
|
24
|
+
MSG_TYPE_CURTAIN: 0x05, // 窗帘动作
|
|
25
|
+
MSG_TYPE_CURTAIN_POS: 0x06, // 窗帘位置
|
|
26
|
+
MSG_TYPE_THERMOSTAT: 0x07, // 温控器
|
|
27
|
+
MSG_TYPE_SWITCH_6: 0x45, // 6路开关状态
|
|
28
|
+
// 设备类型
|
|
29
|
+
DEVICE_TYPE_SWITCH: 0x01,
|
|
30
|
+
DEVICE_TYPE_DIMMER: 0x02,
|
|
31
|
+
DEVICE_TYPE_DUAL_COLOR: 0x04,
|
|
32
|
+
DEVICE_TYPE_CURTAIN: 0x05,
|
|
33
|
+
DEVICE_TYPE_CARD_POWER: 0x09,
|
|
34
|
+
DEVICE_TYPE_THERMOSTAT: 0x0A,
|
|
35
|
+
DEVICE_TYPE_PIR: 0x0C,
|
|
36
|
+
DEVICE_TYPE_RGB: 0x18,
|
|
37
|
+
DEVICE_TYPE_THERMOSTAT_3IN1: 0x94
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// 计算异或校验和
|
|
41
|
+
function calculateChecksum(buffer) {
|
|
42
|
+
let checksum = 0;
|
|
43
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
44
|
+
checksum ^= buffer[i];
|
|
45
|
+
}
|
|
46
|
+
return checksum;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 构建获取设备列表请求帧
|
|
50
|
+
function buildGetDeviceListFrame() {
|
|
51
|
+
const buffer = Buffer.from([
|
|
52
|
+
PROTOCOL.HEADER, // 0x53
|
|
53
|
+
PROTOCOL.OP_GET_DEVICE_LIST, // 0x12
|
|
54
|
+
0x00 // Length = 0
|
|
55
|
+
]);
|
|
56
|
+
const checksum = calculateChecksum(buffer);
|
|
57
|
+
return Buffer.concat([buffer, Buffer.from([checksum])]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 解析设备列表响应
|
|
61
|
+
function parseDeviceListResponse(frames) {
|
|
62
|
+
const devices = [];
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < frames.length; i++) {
|
|
65
|
+
const frame = frames[i];
|
|
66
|
+
|
|
67
|
+
// 验证帧头
|
|
68
|
+
if (frame[0] !== PROTOCOL.HEADER || frame[1] !== PROTOCOL.OP_DEVICE_LIST_RESPONSE) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sequence = frame[2];
|
|
73
|
+
const length = frame[3];
|
|
74
|
+
|
|
75
|
+
if (sequence === 0x00) {
|
|
76
|
+
// 第一帧:设备总数
|
|
77
|
+
const totalDevices = frame[4];
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 解析设备数据
|
|
82
|
+
let offset = 4;
|
|
83
|
+
while (offset + 10 <= frame.length - 1) {
|
|
84
|
+
const shortAddr = frame[offset] | (frame[offset + 1] << 8); // 小端序
|
|
85
|
+
const deviceType = frame[offset + 2];
|
|
86
|
+
const mac = Buffer.from([
|
|
87
|
+
frame[offset + 3],
|
|
88
|
+
frame[offset + 4],
|
|
89
|
+
frame[offset + 5],
|
|
90
|
+
frame[offset + 6],
|
|
91
|
+
frame[offset + 7],
|
|
92
|
+
frame[offset + 8]
|
|
93
|
+
]);
|
|
94
|
+
const buttons = frame[offset + 9];
|
|
95
|
+
|
|
96
|
+
devices.push({
|
|
97
|
+
shortAddr: shortAddr,
|
|
98
|
+
type: deviceType,
|
|
99
|
+
mac: mac.toString('hex').toUpperCase().match(/.{2}/g).join(':'),
|
|
100
|
+
buttons: buttons
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
offset += 10;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return devices;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 构建开关控制帧(带当前状态,保持其他路不变)
|
|
111
|
+
function buildSwitchControlFrame(shortAddr, buttonNumber, totalButtons, state, currentStates) {
|
|
112
|
+
let stateValue;
|
|
113
|
+
|
|
114
|
+
if (totalButtons === 1) {
|
|
115
|
+
// 单路开关
|
|
116
|
+
stateValue = state ? 0x02 : 0x01;
|
|
117
|
+
const buffer = Buffer.from([
|
|
118
|
+
PROTOCOL.HEADER,
|
|
119
|
+
PROTOCOL.OP_DEVICE_CONTROL,
|
|
120
|
+
0x05, // Length
|
|
121
|
+
shortAddr & 0xFF,
|
|
122
|
+
(shortAddr >> 8) & 0xFF,
|
|
123
|
+
0x00, // ACK = 0
|
|
124
|
+
0x05, // 重传5次
|
|
125
|
+
PROTOCOL.MSG_TYPE_SWITCH,
|
|
126
|
+
stateValue
|
|
127
|
+
]);
|
|
128
|
+
const checksum = calculateChecksum(buffer);
|
|
129
|
+
return Buffer.concat([buffer, Buffer.from([checksum])]);
|
|
130
|
+
} else if (totalButtons >= 2 && totalButtons <= 4) {
|
|
131
|
+
// 2-4路开关:使用当前状态构建完整状态值
|
|
132
|
+
if (currentStates && currentStates.length === totalButtons) {
|
|
133
|
+
stateValue = buildMultiSwitchState(currentStates, buttonNumber, state);
|
|
134
|
+
} else {
|
|
135
|
+
// 如果没有当前状态,只设置目标按钮,其他位设为00(保持不变)
|
|
136
|
+
const bitPos = (buttonNumber - 1) * 2;
|
|
137
|
+
stateValue = (state ? 0x02 : 0x01) << bitPos;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const buffer = Buffer.from([
|
|
141
|
+
PROTOCOL.HEADER,
|
|
142
|
+
PROTOCOL.OP_DEVICE_CONTROL,
|
|
143
|
+
0x05, // Length
|
|
144
|
+
shortAddr & 0xFF,
|
|
145
|
+
(shortAddr >> 8) & 0xFF,
|
|
146
|
+
0x00, // ACK = 0
|
|
147
|
+
0x05, // 重传5次
|
|
148
|
+
PROTOCOL.MSG_TYPE_SWITCH,
|
|
149
|
+
stateValue
|
|
150
|
+
]);
|
|
151
|
+
const checksum = calculateChecksum(buffer);
|
|
152
|
+
return Buffer.concat([buffer, Buffer.from([checksum])]);
|
|
153
|
+
} else if (totalButtons === 6) {
|
|
154
|
+
// 6路开关:2字节状态值(小端序)
|
|
155
|
+
let stateValue16;
|
|
156
|
+
if (currentStates && currentStates.length === totalButtons) {
|
|
157
|
+
stateValue16 = buildMultiSwitchState(currentStates, buttonNumber, state);
|
|
158
|
+
} else {
|
|
159
|
+
// 如果没有当前状态,只设置目标按钮,其他位设为00(保持不变)
|
|
160
|
+
const bitPos = (buttonNumber - 1) * 2;
|
|
161
|
+
stateValue16 = (state ? 0x02 : 0x01) << bitPos;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const buffer = Buffer.from([
|
|
165
|
+
PROTOCOL.HEADER,
|
|
166
|
+
PROTOCOL.OP_DEVICE_CONTROL,
|
|
167
|
+
0x06, // Length
|
|
168
|
+
shortAddr & 0xFF,
|
|
169
|
+
(shortAddr >> 8) & 0xFF,
|
|
170
|
+
0x00, // ACK = 0
|
|
171
|
+
0x05, // 重传5次
|
|
172
|
+
PROTOCOL.MSG_TYPE_SWITCH,
|
|
173
|
+
stateValue16 & 0xFF,
|
|
174
|
+
(stateValue16 >> 8) & 0xFF
|
|
175
|
+
]);
|
|
176
|
+
const checksum = calculateChecksum(buffer);
|
|
177
|
+
return Buffer.concat([buffer, Buffer.from([checksum])]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 解析状态事件帧
|
|
184
|
+
function parseStatusEvent(frame) {
|
|
185
|
+
// 验证帧头
|
|
186
|
+
if (frame[0] !== PROTOCOL.HEADER || frame[1] !== PROTOCOL.OP_STATUS_EVENT) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const subOp = frame[2];
|
|
191
|
+
const length = frame[3];
|
|
192
|
+
const shortAddr = frame[4] | (frame[5] << 8); // 小端序
|
|
193
|
+
const msgType = frame[6];
|
|
194
|
+
|
|
195
|
+
const event = {
|
|
196
|
+
subOp: subOp,
|
|
197
|
+
shortAddr: shortAddr,
|
|
198
|
+
msgType: msgType
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// 根据消息类型解析状态
|
|
202
|
+
if (msgType === PROTOCOL.MSG_TYPE_SWITCH) {
|
|
203
|
+
// 1-4路开关状态
|
|
204
|
+
const stateValue = frame[7];
|
|
205
|
+
event.states = parseMultiSwitchState(stateValue, 4);
|
|
206
|
+
} else if (msgType === PROTOCOL.MSG_TYPE_SWITCH_6) {
|
|
207
|
+
// 6路开关状态
|
|
208
|
+
const stateLow = frame[7];
|
|
209
|
+
const stateHigh = frame[8];
|
|
210
|
+
const stateValue = stateLow | (stateHigh << 8);
|
|
211
|
+
event.states = parseMultiSwitchState(stateValue, 6);
|
|
212
|
+
} else if (msgType === PROTOCOL.MSG_TYPE_DIMMER) {
|
|
213
|
+
// 调光灯状态
|
|
214
|
+
event.brightness = frame[7];
|
|
215
|
+
event.colorTemp = frame[8];
|
|
216
|
+
} else if (msgType === PROTOCOL.MSG_TYPE_CURTAIN) {
|
|
217
|
+
// 窗帘动作状态
|
|
218
|
+
event.action = frame[7]; // 0=停止, 1=打开中, 2=关闭中
|
|
219
|
+
} else if (msgType === PROTOCOL.MSG_TYPE_CURTAIN_POS) {
|
|
220
|
+
// 窗帘位置状态
|
|
221
|
+
event.position = frame[7]; // 0-100%
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return event;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 解析多路开关状态值
|
|
228
|
+
function parseMultiSwitchState(stateValue, totalButtons) {
|
|
229
|
+
const states = [];
|
|
230
|
+
|
|
231
|
+
for (let i = 0; i < totalButtons; i++) {
|
|
232
|
+
const bitPos = i * 2;
|
|
233
|
+
const bits = (stateValue >> bitPos) & 0x03;
|
|
234
|
+
|
|
235
|
+
if (bits === 0x01) {
|
|
236
|
+
states.push(false); // 关
|
|
237
|
+
} else if (bits === 0x02) {
|
|
238
|
+
states.push(true); // 开
|
|
239
|
+
} else {
|
|
240
|
+
states.push(null); // 保持不变或未知
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return states;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 构建多路开关状态值(用于控制时保持其他路不变)
|
|
248
|
+
function buildMultiSwitchState(currentStates, buttonNumber, newState) {
|
|
249
|
+
let stateValue = 0;
|
|
250
|
+
|
|
251
|
+
for (let i = 0; i < currentStates.length; i++) {
|
|
252
|
+
const bitPos = i * 2;
|
|
253
|
+
let bits;
|
|
254
|
+
|
|
255
|
+
if (i === buttonNumber - 1) {
|
|
256
|
+
// 要改变的按钮
|
|
257
|
+
bits = newState ? 0x02 : 0x01;
|
|
258
|
+
} else {
|
|
259
|
+
// 保持不变的按钮
|
|
260
|
+
if (currentStates[i] === true) {
|
|
261
|
+
bits = 0x02;
|
|
262
|
+
} else if (currentStates[i] === false) {
|
|
263
|
+
bits = 0x01;
|
|
264
|
+
} else {
|
|
265
|
+
bits = 0x00; // 保持不变
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
stateValue |= (bits << bitPos);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return stateValue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
PROTOCOL,
|
|
277
|
+
calculateChecksum,
|
|
278
|
+
buildGetDeviceListFrame,
|
|
279
|
+
parseDeviceListResponse,
|
|
280
|
+
buildSwitchControlFrame,
|
|
281
|
+
parseStatusEvent,
|
|
282
|
+
parseMultiSwitchState,
|
|
283
|
+
buildMultiSwitchState
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
var node = this;
|
|
17
17
|
var stateCache = {}; // 缓存所有线圈状态
|
|
18
18
|
var relayNamesCache = {}; // 缓存继电器名称
|
|
19
|
+
var pollInterval = null; // 轮询定时器(全局变量,用于清理)
|
|
19
20
|
|
|
20
21
|
// 填充主站节点选择器
|
|
21
22
|
var masterNodeSelect = $("#node-input-masterNode");
|
|
@@ -62,17 +63,27 @@
|
|
|
62
63
|
return;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
// 显示加载中
|
|
67
|
+
container.html('<div style="padding: 40px; text-align: center; color: #999; font-size: 14px;"><i class="fa fa-spinner fa-spin"></i> 加载中...</div>');
|
|
68
|
+
|
|
69
|
+
// 通过HTTP API获取主站节点的最新配置
|
|
70
|
+
$.ajax({
|
|
71
|
+
url: '/modbus-dashboard/master-config/' + masterNodeId,
|
|
72
|
+
method: 'GET',
|
|
73
|
+
success: function(masterConfig) {
|
|
74
|
+
if (!masterConfig || !masterConfig.slaves || masterConfig.slaves.length === 0) {
|
|
75
|
+
container.html('<div style="padding: 40px; text-align: center; color: #999; font-size: 14px;">主站节点未配置从站</div>');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
70
78
|
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
// 加载继电器名称
|
|
80
|
+
loadRelayNames();
|
|
73
81
|
|
|
74
|
-
|
|
75
|
-
|
|
82
|
+
// 清空容器
|
|
83
|
+
container.empty();
|
|
84
|
+
|
|
85
|
+
// 遍历所有从站
|
|
86
|
+
masterConfig.slaves.forEach(function(slave) {
|
|
76
87
|
var slaveSection = $('<div class="slave-section">');
|
|
77
88
|
|
|
78
89
|
var slaveHeader = $(`
|
|
@@ -113,20 +124,25 @@
|
|
|
113
124
|
container.append(slaveSection);
|
|
114
125
|
});
|
|
115
126
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
// 绑定按钮点击事件
|
|
128
|
+
$(".btn-toggle").off("click").on("click", function() {
|
|
129
|
+
var slaveAddr = parseInt($(this).data("slave"));
|
|
130
|
+
var coil = parseInt($(this).data("coil"));
|
|
131
|
+
var key = slaveAddr + "_" + coil;
|
|
132
|
+
var currentState = stateCache[key] || false;
|
|
133
|
+
var newState = !currentState;
|
|
134
|
+
|
|
135
|
+
// 发送控制命令(通过HTTP API)
|
|
136
|
+
sendControlCommand(slaveAddr, coil, newState);
|
|
137
|
+
|
|
138
|
+
// 立即更新UI(乐观更新)
|
|
139
|
+
stateCache[key] = newState;
|
|
140
|
+
updateButtonState($(this), newState);
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
error: function(xhr, status, error) {
|
|
144
|
+
container.html('<div style="padding: 40px; text-align: center; color: #f44336; font-size: 14px;"><i class="fa fa-exclamation-triangle"></i> 加载失败: ' + error + '</div>');
|
|
145
|
+
}
|
|
130
146
|
});
|
|
131
147
|
}
|
|
132
148
|
|
|
@@ -164,7 +180,6 @@
|
|
|
164
180
|
}
|
|
165
181
|
|
|
166
182
|
// 轮询状态更新(每500ms)
|
|
167
|
-
var pollInterval = null;
|
|
168
183
|
function startPolling() {
|
|
169
184
|
if (pollInterval) {
|
|
170
185
|
clearInterval(pollInterval);
|
|
@@ -241,6 +256,27 @@
|
|
|
241
256
|
$("#node-dialog-cancel, #node-dialog-ok").on("click", function() {
|
|
242
257
|
stopPolling();
|
|
243
258
|
});
|
|
259
|
+
},
|
|
260
|
+
oneditcancel: function() {
|
|
261
|
+
// 取消编辑时停止轮询,防止内存泄漏
|
|
262
|
+
if (pollInterval) {
|
|
263
|
+
clearInterval(pollInterval);
|
|
264
|
+
pollInterval = null;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
oneditsave: function() {
|
|
268
|
+
// 保存时停止轮询,防止内存泄漏
|
|
269
|
+
if (pollInterval) {
|
|
270
|
+
clearInterval(pollInterval);
|
|
271
|
+
pollInterval = null;
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
oneditdelete: function() {
|
|
275
|
+
// 删除节点时停止轮询,防止内存泄漏
|
|
276
|
+
if (pollInterval) {
|
|
277
|
+
clearInterval(pollInterval);
|
|
278
|
+
pollInterval = null;
|
|
279
|
+
}
|
|
244
280
|
}
|
|
245
281
|
});
|
|
246
282
|
</script>
|
|
@@ -267,7 +303,7 @@
|
|
|
267
303
|
<i class="fa fa-dashboard"></i> 控制面板
|
|
268
304
|
</label>
|
|
269
305
|
<div id="dashboard-container" style="
|
|
270
|
-
max-height:
|
|
306
|
+
max-height: 700px;
|
|
271
307
|
overflow-y: auto;
|
|
272
308
|
border: 1px solid #ddd;
|
|
273
309
|
border-radius: 4px;
|
|
@@ -62,6 +62,24 @@ module.exports = function(RED) {
|
|
|
62
62
|
|
|
63
63
|
RED.nodes.registerType("modbus-dashboard", ModbusDashboardNode);
|
|
64
64
|
|
|
65
|
+
// HTTP API:获取主站节点配置
|
|
66
|
+
RED.httpAdmin.get('/modbus-dashboard/master-config/:id', function(req, res) {
|
|
67
|
+
var masterNodeId = req.params.id;
|
|
68
|
+
var masterNode = RED.nodes.getNode(masterNodeId);
|
|
69
|
+
|
|
70
|
+
if (!masterNode) {
|
|
71
|
+
res.status(404).json({error: '主站节点不存在'});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 返回主站配置(包括最新的从站列表)
|
|
76
|
+
// 从站列表存储在 node.config.slaves 中
|
|
77
|
+
res.json({
|
|
78
|
+
slaves: (masterNode.config && masterNode.config.slaves) ? masterNode.config.slaves : [],
|
|
79
|
+
relayNames: masterNode.relayNames || {}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
65
83
|
// HTTP API:获取状态
|
|
66
84
|
RED.httpAdmin.get('/modbus-dashboard/state', function(req, res) {
|
|
67
85
|
res.json({
|