node-red-contrib-symi-modbus 2.10.3 → 2.10.5
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 +23 -11
- package/nodes/modbus-master.js +65 -49
- package/nodes/modbus-slave-switch.js +63 -52
- package/nodes/relay-output.html +3 -3
- package/nodes/relay-output.js +60 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,6 +32,8 @@ Node-RED的Modbus继电器控制节点,支持TCP/串口通信和MQTT集成,
|
|
|
32
32
|
- **门禁联动过滤**:继电器节点支持门禁ID过滤,实现精确的门禁联动控制
|
|
33
33
|
- **HomeKit网桥**:一键接入Apple HomeKit,支持Siri语音控制,状态实时同步
|
|
34
34
|
- **智能写入队列**:所有写入操作串行执行,支持优先级管理,确保指示灯反馈在群控场景下秒级响应
|
|
35
|
+
- **多主站精准隔离**:支持多主站并行运行,通过 `masterId` 实现指令精准定向,彻底解决多主站环境下的控制冲突
|
|
36
|
+
- **真·状态翻转逻辑**:继电器输出节点内置状态感知能力,基于物理设备真实反馈执行翻转操作,解决传统盲发指令导致的开关失灵
|
|
35
37
|
- **可视化运维**:
|
|
36
38
|
- **控制看板**:实时显示所有继电器状态,支持手动控制
|
|
37
39
|
- **调试模式**:详细的通信日志,支持十六进制报文监控
|
|
@@ -957,6 +959,20 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
957
959
|
|
|
958
960
|
## 版本更新说明
|
|
959
961
|
|
|
962
|
+
### v2.10.5 (2026-01-15)
|
|
963
|
+
- **多主站精准隔离架构**:引入 `masterId` 指令定向机制。`relay-output` 节点现在可以精准锁定目标主站,彻底解决了多主站环境下(如多台 Modbus 网关)指令错发或“一控多”的冲突问题。
|
|
964
|
+
- **真·状态翻转 (Toggle) 逻辑**:继电器输出节点新增状态感知能力。通过实时监听总线状态回传(Coil State Sync),翻转动作不再是盲目的逻辑取反,而是基于物理设备真实状态的精准操作,解决了高频连点或多端控制下的状态不同步顽疾。
|
|
965
|
+
- **指示灯反馈机制重构**:优化了 `modbus-slave-switch` 的反馈锁与初始化逻辑。移除了过度的初始化过滤,确保面板指示灯在系统重启或状态突变时能秒级同步(包括 OFF 状态),解决了指示灯反馈“只开不关”的问题。
|
|
966
|
+
- **生产级日志分级**:对全局日志进行了分级处理。将底层的 Modbus 通信报文转入 Debug 级别,控制台仅保留关键的业务联动日志(如 `[免连线联动]`),极大提升了高并发场景下的运行效率并降低了 IO 损耗。
|
|
967
|
+
- **资源管理与稳定性**:规范化了所有节点的 `close` 生命周期管理。完善了事件监听器的移除机制,确保在频繁部署或节点增删时内存无泄露,系统长期运行更加稳健。
|
|
968
|
+
|
|
969
|
+
### v2.10.4 (2026-01-14)
|
|
970
|
+
- **Modbus超时优化**:将默认Modbus通信超时时间(Timeout)统一调整为 **3000ms**(原TCP 1000ms/串口 2000ms),彻底解决网络波动或从站响应慢导致的“轮询超时”和“写入失败”问题,这是生产环境稳定的关键参数。
|
|
971
|
+
- **锁等待恢复合理值**:将写入锁等待超时恢复为 **3000ms**,配合更长的底层通信超时,确保在总线繁忙时依然能有序排队,不再出现死锁或虚假超时。
|
|
972
|
+
- **断连彻底静默**:移除写入队列在断连时的错误日志提醒,断线期间不报错、不刷屏,确保生产环境日志整洁。
|
|
973
|
+
- **缓存策略优化**:连接断开时立即清空所有待处理的写入任务(`writeQueue = []`),重连后不处理断连期间的旧指令,只响应重连后的新操作。
|
|
974
|
+
- **连接恢复优化**:移除重连后的自动任务恢复逻辑,实现真正的“断连不处理缓存,重连只理新数”。
|
|
975
|
+
|
|
960
976
|
### v2.10.3 (2026-01-14)
|
|
961
977
|
- **协议兼容性**: 完善 Symi 协议解析逻辑,全面支持 `0x03 SET`、`0x04 REPORT` 和 `0x05 STATUS` 帧类型,解决某些面板状态错位导致的按键不响应问题。
|
|
962
978
|
- **稳定性修复**: 紧急修复物理回显导致的“继电器无限跳动”死循环问题,通过严格过滤 `0x04` 反馈帧并延长反馈锁至 **100ms**,确保系统在复杂干扰环境下依然稳定。
|
|
@@ -978,17 +994,6 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
978
994
|
- **防抖优化**: 引入全局按键事件去重防抖,解决信号抖动问题。
|
|
979
995
|
- **组网支持**: 改进 LED 反馈机制,支持 Symi/Clowire/Mesh 多品牌混合组网下的状态同步。
|
|
980
996
|
|
|
981
|
-
### v2.9.20 (2026-01-12)
|
|
982
|
-
- **协议同步**: RS-485 开关指示灯同步逻辑,严格遵循协议规范(SET 0x03/REPORT 0x04)。
|
|
983
|
-
- **优先队列**: 为 LED 反馈指令引入独立的高优先级队列,确保高负载下指示灯秒级同步。
|
|
984
|
-
- **映射修正**: 修正 `buttonNumber` 类型转换,解决 8 键面板通道映射偏移问题。
|
|
985
|
-
|
|
986
|
-
### v2.9.14 (2025-12-30)
|
|
987
|
-
- **新协议**: 支持 Clowire(克伦威尔)智能面板协议。
|
|
988
|
-
- **新节点**: 继电器输出节点(relay-output),支持通过内部事件直接绑定从站开关。
|
|
989
|
-
- **环境适配**: 优化 HassOS 环境下的串口锁定问题(lock: false)。
|
|
990
|
-
|
|
991
|
-
|
|
992
997
|
**核心特性**:
|
|
993
998
|
- **指示灯同步逻辑优化**:
|
|
994
999
|
- **485面板**:使用 `REPORT (0x04)` 协议进行反馈,并将主机地址固定为 `0x00`,确保同步状态时不触发继电器动作。
|
|
@@ -1013,6 +1018,13 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
1013
1018
|
- **多连接支持**:支持 Mac/Linux 多串口与 TCP 同时运行,各主站实例完全隔离,互不干扰。
|
|
1014
1019
|
- **断电恢复**:所有状态和配置自动持久化,设备上电后 1 秒内自动同步最新状态。
|
|
1015
1020
|
- **日志防护**:错误日志限流与磁盘溢出保护,确保工控机环境下的系统安全。
|
|
1021
|
+
- **日志分级**:生产环境下仅输出关键业务日志,详细通信日志转入 Debug 级别
|
|
1022
|
+
- **智能优先队列**:触发源面板优先处理(500ms优先窗口)
|
|
1023
|
+
- **队列自动处理**:指令自动串行化,避免 RS485 总线冲突
|
|
1024
|
+
- **断连自动恢复**:主站/从站连接断开后支持毫秒级自动重连
|
|
1025
|
+
- **多实例运行**:支持同时运行多个 `Modbus主站` 节点,配置完全隔离
|
|
1026
|
+
- **状态持久化**:Mesh 设备列表和继电器状态在重启后自动恢复
|
|
1027
|
+
- **日志分级**:生产环境下仅输出关键业务日志,详细通信日志转入 Debug 级别
|
|
1016
1028
|
|
|
1017
1029
|
**实际使用场景**:
|
|
1018
1030
|
- 主站轮询:200ms/台,支持5-10台从站设备
|
package/nodes/modbus-master.js
CHANGED
|
@@ -329,9 +329,9 @@ module.exports = function(RED) {
|
|
|
329
329
|
}
|
|
330
330
|
|
|
331
331
|
// 设置Modbus超时时间
|
|
332
|
-
// TCP
|
|
333
|
-
//
|
|
334
|
-
const timeout =
|
|
332
|
+
// TCP模式:提高到3000ms,适配网络波动较大的环境
|
|
333
|
+
// 串口模式:提高到3000ms,串口通信在复杂环境下需要更长时间
|
|
334
|
+
const timeout = 3000;
|
|
335
335
|
node.client.setTimeout(timeout);
|
|
336
336
|
node.log(`Modbus超时设置: ${timeout}ms`);
|
|
337
337
|
|
|
@@ -384,6 +384,7 @@ module.exports = function(RED) {
|
|
|
384
384
|
|
|
385
385
|
} catch (err) {
|
|
386
386
|
node.isConnected = false;
|
|
387
|
+
node.writeQueue = []; // 断开连接时清空写入队列,不处理缓存数据
|
|
387
388
|
|
|
388
389
|
// 优化:如果错误消息没有变化,且不是第一次重试,则不输出 node.error 到全局日志
|
|
389
390
|
// 这样可以避免 ECONNREFUSED 等持续性错误刷屏
|
|
@@ -901,17 +902,12 @@ module.exports = function(RED) {
|
|
|
901
902
|
|
|
902
903
|
const coilCount = slave.coilEnd - slave.coilStart + 1;
|
|
903
904
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
// 检查锁状态(如果有写操作正在进行,或者写入队列不为空,跳过本次轮询,优先让出总线)
|
|
913
|
-
if (node.modbusLock || node.writeQueue.length > 0 || node.isProcessingWrite) {
|
|
914
|
-
// 跳过该从站(不移动索引,由pollLoop统一管理)
|
|
905
|
+
// 检查是否刚写入过(写入后50ms内不轮询该从站,避免读到旧值)
|
|
906
|
+
const lastWrite = node.lastWriteTime[slaveId] || 0;
|
|
907
|
+
const timeSinceWrite = Date.now() - lastWrite;
|
|
908
|
+
if (timeSinceWrite < 50) {
|
|
909
|
+
// 跳过该从站,但必须移动索引,否则会死锁在当前从站
|
|
910
|
+
node.currentSlaveIndex = (node.currentSlaveIndex + 1) % node.config.slaves.length;
|
|
915
911
|
return;
|
|
916
912
|
}
|
|
917
913
|
|
|
@@ -1069,6 +1065,7 @@ module.exports = function(RED) {
|
|
|
1069
1065
|
}
|
|
1070
1066
|
|
|
1071
1067
|
node.isConnected = false;
|
|
1068
|
+
node.writeQueue = []; // 断开连接时清空写入队列,不处理缓存数据
|
|
1072
1069
|
// 原则:轮询永不停止,即使连接断开也由 startPolling 的 pollLoop 自动重试
|
|
1073
1070
|
// 不再调用 node.stopPolling(),避免轮询线程彻底终止
|
|
1074
1071
|
// node.stopPolling();
|
|
@@ -1241,6 +1238,11 @@ module.exports = function(RED) {
|
|
|
1241
1238
|
return;
|
|
1242
1239
|
}
|
|
1243
1240
|
|
|
1241
|
+
// 如果未连接,静默退出,等待重连后自动触发,避免断连期间日志刷屏
|
|
1242
|
+
if (!node.isConnected || !node.client || node.isClosing) {
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1244
1246
|
node.isProcessingWrite = true;
|
|
1245
1247
|
|
|
1246
1248
|
try {
|
|
@@ -1251,7 +1253,6 @@ module.exports = function(RED) {
|
|
|
1251
1253
|
while (node.writeQueue.length > 0) {
|
|
1252
1254
|
// 再次检查连接和客户端,防止处理中途断开
|
|
1253
1255
|
if (node.isClosing || !node.isConnected || !node.client) {
|
|
1254
|
-
node.log('处理写入队列时Modbus断开,等待重连...');
|
|
1255
1256
|
break;
|
|
1256
1257
|
}
|
|
1257
1258
|
|
|
@@ -1293,8 +1294,8 @@ module.exports = function(RED) {
|
|
|
1293
1294
|
} finally {
|
|
1294
1295
|
node.isProcessingWrite = false;
|
|
1295
1296
|
|
|
1296
|
-
//
|
|
1297
|
-
if (node.writeQueue.length > 0) {
|
|
1297
|
+
// 检查是否在处理期间又有新任务加入,且连接正常
|
|
1298
|
+
if (node.writeQueue.length > 0 && node.isConnected && !node.isClosing) {
|
|
1298
1299
|
setImmediate(() => node.processWriteQueue());
|
|
1299
1300
|
}
|
|
1300
1301
|
}
|
|
@@ -1312,13 +1313,10 @@ module.exports = function(RED) {
|
|
|
1312
1313
|
|
|
1313
1314
|
try {
|
|
1314
1315
|
// 等待锁释放
|
|
1315
|
-
//
|
|
1316
|
-
const maxWait =
|
|
1316
|
+
// 恢复为3000ms,合理的锁等待时间
|
|
1317
|
+
const maxWait = 3000;
|
|
1317
1318
|
const startWait = Date.now();
|
|
1318
1319
|
while (node.modbusLock && (Date.now() - startWait) < maxWait) {
|
|
1319
|
-
if (node.modbusLockType === 'polling' && (Date.now() - startWait) > 100) {
|
|
1320
|
-
break;
|
|
1321
|
-
}
|
|
1322
1320
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
1323
1321
|
}
|
|
1324
1322
|
|
|
@@ -1400,18 +1398,11 @@ module.exports = function(RED) {
|
|
|
1400
1398
|
const pauseStartTime = Date.now();
|
|
1401
1399
|
|
|
1402
1400
|
try {
|
|
1403
|
-
//
|
|
1404
|
-
|
|
1401
|
+
// 等待锁释放(写入锁优先级更高)
|
|
1402
|
+
// 恢复为3000ms,合理的锁等待时间
|
|
1403
|
+
const maxWait = 3000;
|
|
1405
1404
|
const startWait = Date.now();
|
|
1406
1405
|
while (node.modbusLock && (Date.now() - startWait) < maxWait) {
|
|
1407
|
-
// 如果当前是轮询锁,且我们已经等待了超过 40ms(一个基本的 Modbus 交互周期)
|
|
1408
|
-
// 我们可以标记锁可以被“抢占”,但底层驱动由于是单线程,其实还是要等 await 返回
|
|
1409
|
-
// 这里的关键是:如果是轮询锁,我们不应该等待太久
|
|
1410
|
-
if (node.modbusLockType === 'polling' && (Date.now() - startWait) > 100) {
|
|
1411
|
-
// 强制跳出等待循环,让写操作尝试继续
|
|
1412
|
-
// 注意:这只是为了防止 pollNextSlave 挂起导致锁不释放
|
|
1413
|
-
break;
|
|
1414
|
-
}
|
|
1415
1406
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
1416
1407
|
}
|
|
1417
1408
|
|
|
@@ -1436,7 +1427,7 @@ module.exports = function(RED) {
|
|
|
1436
1427
|
node.client.setID(slaveId);
|
|
1437
1428
|
|
|
1438
1429
|
// 记录写入操作(帮助追踪总线数据来源)
|
|
1439
|
-
node.
|
|
1430
|
+
node.debug(`[Modbus写入] 从站${slaveId} 线圈${coil} = ${value ? 'ON' : 'OFF'} (触发源: ${triggerSource})`);
|
|
1440
1431
|
|
|
1441
1432
|
await node.client.writeCoil(coil, value);
|
|
1442
1433
|
|
|
@@ -1447,7 +1438,7 @@ module.exports = function(RED) {
|
|
|
1447
1438
|
const oldValue = node.deviceStates[slaveId].coils[coil];
|
|
1448
1439
|
node.deviceStates[slaveId].coils[coil] = value;
|
|
1449
1440
|
|
|
1450
|
-
node.
|
|
1441
|
+
node.debug(`[Modbus成功] 从站${slaveId} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
|
|
1451
1442
|
|
|
1452
1443
|
// 发布到MQTT和触发事件
|
|
1453
1444
|
node.publishMqttState(slaveId, coil, value);
|
|
@@ -1492,6 +1483,17 @@ module.exports = function(RED) {
|
|
|
1492
1483
|
// 写单个线圈(公共接口,通过队列执行)
|
|
1493
1484
|
node.writeSingleCoil = function(slaveId, coil, value, triggerSource = 'unknown') {
|
|
1494
1485
|
return new Promise((resolve, reject) => {
|
|
1486
|
+
// 队列去重:如果队列中已经有针对同一从站同一线圈的写入任务,则移除旧任务,只保留最新的
|
|
1487
|
+
// 这能有效防止“连点”导致的队列积压和状态反复跳变
|
|
1488
|
+
node.writeQueue = node.writeQueue.filter(task => {
|
|
1489
|
+
if (task.type === 'single' && task.slaveId === slaveId && task.coil === coil) {
|
|
1490
|
+
// 如果任务还未开始执行(在队列中),直接移除,并调用其resolve防止Promise挂起
|
|
1491
|
+
if (task.resolve) task.resolve();
|
|
1492
|
+
return false;
|
|
1493
|
+
}
|
|
1494
|
+
return true;
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1495
1497
|
// 添加到队列
|
|
1496
1498
|
node.writeQueue.push({
|
|
1497
1499
|
type: 'single',
|
|
@@ -1519,13 +1521,11 @@ module.exports = function(RED) {
|
|
|
1519
1521
|
const pauseStartTime = Date.now();
|
|
1520
1522
|
|
|
1521
1523
|
try {
|
|
1522
|
-
//
|
|
1523
|
-
|
|
1524
|
+
// 等待锁释放
|
|
1525
|
+
// 恢复为3000ms,合理的锁等待时间
|
|
1526
|
+
const maxWait = 3000;
|
|
1524
1527
|
const startWait = Date.now();
|
|
1525
1528
|
while (node.modbusLock && (Date.now() - startWait) < maxWait) {
|
|
1526
|
-
if (node.modbusLockType === 'polling' && (Date.now() - startWait) > 100) {
|
|
1527
|
-
break;
|
|
1528
|
-
}
|
|
1529
1529
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
1530
1530
|
}
|
|
1531
1531
|
|
|
@@ -1632,29 +1632,45 @@ module.exports = function(RED) {
|
|
|
1632
1632
|
|
|
1633
1633
|
// 检查主站关联(如果指定了主站ID,则必须匹配)
|
|
1634
1634
|
if (data.masterId && data.masterId !== node.id) {
|
|
1635
|
-
//
|
|
1636
|
-
const hasSlave = node.config.slaves.find(s => s.address === slave);
|
|
1637
|
-
if (hasSlave) {
|
|
1638
|
-
node.log(`忽略写入事件:从站${slave}在配置中,但masterId不匹配 (收到:${data.masterId}, 本机:${node.id})`);
|
|
1639
|
-
}
|
|
1635
|
+
// 如果 masterId 不匹配,绝对不能处理,否则会导致多主站冲突
|
|
1640
1636
|
return;
|
|
1641
1637
|
}
|
|
1642
1638
|
|
|
1639
|
+
// 如果是广播事件(没有 masterId),但当前主站不是 relay-output 预期指向的主站,也应谨慎处理
|
|
1640
|
+
// 注意:内部事件通信强制要求 masterId,广播仅用于旧版兼容
|
|
1641
|
+
if (!data.masterId) {
|
|
1642
|
+
// 如果是广播,只有在配置中包含该从站时才尝试处理
|
|
1643
|
+
const hasSlave = node.config.slaves.find(s => parseInt(s.address) === slave);
|
|
1644
|
+
if (!hasSlave) return;
|
|
1645
|
+
|
|
1646
|
+
// 增加一条调试信息,提示广播可能带来的潜在冲突
|
|
1647
|
+
node.debug(`[免连线联动] 收到广播写入事件(从站${slave}),主站(${node.config.name || node.id})尝试执行。建议检查 relay-output 节点配置。`);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1643
1650
|
// 检查该主站是否配置了此从站
|
|
1644
|
-
const slaveConfig = node.config.slaves.find(s => s.address === slave);
|
|
1651
|
+
const slaveConfig = node.config.slaves.find(s => parseInt(s.address) === slave);
|
|
1645
1652
|
if (!slaveConfig) {
|
|
1646
|
-
//
|
|
1653
|
+
// 如果是针对本主站的明确写入(带 masterId),但未配置该从站,则给出警告
|
|
1654
|
+
if (data.masterId) {
|
|
1655
|
+
node.warn(`[免连线联动] 忽略明确写入:主站(${node.id})未配置从站${slave}。`);
|
|
1656
|
+
}
|
|
1647
1657
|
return;
|
|
1648
1658
|
}
|
|
1649
1659
|
|
|
1650
1660
|
// 输出日志确认收到事件
|
|
1651
|
-
|
|
1661
|
+
const masterName = node.config.name || "未命名主站";
|
|
1662
|
+
const targetMasterInfo = data.masterId === node.id ? `匹配成功 (${masterName})` : (data.masterId || '广播模式');
|
|
1663
|
+
|
|
1664
|
+
// 精简日志:明确主站写入时输出一条信息
|
|
1665
|
+
if (data.masterId) {
|
|
1666
|
+
node.log(`[免连线联动] 触发写入: 从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'} (触发源: ${triggerSource})`);
|
|
1667
|
+
} else {
|
|
1668
|
+
node.debug(`[免连线联动] 目标主站: ${targetMasterInfo} | 触发源: ${triggerSource}`);
|
|
1669
|
+
}
|
|
1652
1670
|
|
|
1653
1671
|
try {
|
|
1654
1672
|
// 执行写入操作(writeSingleCoil内部已经会广播状态变化事件)
|
|
1655
1673
|
await node.writeSingleCoil(slave, coil, value, triggerSource);
|
|
1656
|
-
|
|
1657
|
-
node.log(`内部事件写入成功:从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
|
|
1658
1674
|
} catch (err) {
|
|
1659
1675
|
node.log(`内部事件写入失败: 从站${slave} 线圈${coil} - ${err.message}`);
|
|
1660
1676
|
}
|
|
@@ -536,9 +536,16 @@ module.exports = function(RED) {
|
|
|
536
536
|
// 检查是否是当前面板触发的操作
|
|
537
537
|
const isTriggerSourcePanel = (node.serialPortConfig && node.serialPortConfig.triggerSource === panelKey);
|
|
538
538
|
|
|
539
|
-
//
|
|
539
|
+
// 检查主站关联
|
|
540
540
|
if (node.config.modbusMaster && masterId && node.config.modbusMaster !== masterId) {
|
|
541
|
-
|
|
541
|
+
// 如果配置了主站但 ID 不匹配,通常应该忽略。
|
|
542
|
+
// 但是,如果从站地址和线圈号完全匹配,说明这是我们正在监控的设备状态。
|
|
543
|
+
// 在多主站联动场景下,允许接收来自其他主站的状态同步。
|
|
544
|
+
if (slave === parseInt(node.config.targetSlaveAddress) && coil === parseInt(node.config.targetCoilNumber)) {
|
|
545
|
+
node.debug(`[LED反馈] 接收到来自跨主站(${masterId})的状态同步:从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
|
|
546
|
+
} else {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
542
549
|
}
|
|
543
550
|
|
|
544
551
|
// 检查是否是我们关注的从站和线圈
|
|
@@ -574,67 +581,62 @@ module.exports = function(RED) {
|
|
|
574
581
|
if (stateChanged) {
|
|
575
582
|
// 核心逻辑:状态发生变化时,启动 LED 同步防抖队列
|
|
576
583
|
// 防抖机制(200ms)确保在全开/全关等批量场景下,能够收集完整状态并平滑同步
|
|
577
|
-
|
|
578
|
-
|
|
584
|
+
const panelId = isMesh ? panelKey : node.config.switchId;
|
|
585
|
+
const debounceKey = getScopedKey(node, panelId);
|
|
586
|
+
let timerObj = meshLedDebounceTimers.get(debounceKey);
|
|
587
|
+
|
|
588
|
+
if (!timerObj) {
|
|
589
|
+
timerObj = {
|
|
590
|
+
timer: null,
|
|
591
|
+
nodeId: node.id,
|
|
592
|
+
serialPortConfig: node.serialPortConfig,
|
|
593
|
+
firstChangeTime: Date.now(),
|
|
594
|
+
changeCount: 0,
|
|
595
|
+
targetNodes: new Set([node])
|
|
596
|
+
};
|
|
597
|
+
meshLedDebounceTimers.set(debounceKey, timerObj);
|
|
579
598
|
} else {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
let timerObj = meshLedDebounceTimers.get(debounceKey);
|
|
583
|
-
|
|
584
|
-
if (!timerObj) {
|
|
585
|
-
timerObj = {
|
|
586
|
-
timer: null,
|
|
587
|
-
nodeId: node.id,
|
|
588
|
-
serialPortConfig: node.serialPortConfig,
|
|
589
|
-
firstChangeTime: Date.now(),
|
|
590
|
-
changeCount: 0,
|
|
591
|
-
targetNodes: new Set([node])
|
|
592
|
-
};
|
|
593
|
-
meshLedDebounceTimers.set(debounceKey, timerObj);
|
|
594
|
-
} else {
|
|
595
|
-
timerObj.targetNodes.add(node);
|
|
596
|
-
}
|
|
599
|
+
timerObj.targetNodes.add(node);
|
|
600
|
+
}
|
|
597
601
|
|
|
598
|
-
|
|
599
|
-
|
|
602
|
+
timerObj.changeCount++;
|
|
603
|
+
if (timerObj.timer) clearTimeout(timerObj.timer);
|
|
600
604
|
|
|
601
|
-
|
|
602
|
-
|
|
605
|
+
// 统一使用 200ms 防抖,确保在大批量操作时收集完整状态
|
|
606
|
+
const debounceTime = isInit ? 50 : 200;
|
|
603
607
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
});
|
|
621
|
-
// 强制 isTriggerSource=false,确保这里发出的都是同步指令
|
|
622
|
-
representativeNode.sendCommandToPanel(representativeNode.currentState, false, changedButtons);
|
|
623
|
-
}
|
|
624
|
-
} else {
|
|
625
|
-
currentTimerObj.targetNodes.forEach(targetNode => {
|
|
626
|
-
targetNode.sendCommandToPanel(targetNode.currentState, false);
|
|
608
|
+
timerObj.timer = setTimeout(() => {
|
|
609
|
+
const currentTimerObj = meshLedDebounceTimers.get(debounceKey);
|
|
610
|
+
if (currentTimerObj) {
|
|
611
|
+
meshLedDebounceTimers.delete(debounceKey);
|
|
612
|
+
|
|
613
|
+
if (currentTimerObj.serialPortConfig && currentTimerObj.serialPortConfig.setFeedbackLock) {
|
|
614
|
+
// 锁定反馈,防止回显
|
|
615
|
+
currentTimerObj.serialPortConfig.setFeedbackLock(100);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (isMesh) {
|
|
619
|
+
const representativeNode = currentTimerObj.targetNodes.values().next().value;
|
|
620
|
+
if (representativeNode) {
|
|
621
|
+
const changedButtons = new Set();
|
|
622
|
+
currentTimerObj.targetNodes.forEach(n => {
|
|
623
|
+
if (n.config.meshButtonNumber) changedButtons.add(parseInt(n.config.meshButtonNumber));
|
|
627
624
|
});
|
|
625
|
+
representativeNode.sendCommandToPanel(representativeNode.currentState, false, changedButtons);
|
|
628
626
|
}
|
|
627
|
+
} else {
|
|
628
|
+
currentTimerObj.targetNodes.forEach(targetNode => {
|
|
629
|
+
targetNode.sendCommandToPanel(targetNode.currentState, false);
|
|
630
|
+
});
|
|
629
631
|
}
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
+
}
|
|
633
|
+
}, debounceTime);
|
|
632
634
|
}
|
|
633
635
|
|
|
634
636
|
node.updateStatus();
|
|
635
637
|
|
|
636
638
|
if (isInit) {
|
|
637
|
-
node.debug(
|
|
639
|
+
node.debug(`初始化状态同步:从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
|
|
638
640
|
return;
|
|
639
641
|
}
|
|
640
642
|
|
|
@@ -1250,6 +1252,15 @@ module.exports = function(RED) {
|
|
|
1250
1252
|
// 模式2:内部事件模式(通过RED内部事件系统,免连线通信)
|
|
1251
1253
|
// 当MQTT未启用或未连接时,通过内部事件发送控制命令
|
|
1252
1254
|
// 主站节点会监听这些事件并执行写入操作
|
|
1255
|
+
|
|
1256
|
+
// 确定目标主站 ID
|
|
1257
|
+
// 如果配置了主站但节点不存在,则降级为广播模式(不带 masterId),让所有主站尝试处理
|
|
1258
|
+
let targetMasterId = node.config.modbusMaster || "";
|
|
1259
|
+
if (targetMasterId && !RED.nodes.getNode(targetMasterId)) {
|
|
1260
|
+
node.debug(`指定的配置主站 ${targetMasterId} 不存在,切换到广播模式`);
|
|
1261
|
+
targetMasterId = undefined;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1253
1264
|
RED.events.emit('modbus:writeCoil', {
|
|
1254
1265
|
slave: node.config.targetSlaveAddress,
|
|
1255
1266
|
coil: node.config.targetCoilNumber,
|
|
@@ -1257,7 +1268,7 @@ module.exports = function(RED) {
|
|
|
1257
1268
|
source: 'slave-switch',
|
|
1258
1269
|
triggerSource: isSceneMode ? 'scene-trigger' : 'button-press', // 场景模式使用scene-trigger,开关模式使用button-press
|
|
1259
1270
|
nodeId: node.id,
|
|
1260
|
-
masterId:
|
|
1271
|
+
masterId: targetMasterId, // 指定目标主站ID
|
|
1261
1272
|
serialPortConfigId: config.serialPortConfig // 增加串口配置ID,用于主站过滤,防止多主站冲突
|
|
1262
1273
|
});
|
|
1263
1274
|
|
package/nodes/relay-output.html
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
// 填充主站节点选择器
|
|
45
45
|
var masterNodeSelect = $("#node-input-masterNode");
|
|
46
46
|
masterNodeSelect.empty();
|
|
47
|
-
masterNodeSelect.append('<option value=""
|
|
47
|
+
masterNodeSelect.append('<option value="">自动查找 (跨主站联动)</option>');
|
|
48
48
|
|
|
49
49
|
// 查找所有modbus-master节点
|
|
50
50
|
var masters = [];
|
|
@@ -110,10 +110,10 @@
|
|
|
110
110
|
var buttonType = $("#node-input-buttonType").val();
|
|
111
111
|
var masterNodeId = $("#node-input-masterNode").val();
|
|
112
112
|
|
|
113
|
-
var masterLabel = "
|
|
113
|
+
var masterLabel = "自动查找";
|
|
114
114
|
if (masterNodeId) {
|
|
115
115
|
var masterNode = RED.nodes.node(masterNodeId);
|
|
116
|
-
masterLabel = masterNode ? (masterNode.name || `主站 ${masterNodeId.substring(0, 8)}`) : "未知主站";
|
|
116
|
+
masterLabel = masterNode ? (masterNode.name || `主站 ${masterNodeId.substring(0, 8)}`) : "未知主站 (将尝试自动查找)";
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
const actionMap = { 'on': '打开', 'off': '关闭', 'follow': '跟随', 'toggle': '翻转' };
|
package/nodes/relay-output.js
CHANGED
|
@@ -38,6 +38,8 @@ module.exports = function(RED) {
|
|
|
38
38
|
node.isClosing = false;
|
|
39
39
|
node.pendingTimer = null; // 延时定时器
|
|
40
40
|
node.stateChangeListener = null; // 状态变化监听器
|
|
41
|
+
node.coilStateListener = null; // 目标线圈状态监听器
|
|
42
|
+
node.currentCoilState = null; // 缓存当前线圈状态,用于实现真正的翻转(Toggle)逻辑
|
|
41
43
|
|
|
42
44
|
// 初始化后延迟启动(防止重启时自动动作)
|
|
43
45
|
node.initialized = false;
|
|
@@ -65,6 +67,7 @@ module.exports = function(RED) {
|
|
|
65
67
|
// 解决多网关环境下的冲突问题
|
|
66
68
|
if (!node.ignoreGatewayId && node.serialPortConfig && data.serialPortConfigId) {
|
|
67
69
|
if (node.serialPortConfig !== data.serialPortConfigId) {
|
|
70
|
+
node.debug(`[网关过滤] 忽略来自不同网关的事件: 节点=${node.serialPortConfig}, 收到=${data.serialPortConfigId}`);
|
|
68
71
|
return;
|
|
69
72
|
}
|
|
70
73
|
}
|
|
@@ -74,11 +77,8 @@ module.exports = function(RED) {
|
|
|
74
77
|
const msgButtonNumber = data.button || data.buttonNumber;
|
|
75
78
|
|
|
76
79
|
// 增加调试日志,帮助用户排查跨网关联动问题
|
|
77
|
-
// 仅在 ID 匹配时输出按键不匹配的情况,避免日志刷屏
|
|
78
80
|
if (parseInt(msgSwitchId) === node.switchId) {
|
|
79
|
-
|
|
80
|
-
node.debug(`[跨网关] 收到开关 ${msgSwitchId} 事件,但按键编号不匹配 (收到:${msgButtonNumber}, 监听:${node.buttonNumber})`);
|
|
81
|
-
}
|
|
81
|
+
node.debug(`[触发源检测] 开关ID匹配: ${msgSwitchId}, 收到按键: ${msgButtonNumber}, 监听按键: ${node.buttonNumber}`);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// 开关ID必须匹配
|
|
@@ -103,7 +103,7 @@ module.exports = function(RED) {
|
|
|
103
103
|
|
|
104
104
|
// 执行控制
|
|
105
105
|
const btnLabel = msgButtonNumber == 15 ? '背光灯' : `按键${msgButtonNumber}`;
|
|
106
|
-
const triggerSource = data.type === 'scene' ? 'scene-trigger' : 'button-press';
|
|
106
|
+
const triggerSource = (data.type === 'scene' || data.isSceneMode) ? 'scene-trigger' : 'button-press';
|
|
107
107
|
node.executeControl(triggerValue, `开关${msgSwitchId}-${btnLabel}`, triggerSource);
|
|
108
108
|
};
|
|
109
109
|
|
|
@@ -112,24 +112,58 @@ module.exports = function(RED) {
|
|
|
112
112
|
node.log(`绑定触发源: 开关${node.switchId} ${btnLabel}`);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// 监听目标线圈状态变化(用于状态同步和实现 Toggle 逻辑)
|
|
116
|
+
node.coilStateListener = function(data) {
|
|
117
|
+
// 过滤:必须是配置的主站、从站和线圈
|
|
118
|
+
if (data.masterId === node.masterNodeId &&
|
|
119
|
+
parseInt(data.slave) === node.slaveAddress &&
|
|
120
|
+
parseInt(data.coil) === (node.coilNumber - 1)) {
|
|
121
|
+
|
|
122
|
+
const oldState = node.currentCoilState;
|
|
123
|
+
node.currentCoilState = data.value;
|
|
124
|
+
|
|
125
|
+
// 如果状态发生变化,更新节点状态显示(非动作触发时的被动更新)
|
|
126
|
+
if (oldState !== node.currentCoilState) {
|
|
127
|
+
const actionText = node.currentCoilState ? 'ON' : 'OFF';
|
|
128
|
+
node.updateStatus(`同步状态: ${actionText} ← 从站${node.slaveAddress}`, node.currentCoilState ? 'green' : 'grey');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
RED.events.on('modbus:coilStateChanged', node.coilStateListener);
|
|
133
|
+
|
|
115
134
|
// 发送写入命令到主站(通过内部事件)
|
|
116
135
|
node.sendWriteCommand = function(value, source, triggerSource = 'relay-control') {
|
|
117
136
|
const coilIndex = node.coilNumber - 1; // 用户输入1-32,内部使用0-31
|
|
118
137
|
|
|
138
|
+
// 确定目标主站 ID
|
|
139
|
+
// 必须严格使用配置的主站节点,不允许自动切换到广播模式
|
|
140
|
+
// 否则在多主站环境下(多个主站都有相同从站地址时)会导致控制混乱
|
|
141
|
+
const targetMasterId = node.masterNodeId;
|
|
142
|
+
|
|
143
|
+
if (!targetMasterId) {
|
|
144
|
+
node.warn(`[继电器输出] 节点未配置目标主站,指令将无法发送。请在节点配置中选择正确的主站。`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!RED.nodes.getNode(targetMasterId)) {
|
|
149
|
+
node.error(`[继电器输出] 配置的目标主站 ${targetMasterId} 不存在或未部署。`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
119
153
|
RED.events.emit('modbus:writeCoil', {
|
|
120
154
|
slave: node.slaveAddress,
|
|
121
155
|
coil: coilIndex,
|
|
122
156
|
value: value,
|
|
123
157
|
source: 'relay-output',
|
|
124
158
|
triggerSource: triggerSource,
|
|
125
|
-
masterId:
|
|
159
|
+
masterId: targetMasterId,
|
|
126
160
|
nodeId: node.id
|
|
127
161
|
});
|
|
128
162
|
|
|
129
163
|
const actionText = value ? '打开' : '关闭';
|
|
130
164
|
const sourceText = source ? ` (${source})` : '';
|
|
131
165
|
node.updateStatus(`${actionText} → 从站${node.slaveAddress}线圈${node.coilNumber}${sourceText}`, value ? 'green' : 'grey');
|
|
132
|
-
node.
|
|
166
|
+
node.debug(`[联动执行] 触发写入: 从站=${node.slaveAddress}, 线圈=${node.coilNumber}, 值=${value}, 目标主站=${targetMasterId || '广播'}${sourceText}`);
|
|
133
167
|
};
|
|
134
168
|
|
|
135
169
|
// 执行控制逻辑
|
|
@@ -147,8 +181,16 @@ module.exports = function(RED) {
|
|
|
147
181
|
targetValue = triggerValue;
|
|
148
182
|
break;
|
|
149
183
|
case 'toggle':
|
|
184
|
+
// 翻转逻辑:基于当前已知的继电器状态进行翻转
|
|
185
|
+
// 如果当前状态未知(null),则默认发送反向指令
|
|
186
|
+
if (node.currentCoilState === null) {
|
|
187
|
+
node.warn(`[继电器输出] 翻转动作触发,但从站${node.slaveAddress}状态未知,发送反转指令`);
|
|
150
188
|
targetValue = !triggerValue;
|
|
151
|
-
|
|
189
|
+
} else {
|
|
190
|
+
targetValue = !node.currentCoilState;
|
|
191
|
+
node.debug(`[继电器输出] 翻转动作: 当前状态=${node.currentCoilState} -> 目标状态=${targetValue}`);
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
152
194
|
default:
|
|
153
195
|
targetValue = true;
|
|
154
196
|
}
|
|
@@ -225,7 +267,7 @@ module.exports = function(RED) {
|
|
|
225
267
|
});
|
|
226
268
|
|
|
227
269
|
// 节点关闭时清理
|
|
228
|
-
node.on('close', function(done) {
|
|
270
|
+
node.on('close', function(removed, done) {
|
|
229
271
|
node.isClosing = true;
|
|
230
272
|
|
|
231
273
|
// 清除初始化定时器
|
|
@@ -246,6 +288,15 @@ module.exports = function(RED) {
|
|
|
246
288
|
node.stateChangeListener = null;
|
|
247
289
|
}
|
|
248
290
|
|
|
291
|
+
if (node.coilStateListener) {
|
|
292
|
+
RED.events.removeListener('modbus:coilStateChanged', node.coilStateListener);
|
|
293
|
+
node.coilStateListener = null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (removed) {
|
|
297
|
+
// 节点被删除时的额外清理(如果有)
|
|
298
|
+
}
|
|
299
|
+
|
|
249
300
|
done();
|
|
250
301
|
});
|
|
251
302
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-symi-modbus",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.5",
|
|
4
4
|
"description": "Node-RED Modbus节点,支持TCP/串口通信、多主站独立运行、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步(支持Symi/Clowire品牌),工控机长期稳定运行",
|
|
5
5
|
"main": "nodes/modbus-master.js",
|
|
6
6
|
"scripts": {
|