node-red-contrib-symi-modbus 2.6.8 → 2.7.2
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 +164 -57
- package/nodes/custom-protocol.html +276 -0
- package/nodes/custom-protocol.js +201 -0
- package/nodes/modbus-dashboard.html +396 -0
- package/nodes/modbus-dashboard.js +98 -0
- package/nodes/modbus-master.js +130 -60
- package/package.json +5 -3
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
function CustomProtocolNode(config) {
|
|
5
|
+
RED.nodes.createNode(this, config);
|
|
6
|
+
var node = this;
|
|
7
|
+
|
|
8
|
+
// 配置
|
|
9
|
+
node.config = {
|
|
10
|
+
name: config.name || "自定义协议",
|
|
11
|
+
deviceType: config.deviceType || "switch",
|
|
12
|
+
serialConfig: config.serialConfig,
|
|
13
|
+
openCmd: config.openCmd || "",
|
|
14
|
+
closeCmd: config.closeCmd || "",
|
|
15
|
+
pauseCmd: config.pauseCmd || ""
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// 窗帘模式的状态索引(0=打开, 1=关闭, 2=暂停)
|
|
19
|
+
node.curtainStateIndex = 0;
|
|
20
|
+
|
|
21
|
+
// 获取串口配置节点
|
|
22
|
+
var serialNode = RED.nodes.getNode(node.config.serialConfig);
|
|
23
|
+
if (!serialNode) {
|
|
24
|
+
node.error('未找到串口配置节点');
|
|
25
|
+
node.status({fill: "red", shape: "ring", text: "未配置串口"});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 16进制字符串转Buffer
|
|
30
|
+
function hexStringToBuffer(hexString) {
|
|
31
|
+
if (!hexString || hexString.trim() === '') {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
// 移除空格和非16进制字符
|
|
37
|
+
var hex = hexString.replace(/\s+/g, '').replace(/[^0-9A-Fa-f]/g, '');
|
|
38
|
+
|
|
39
|
+
// 确保是偶数长度
|
|
40
|
+
if (hex.length % 2 !== 0) {
|
|
41
|
+
node.warn('16进制字符串长度必须为偶数: ' + hexString);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 限制48字节
|
|
46
|
+
if (hex.length > 96) {
|
|
47
|
+
hex = hex.substring(0, 96);
|
|
48
|
+
node.warn('指令长度超过48字节,已截断');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 转换为Buffer
|
|
52
|
+
var buffer = Buffer.from(hex, 'hex');
|
|
53
|
+
return buffer;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
node.error('16进制字符串转换失败: ' + err.message);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 发送指令到串口
|
|
61
|
+
function sendCommand(buffer, cmdName) {
|
|
62
|
+
if (!buffer) {
|
|
63
|
+
node.warn('指令为空,跳过发送');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 输出消息(可连线到debug节点)
|
|
68
|
+
var msg = {
|
|
69
|
+
payload: buffer,
|
|
70
|
+
topic: 'custom-protocol',
|
|
71
|
+
command: cmdName
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
node.send(msg);
|
|
75
|
+
node.status({fill: "green", shape: "dot", text: cmdName + " (" + buffer.length + "字节)"});
|
|
76
|
+
|
|
77
|
+
// 3秒后清除状态
|
|
78
|
+
setTimeout(function() {
|
|
79
|
+
node.status({fill: "blue", shape: "ring", text: "就绪"});
|
|
80
|
+
}, 3000);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 处理输入消息
|
|
84
|
+
node.on('input', function(msg) {
|
|
85
|
+
var value = msg.payload;
|
|
86
|
+
|
|
87
|
+
// 只接受布尔值
|
|
88
|
+
if (typeof value !== 'boolean') {
|
|
89
|
+
node.warn('输入必须为true/false,当前类型: ' + typeof value);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (node.config.deviceType === 'curtain') {
|
|
94
|
+
// 窗帘模式:true/false交替触发,循环发送三个指令
|
|
95
|
+
var commands = [
|
|
96
|
+
{name: '打开', hex: node.config.openCmd},
|
|
97
|
+
{name: '关闭', hex: node.config.closeCmd},
|
|
98
|
+
{name: '暂停', hex: node.config.pauseCmd}
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
// 获取当前指令
|
|
102
|
+
var currentCmd = commands[node.curtainStateIndex];
|
|
103
|
+
if (!currentCmd || !currentCmd.hex) {
|
|
104
|
+
node.warn('窗帘模式缺少' + currentCmd.name + '指令配置');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 转换并发送
|
|
109
|
+
var buffer = hexStringToBuffer(currentCmd.hex);
|
|
110
|
+
if (buffer) {
|
|
111
|
+
sendCommand(buffer, currentCmd.name);
|
|
112
|
+
|
|
113
|
+
// 移动到下一个状态
|
|
114
|
+
node.curtainStateIndex = (node.curtainStateIndex + 1) % 3;
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
// 开关/其他模式:true发送打开,false发送关闭
|
|
118
|
+
var cmdName = value ? '打开' : '关闭';
|
|
119
|
+
var hexString = value ? node.config.openCmd : node.config.closeCmd;
|
|
120
|
+
|
|
121
|
+
if (!hexString) {
|
|
122
|
+
node.warn(cmdName + '指令未配置');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
var buffer = hexStringToBuffer(hexString);
|
|
127
|
+
if (buffer) {
|
|
128
|
+
sendCommand(buffer, cmdName);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// 初始状态
|
|
134
|
+
var typeNames = {
|
|
135
|
+
'switch': '开关',
|
|
136
|
+
'curtain': '窗帘',
|
|
137
|
+
'other': '其他'
|
|
138
|
+
};
|
|
139
|
+
var typeName = typeNames[node.config.deviceType] || '自定义';
|
|
140
|
+
node.status({fill: "blue", shape: "ring", text: typeName + "模式就绪"});
|
|
141
|
+
|
|
142
|
+
// 清理
|
|
143
|
+
node.on('close', function() {
|
|
144
|
+
node.status({});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
RED.nodes.registerType("custom-protocol", CustomProtocolNode);
|
|
149
|
+
|
|
150
|
+
// HTTP API:测试发送指令
|
|
151
|
+
RED.httpAdmin.post('/custom-protocol/test', function(req, res) {
|
|
152
|
+
var serialConfigId = req.body.serialConfig;
|
|
153
|
+
var hexString = req.body.hexString;
|
|
154
|
+
var cmdName = req.body.cmdName;
|
|
155
|
+
|
|
156
|
+
if (!serialConfigId || !hexString) {
|
|
157
|
+
res.status(400).json({success: false, error: '参数错误'});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 获取串口配置节点
|
|
162
|
+
var serialNode = RED.nodes.getNode(serialConfigId);
|
|
163
|
+
if (!serialNode) {
|
|
164
|
+
res.status(404).json({success: false, error: '未找到串口配置节点'});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
// 转换16进制字符串为Buffer
|
|
170
|
+
var hex = hexString.replace(/\s+/g, '').replace(/[^0-9A-Fa-f]/g, '');
|
|
171
|
+
if (hex.length % 2 !== 0) {
|
|
172
|
+
res.status(400).json({success: false, error: '16进制字符串长度必须为偶数'});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (hex.length > 96) {
|
|
176
|
+
hex = hex.substring(0, 96);
|
|
177
|
+
}
|
|
178
|
+
var buffer = Buffer.from(hex, 'hex');
|
|
179
|
+
|
|
180
|
+
// 发送到串口
|
|
181
|
+
if (serialNode.serialPort && serialNode.serialPort.isOpen) {
|
|
182
|
+
serialNode.serialPort.write(buffer, function(err) {
|
|
183
|
+
if (err) {
|
|
184
|
+
res.json({success: false, error: err.message});
|
|
185
|
+
} else {
|
|
186
|
+
res.json({
|
|
187
|
+
success: true,
|
|
188
|
+
message: cmdName + '指令已发送',
|
|
189
|
+
bytes: buffer.length
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
} else {
|
|
194
|
+
res.status(503).json({success: false, error: '串口未打开'});
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
res.status(500).json({success: false, error: err.message});
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('modbus-dashboard', {
|
|
3
|
+
category: 'SYMI-MODBUS',
|
|
4
|
+
color: '#4CAF50',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: {value: "Modbus控制看板"},
|
|
7
|
+
masterNode: {value: "", required: true}
|
|
8
|
+
},
|
|
9
|
+
inputs: 0,
|
|
10
|
+
outputs: 0,
|
|
11
|
+
icon: "font-awesome/fa-dashboard",
|
|
12
|
+
label: function() {
|
|
13
|
+
return this.name || "Modbus控制看板";
|
|
14
|
+
},
|
|
15
|
+
oneditprepare: function() {
|
|
16
|
+
var node = this;
|
|
17
|
+
var stateCache = {}; // 缓存所有线圈状态
|
|
18
|
+
var relayNamesCache = {}; // 缓存继电器名称
|
|
19
|
+
|
|
20
|
+
// 填充主站节点选择器
|
|
21
|
+
var masterNodeSelect = $("#node-input-masterNode");
|
|
22
|
+
masterNodeSelect.empty();
|
|
23
|
+
masterNodeSelect.append('<option value="">请选择主站节点</option>');
|
|
24
|
+
|
|
25
|
+
RED.nodes.eachNode(function(n) {
|
|
26
|
+
if (n.type === "modbus-master") {
|
|
27
|
+
var label = n.name || `主站 ${n.id.substring(0, 8)}`;
|
|
28
|
+
var selected = (n.id === node.masterNode) ? ' selected' : '';
|
|
29
|
+
masterNodeSelect.append(`<option value="${n.id}"${selected}>${label}</option>`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// 加载HomeKit网桥的名称配置
|
|
34
|
+
function loadRelayNames() {
|
|
35
|
+
RED.nodes.eachNode(function(n) {
|
|
36
|
+
if (n.type === "homekit-bridge" && n.masterNode === node.masterNode) {
|
|
37
|
+
if (n.relayNames) {
|
|
38
|
+
relayNamesCache = Object.assign({}, n.relayNames);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 获取继电器名称
|
|
45
|
+
function getRelayName(slaveAddr, coil) {
|
|
46
|
+
var key = slaveAddr + "_" + coil;
|
|
47
|
+
if (relayNamesCache[key]) {
|
|
48
|
+
return relayNamesCache[key];
|
|
49
|
+
}
|
|
50
|
+
var relayType = coil < 16 ? "开关" : "插座";
|
|
51
|
+
return `${relayType}${coil + 1}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 渲染控制面板
|
|
55
|
+
function renderDashboard() {
|
|
56
|
+
var masterNodeId = $("#node-input-masterNode").val();
|
|
57
|
+
var container = $("#dashboard-container");
|
|
58
|
+
container.empty();
|
|
59
|
+
|
|
60
|
+
if (!masterNodeId) {
|
|
61
|
+
container.html('<div style="padding: 40px; text-align: center; color: #999; font-size: 14px;">请先选择主站节点</div>');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
var masterNode = RED.nodes.node(masterNodeId);
|
|
66
|
+
if (!masterNode || !masterNode.slaves || masterNode.slaves.length === 0) {
|
|
67
|
+
container.html('<div style="padding: 40px; text-align: center; color: #999; font-size: 14px;">主站节点未配置从站</div>');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 加载继电器名称
|
|
72
|
+
loadRelayNames();
|
|
73
|
+
|
|
74
|
+
// 遍历所有从站
|
|
75
|
+
masterNode.slaves.forEach(function(slave) {
|
|
76
|
+
var slaveSection = $('<div class="slave-section">');
|
|
77
|
+
|
|
78
|
+
var slaveHeader = $(`
|
|
79
|
+
<div class="slave-header">
|
|
80
|
+
<span class="slave-title">从站 ${slave.address}</span>
|
|
81
|
+
<span class="slave-info">线圈 ${slave.coilStart}-${slave.coilEnd}</span>
|
|
82
|
+
</div>
|
|
83
|
+
`);
|
|
84
|
+
|
|
85
|
+
var coilGrid = $('<div class="coil-grid">');
|
|
86
|
+
|
|
87
|
+
for (var coil = slave.coilStart; coil <= slave.coilEnd; coil++) {
|
|
88
|
+
var key = slave.address + "_" + coil;
|
|
89
|
+
var relayName = getRelayName(slave.address, coil);
|
|
90
|
+
var currentState = stateCache[key] || false;
|
|
91
|
+
var stateClass = currentState ? 'state-on' : 'state-off';
|
|
92
|
+
var stateText = currentState ? 'ON' : 'OFF';
|
|
93
|
+
|
|
94
|
+
var coilItem = $(`
|
|
95
|
+
<div class="coil-item">
|
|
96
|
+
<div class="coil-header">
|
|
97
|
+
<span class="coil-name" title="${relayName}">${relayName}</span>
|
|
98
|
+
<span class="coil-number">线圈${coil}</span>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="coil-control">
|
|
101
|
+
<button class="btn-toggle ${stateClass}" data-slave="${slave.address}" data-coil="${coil}">
|
|
102
|
+
<span class="state-text">${stateText}</span>
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
`);
|
|
107
|
+
|
|
108
|
+
coilGrid.append(coilItem);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
slaveSection.append(slaveHeader);
|
|
112
|
+
slaveSection.append(coilGrid);
|
|
113
|
+
container.append(slaveSection);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 绑定按钮点击事件
|
|
117
|
+
$(".btn-toggle").off("click").on("click", function() {
|
|
118
|
+
var slaveAddr = parseInt($(this).data("slave"));
|
|
119
|
+
var coil = parseInt($(this).data("coil"));
|
|
120
|
+
var key = slaveAddr + "_" + coil;
|
|
121
|
+
var currentState = stateCache[key] || false;
|
|
122
|
+
var newState = !currentState;
|
|
123
|
+
|
|
124
|
+
// 发送控制命令(通过HTTP API)
|
|
125
|
+
sendControlCommand(slaveAddr, coil, newState);
|
|
126
|
+
|
|
127
|
+
// 立即更新UI(乐观更新)
|
|
128
|
+
stateCache[key] = newState;
|
|
129
|
+
updateButtonState($(this), newState);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 更新按钮状态
|
|
134
|
+
function updateButtonState(button, state) {
|
|
135
|
+
if (state) {
|
|
136
|
+
button.removeClass('state-off').addClass('state-on');
|
|
137
|
+
button.find('.state-text').text('ON');
|
|
138
|
+
} else {
|
|
139
|
+
button.removeClass('state-on').addClass('state-off');
|
|
140
|
+
button.find('.state-text').text('OFF');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 发送控制命令
|
|
145
|
+
function sendControlCommand(slaveAddr, coil, value) {
|
|
146
|
+
// 通过Node-RED的admin API发送注入命令
|
|
147
|
+
$.ajax({
|
|
148
|
+
url: '/modbus-dashboard/control',
|
|
149
|
+
method: 'POST',
|
|
150
|
+
contentType: 'application/json',
|
|
151
|
+
data: JSON.stringify({
|
|
152
|
+
slave: slaveAddr,
|
|
153
|
+
coil: coil,
|
|
154
|
+
value: value
|
|
155
|
+
}),
|
|
156
|
+
success: function() {
|
|
157
|
+
console.log(`控制命令已发送: 从站${slaveAddr} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
|
|
158
|
+
},
|
|
159
|
+
error: function(err) {
|
|
160
|
+
console.error('控制命令发送失败:', err);
|
|
161
|
+
RED.notify('控制命令发送失败', 'error');
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 轮询状态更新(每500ms)
|
|
167
|
+
var pollInterval = null;
|
|
168
|
+
function startPolling() {
|
|
169
|
+
if (pollInterval) {
|
|
170
|
+
clearInterval(pollInterval);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
pollInterval = setInterval(function() {
|
|
174
|
+
var masterNodeId = $("#node-input-masterNode").val();
|
|
175
|
+
if (!masterNodeId) return;
|
|
176
|
+
|
|
177
|
+
$.ajax({
|
|
178
|
+
url: '/modbus-dashboard/state',
|
|
179
|
+
method: 'GET',
|
|
180
|
+
success: function(data) {
|
|
181
|
+
if (data && data.states) {
|
|
182
|
+
// 更新状态缓存
|
|
183
|
+
Object.keys(data.states).forEach(function(key) {
|
|
184
|
+
var oldState = stateCache[key];
|
|
185
|
+
var newState = data.states[key];
|
|
186
|
+
stateCache[key] = newState;
|
|
187
|
+
|
|
188
|
+
// 如果状态变化,更新UI
|
|
189
|
+
if (oldState !== newState) {
|
|
190
|
+
var parts = key.split('_');
|
|
191
|
+
var slaveAddr = parts[0];
|
|
192
|
+
var coil = parts[1];
|
|
193
|
+
var button = $(`.btn-toggle[data-slave="${slaveAddr}"][data-coil="${coil}"]`);
|
|
194
|
+
if (button.length > 0) {
|
|
195
|
+
updateButtonState(button, newState);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
error: function(err) {
|
|
202
|
+
console.error('状态轮询失败:', err);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}, 500);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 停止轮询
|
|
209
|
+
function stopPolling() {
|
|
210
|
+
if (pollInterval) {
|
|
211
|
+
clearInterval(pollInterval);
|
|
212
|
+
pollInterval = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 监听主站节点选择变化
|
|
217
|
+
masterNodeSelect.on("change", function() {
|
|
218
|
+
renderDashboard();
|
|
219
|
+
stopPolling();
|
|
220
|
+
if ($(this).val()) {
|
|
221
|
+
startPolling();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// 初始渲染
|
|
226
|
+
renderDashboard();
|
|
227
|
+
if (node.masterNode) {
|
|
228
|
+
startPolling();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 对话框关闭时停止轮询
|
|
232
|
+
$("#node-dialog-cancel, #node-dialog-ok").on("click", function() {
|
|
233
|
+
stopPolling();
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
</script>
|
|
238
|
+
|
|
239
|
+
<script type="text/html" data-template-name="modbus-dashboard">
|
|
240
|
+
<div class="form-row">
|
|
241
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> 节点名称</label>
|
|
242
|
+
<input type="text" id="node-input-name" placeholder="Modbus控制看板">
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<div class="form-row">
|
|
246
|
+
<label for="node-input-masterNode"><i class="fa fa-microchip"></i> 主站节点</label>
|
|
247
|
+
<select id="node-input-masterNode" style="width: 70%;">
|
|
248
|
+
<option value="">请选择主站节点</option>
|
|
249
|
+
</select>
|
|
250
|
+
<div style="font-size: 11px; color: #999; margin-top: 5px;">选择要监控的Modbus主站节点</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div class="form-row" style="margin-top: 20px;">
|
|
254
|
+
<label style="width: 100%; font-weight: bold; margin-bottom: 10px;">
|
|
255
|
+
<i class="fa fa-dashboard"></i> 控制面板
|
|
256
|
+
</label>
|
|
257
|
+
<div id="dashboard-container" style="
|
|
258
|
+
max-height: 500px;
|
|
259
|
+
overflow-y: auto;
|
|
260
|
+
border: 1px solid #ddd;
|
|
261
|
+
border-radius: 4px;
|
|
262
|
+
background: #fafafa;
|
|
263
|
+
padding: 10px;
|
|
264
|
+
"></div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<style>
|
|
268
|
+
.slave-section {
|
|
269
|
+
margin-bottom: 20px;
|
|
270
|
+
background: white;
|
|
271
|
+
border-radius: 8px;
|
|
272
|
+
padding: 15px;
|
|
273
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.slave-header {
|
|
277
|
+
display: flex;
|
|
278
|
+
justify-content: space-between;
|
|
279
|
+
align-items: center;
|
|
280
|
+
margin-bottom: 15px;
|
|
281
|
+
padding-bottom: 10px;
|
|
282
|
+
border-bottom: 2px solid #4CAF50;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.slave-title {
|
|
286
|
+
font-weight: bold;
|
|
287
|
+
font-size: 16px;
|
|
288
|
+
color: #333;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.slave-info {
|
|
292
|
+
font-size: 12px;
|
|
293
|
+
color: #666;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.coil-grid {
|
|
297
|
+
display: grid;
|
|
298
|
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
299
|
+
gap: 10px;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.coil-item {
|
|
303
|
+
background: #f9f9f9;
|
|
304
|
+
border: 1px solid #e0e0e0;
|
|
305
|
+
border-radius: 6px;
|
|
306
|
+
padding: 10px;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.coil-header {
|
|
310
|
+
display: flex;
|
|
311
|
+
justify-content: space-between;
|
|
312
|
+
align-items: center;
|
|
313
|
+
margin-bottom: 8px;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.coil-name {
|
|
317
|
+
font-weight: 500;
|
|
318
|
+
font-size: 13px;
|
|
319
|
+
color: #333;
|
|
320
|
+
overflow: hidden;
|
|
321
|
+
text-overflow: ellipsis;
|
|
322
|
+
white-space: nowrap;
|
|
323
|
+
max-width: 120px;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.coil-number {
|
|
327
|
+
font-size: 11px;
|
|
328
|
+
color: #999;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.coil-control {
|
|
332
|
+
display: flex;
|
|
333
|
+
justify-content: center;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.btn-toggle {
|
|
337
|
+
width: 100%;
|
|
338
|
+
padding: 8px 16px;
|
|
339
|
+
border: none;
|
|
340
|
+
border-radius: 4px;
|
|
341
|
+
font-weight: bold;
|
|
342
|
+
font-size: 13px;
|
|
343
|
+
cursor: pointer;
|
|
344
|
+
transition: all 0.2s;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.btn-toggle.state-on {
|
|
348
|
+
background: #4CAF50;
|
|
349
|
+
color: white;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.btn-toggle.state-on:hover {
|
|
353
|
+
background: #45a049;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.btn-toggle.state-off {
|
|
357
|
+
background: #f44336;
|
|
358
|
+
color: white;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.btn-toggle.state-off:hover {
|
|
362
|
+
background: #da190b;
|
|
363
|
+
}
|
|
364
|
+
</style>
|
|
365
|
+
</script>
|
|
366
|
+
|
|
367
|
+
<script type="text/html" data-help-name="modbus-dashboard">
|
|
368
|
+
<p>Modbus控制看板节点,提供可视化界面显示和控制所有从站的继电器状态。</p>
|
|
369
|
+
|
|
370
|
+
<h3>功能特性</h3>
|
|
371
|
+
<ul>
|
|
372
|
+
<li>实时显示所有从站和线圈的状态</li>
|
|
373
|
+
<li>支持直接点击按钮控制继电器开关</li>
|
|
374
|
+
<li>自动同步HomeKit网桥配置的继电器名称</li>
|
|
375
|
+
<li>美观的网格布局,按从站分组显示</li>
|
|
376
|
+
<li>状态实时更新(500ms轮询)</li>
|
|
377
|
+
<li>零额外开销,不参与实际Modbus通信</li>
|
|
378
|
+
</ul>
|
|
379
|
+
|
|
380
|
+
<h3>使用步骤</h3>
|
|
381
|
+
<ol>
|
|
382
|
+
<li>选择已配置的Modbus主站节点</li>
|
|
383
|
+
<li>在配置界面中查看所有继电器状态</li>
|
|
384
|
+
<li>点击按钮即可控制继电器开关</li>
|
|
385
|
+
<li>部署流程后,节点会显示监控状态</li>
|
|
386
|
+
</ol>
|
|
387
|
+
|
|
388
|
+
<h3>注意事项</h3>
|
|
389
|
+
<ul>
|
|
390
|
+
<li>确保主站节点已正确配置并运行</li>
|
|
391
|
+
<li>控制看板只在配置界面打开时才轮询状态</li>
|
|
392
|
+
<li>继电器名称与HomeKit网桥共享,修改需在HomeKit网桥节点中进行</li>
|
|
393
|
+
<li>本节点不参与实际Modbus通信,不会增加主站负担</li>
|
|
394
|
+
</ul>
|
|
395
|
+
</script>
|
|
396
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// 全局状态缓存(所有dashboard节点共享)
|
|
5
|
+
var globalStateCache = {};
|
|
6
|
+
|
|
7
|
+
function ModbusDashboardNode(config) {
|
|
8
|
+
RED.nodes.createNode(this, config);
|
|
9
|
+
var node = this;
|
|
10
|
+
|
|
11
|
+
// 配置
|
|
12
|
+
node.config = {
|
|
13
|
+
name: config.name || "Modbus控制看板",
|
|
14
|
+
masterNode: config.masterNode
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// 获取主站节点
|
|
18
|
+
var masterNode = RED.nodes.getNode(node.config.masterNode);
|
|
19
|
+
if (!masterNode) {
|
|
20
|
+
node.error('未找到主站节点');
|
|
21
|
+
node.status({fill: "red", shape: "ring", text: "未配置主站"});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 监听主站状态更新事件
|
|
26
|
+
node.stateUpdateHandler = function(data) {
|
|
27
|
+
if (!data || typeof data !== 'object') {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var key = data.slave + "_" + data.coil;
|
|
32
|
+
globalStateCache[key] = data.value;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// 注册事件监听器
|
|
36
|
+
masterNode.on('stateUpdate', node.stateUpdateHandler);
|
|
37
|
+
|
|
38
|
+
// 初始化状态缓存(从主站获取当前状态)
|
|
39
|
+
if (masterNode.deviceStates) {
|
|
40
|
+
Object.keys(masterNode.deviceStates).forEach(function(slaveId) {
|
|
41
|
+
var deviceState = masterNode.deviceStates[slaveId];
|
|
42
|
+
if (deviceState && deviceState.coils) {
|
|
43
|
+
deviceState.coils.forEach(function(value, coil) {
|
|
44
|
+
var key = slaveId + "_" + coil;
|
|
45
|
+
globalStateCache[key] = value;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 更新节点状态
|
|
52
|
+
node.status({fill: "green", shape: "dot", text: "监控中"});
|
|
53
|
+
|
|
54
|
+
// 清理
|
|
55
|
+
node.on('close', function() {
|
|
56
|
+
if (masterNode && node.stateUpdateHandler) {
|
|
57
|
+
masterNode.removeListener('stateUpdate', node.stateUpdateHandler);
|
|
58
|
+
}
|
|
59
|
+
node.status({});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
RED.nodes.registerType("modbus-dashboard", ModbusDashboardNode);
|
|
64
|
+
|
|
65
|
+
// HTTP API:获取状态
|
|
66
|
+
RED.httpAdmin.get('/modbus-dashboard/state', function(req, res) {
|
|
67
|
+
res.json({
|
|
68
|
+
states: globalStateCache
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// HTTP API:发送控制命令
|
|
73
|
+
RED.httpAdmin.post('/modbus-dashboard/control', function(req, res) {
|
|
74
|
+
var slave = parseInt(req.body.slave);
|
|
75
|
+
var coil = parseInt(req.body.coil);
|
|
76
|
+
var value = Boolean(req.body.value);
|
|
77
|
+
|
|
78
|
+
if (isNaN(slave) || isNaN(coil)) {
|
|
79
|
+
res.status(400).json({error: '参数错误'});
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 发送内部事件(通过免连线通信机制)
|
|
84
|
+
RED.events.emit('modbus:writeCoil', {
|
|
85
|
+
slave: slave,
|
|
86
|
+
coil: coil,
|
|
87
|
+
value: value,
|
|
88
|
+
source: 'dashboard'
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// 立即更新缓存(乐观更新)
|
|
92
|
+
var key = slave + "_" + coil;
|
|
93
|
+
globalStateCache[key] = value;
|
|
94
|
+
|
|
95
|
+
res.json({success: true});
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|