node-red-contrib-symi-modbus 2.10.3 → 2.10.6

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 CHANGED
@@ -32,6 +32,9 @@ Node-RED的Modbus继电器控制节点,支持TCP/串口通信和MQTT集成,
32
32
  - **门禁联动过滤**:继电器节点支持门禁ID过滤,实现精确的门禁联动控制
33
33
  - **HomeKit网桥**:一键接入Apple HomeKit,支持Siri语音控制,状态实时同步
34
34
  - **智能写入队列**:所有写入操作串行执行,支持优先级管理,确保指示灯反馈在群控场景下秒级响应
35
+ - **多主站精准隔离**:支持多主站并行运行,通过 `masterId` 实现指令精准定向,严格过滤跨主站事件,彻底解决多主站环境下的控制冲突与误触发
36
+ - **智能反馈路由**:从站开关节点自动识别所属主站,仅响应目标主站的状态变化,防止多系统并存时的信号串扰
37
+ - **真·状态翻转逻辑**:继电器输出节点内置状态感知能力,基于物理设备真实反馈执行翻转操作,解决传统盲发指令导致的开关失灵
35
38
  - **可视化运维**:
36
39
  - **控制看板**:实时显示所有继电器状态,支持手动控制
37
40
  - **调试模式**:详细的通信日志,支持十六进制报文监控
@@ -280,6 +283,7 @@ node-red-restart
280
283
  - 轮询恢复时间:50ms(写入完成后快速恢复轮询)
281
284
  - 锁等待超时:500ms(适配高延迟网络,确保指令不丢失)
282
285
  - 智能优先队列:触发源面板优先处理(500ms优先窗口)
286
+ - 触发源追踪:全链路追踪控制指令来源(triggerSource),日志清晰记录是物理按键、HomeKit还是自动化触发
283
287
  - 队列自动处理,无需手动干预
284
288
 
285
289
  ## 核心特性说明
@@ -957,6 +961,27 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
957
961
 
958
962
  ## 版本更新说明
959
963
 
964
+ ### v2.10.6 (2026-01-17)
965
+ - **多节点LED状态聚合**: 支持多个 `从站开关` 节点配置为同一物理按键(相同 Switch ID 和 Button ID)但控制不同继电器。系统会自动聚合所有关联继电器的状态(OR逻辑),实现“任意开则灯亮,全关则灯灭”的智能反馈,完美支持“一键多控”场景。
966
+ - **Clowire协议深度优化**: 新增支持 Clowire 协议的“全关”指令(0x1020=0x0002)。当检测到某面板下的所有按键状态均为 OFF 时,系统会自动合并发送一条全关指令,替代逐个发送的关闭指令,大幅降低 RS-485 总线通信压力,提升批量关灯时的响应速度。
967
+ - **Symi协议深度优化**: 新增支持 Symi 协议的“全关”指令(0x05 MULTI)。逻辑同上,当检测到 Symi 面板所有按键全关时,自动发送一条多灯控制指令(状态位全0),实现高效的“一键全关”反馈。
968
+ - **网络错误日志优化**: 对 Modbus 主站的 TCP/串口底层连接错误进行严格限流(每分钟仅记录一次),避免在网络不稳定或断网情况下产生大量垃圾日志,保护磁盘空间。
969
+ - **稳定性增强**: 优化了全局状态管理的内存回收机制,确保节点删除或重部署时无内存残留。
970
+
971
+ ### v2.10.5 (2026-01-15)
972
+ - **多主站精准隔离架构**:引入 `masterId` 指令定向机制。`relay-output` 节点现在可以精准锁定目标主站,彻底解决了多主站环境下(如多台 Modbus 网关)指令错发或“一控多”的冲突问题。
973
+ - **真·状态翻转 (Toggle) 逻辑**:继电器输出节点新增状态感知能力。通过实时监听总线状态回传(Coil State Sync),翻转动作不再是盲目的逻辑取反,而是基于物理设备真实状态的精准操作,解决了高频连点或多端控制下的状态不同步顽疾。
974
+ - **指示灯反馈机制重构**:优化了 `modbus-slave-switch` 的反馈锁与初始化逻辑。移除了过度的初始化过滤,确保面板指示灯在系统重启或状态突变时能秒级同步(包括 OFF 状态),解决了指示灯反馈“只开不关”的问题。
975
+ - **生产级日志分级**:对全局日志进行了分级处理。将底层的 Modbus 通信报文转入 Debug 级别,控制台仅保留关键的业务联动日志(如 `[免连线联动]`),极大提升了高并发场景下的运行效率并降低了 IO 损耗。
976
+ - **资源管理与稳定性**:规范化了所有节点的 `close` 生命周期管理。完善了事件监听器的移除机制,确保在频繁部署或节点增删时内存无泄露,系统长期运行更加稳健。
977
+
978
+ ### v2.10.4 (2026-01-14)
979
+ - **Modbus超时优化**:将默认Modbus通信超时时间(Timeout)统一调整为 **3000ms**(原TCP 1000ms/串口 2000ms),彻底解决网络波动或从站响应慢导致的“轮询超时”和“写入失败”问题,这是生产环境稳定的关键参数。
980
+ - **锁等待恢复合理值**:将写入锁等待超时恢复为 **3000ms**,配合更长的底层通信超时,确保在总线繁忙时依然能有序排队,不再出现死锁或虚假超时。
981
+ - **断连彻底静默**:移除写入队列在断连时的错误日志提醒,断线期间不报错、不刷屏,确保生产环境日志整洁。
982
+ - **缓存策略优化**:连接断开时立即清空所有待处理的写入任务(`writeQueue = []`),重连后不处理断连期间的旧指令,只响应重连后的新操作。
983
+ - **连接恢复优化**:移除重连后的自动任务恢复逻辑,实现真正的“断连不处理缓存,重连只理新数”。
984
+
960
985
  ### v2.10.3 (2026-01-14)
961
986
  - **协议兼容性**: 完善 Symi 协议解析逻辑,全面支持 `0x03 SET`、`0x04 REPORT` 和 `0x05 STATUS` 帧类型,解决某些面板状态错位导致的按键不响应问题。
962
987
  - **稳定性修复**: 紧急修复物理回显导致的“继电器无限跳动”死循环问题,通过严格过滤 `0x04` 反馈帧并延长反馈锁至 **100ms**,确保系统在复杂干扰环境下依然稳定。
@@ -978,17 +1003,6 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
978
1003
  - **防抖优化**: 引入全局按键事件去重防抖,解决信号抖动问题。
979
1004
  - **组网支持**: 改进 LED 反馈机制,支持 Symi/Clowire/Mesh 多品牌混合组网下的状态同步。
980
1005
 
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
1006
  **核心特性**:
993
1007
  - **指示灯同步逻辑优化**:
994
1008
  - **485面板**:使用 `REPORT (0x04)` 协议进行反馈,并将主机地址固定为 `0x00`,确保同步状态时不触发继电器动作。
@@ -1013,6 +1027,13 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
1013
1027
  - **多连接支持**:支持 Mac/Linux 多串口与 TCP 同时运行,各主站实例完全隔离,互不干扰。
1014
1028
  - **断电恢复**:所有状态和配置自动持久化,设备上电后 1 秒内自动同步最新状态。
1015
1029
  - **日志防护**:错误日志限流与磁盘溢出保护,确保工控机环境下的系统安全。
1030
+ - **日志分级**:生产环境下仅输出关键业务日志,详细通信日志转入 Debug 级别
1031
+ - **智能优先队列**:触发源面板优先处理(500ms优先窗口)
1032
+ - **队列自动处理**:指令自动串行化,避免 RS485 总线冲突
1033
+ - **断连自动恢复**:主站/从站连接断开后支持毫秒级自动重连
1034
+ - **多实例运行**:支持同时运行多个 `Modbus主站` 节点,配置完全隔离
1035
+ - **状态持久化**:Mesh 设备列表和继电器状态在重启后自动恢复
1036
+ - **日志分级**:生产环境下仅输出关键业务日志,详细通信日志转入 Debug 级别
1016
1037
 
1017
1038
  **实际使用场景**:
1018
1039
  - 主站轮询:200ms/台,支持5-10台从站设备
@@ -193,6 +193,30 @@ module.exports = {
193
193
  return buffer;
194
194
  },
195
195
 
196
+ /**
197
+ * 构建关闭所有LED指示灯指令(写入0x1020值为0x0002)
198
+ * @param {number} panelAddr - 面板地址(1-127)
199
+ * @returns {Buffer} 完整的指令帧
200
+ */
201
+ buildTurnOffAllLedsCommand: function(panelAddr) {
202
+ const buffer = Buffer.alloc(9);
203
+ let idx = 0;
204
+
205
+ buffer[idx++] = panelAddr;
206
+ buffer[idx++] = this.CMD_WRITE;
207
+ buffer[idx++] = 0x10; // 寄存器地址高字节
208
+ buffer[idx++] = 0x20; // 寄存器地址低字节(0x1020)
209
+ buffer[idx++] = 0x00;
210
+ buffer[idx++] = 0x02; // 值 0x0002 表示关闭所有LED
211
+
212
+ const crc = this.calculateCRC16(buffer, 6);
213
+ buffer[idx++] = crc & 0xFF;
214
+ buffer[idx++] = (crc >> 8) & 0xFF;
215
+ buffer[idx++] = this.FRAME_TAIL;
216
+
217
+ return buffer;
218
+ },
219
+
196
220
  /**
197
221
  * 判断是否是有效的 Clowire 协议帧
198
222
  * @param {Buffer} buffer - 接收到的数据
@@ -165,6 +165,41 @@ module.exports = {
165
165
 
166
166
  return buffer;
167
167
  },
168
+
169
+ /**
170
+ * 构建多灯状态反馈指令(REPORT类型,用于状态同步到面板指示灯)
171
+ * 用于“全关”等批量操作优化
172
+ * @param {number} localAddr - 本机地址
173
+ * @param {number} deviceAddr - 设备地址
174
+ * @param {number} states - 灯状态位图(8bit,每bit对应1个灯)
175
+ * @returns {Buffer} 完整的指令帧
176
+ */
177
+ buildMultiLightReport: function(localAddr, deviceAddr, states) {
178
+ const buffer = Buffer.alloc(16);
179
+ let idx = 0;
180
+
181
+ buffer[idx++] = this.FRAME_HEADER; // 帧头
182
+ buffer[idx++] = localAddr; // 本机地址
183
+ buffer[idx++] = this.DATA_TYPE_REPORT; // 数据类型:上报(用于反馈)
184
+ buffer[idx++] = 0x10; // 数据长度
185
+ buffer[idx++] = this.DEVICE_TYPE_LIGHT; // 设备类型:灯光
186
+ buffer[idx++] = 0x00; // 品牌ID
187
+ buffer[idx++] = deviceAddr; // 设备地址
188
+ buffer[idx++] = 0x00; // 设备通道(多灯控制通常为0)
189
+ buffer[idx++] = 0x00; // 房间号
190
+ buffer[idx++] = 0x00; // 房间类型
191
+ buffer[idx++] = 0x00; // 房间ID
192
+ buffer[idx++] = this.LIGHT_OP_MULTI; // 操作码:多灯控制
193
+ buffer[idx++] = 0x00; // 延时(反馈不需要延时)
194
+ buffer[idx++] = states; // 灯状态位图
195
+
196
+ // 计算CRC8校验
197
+ const crc = this.calculateCRC8(buffer, 16);
198
+ buffer[idx++] = crc; // CRC8校验
199
+ buffer[idx++] = this.FRAME_TAIL; // 报尾
200
+
201
+ return buffer;
202
+ },
168
203
 
169
204
  /**
170
205
  * 构建查询指令
@@ -161,11 +161,16 @@ module.exports = function(RED) {
161
161
  node.pausePolling = false; // 暂停轮询标志(从站上报时暂停)
162
162
  node.pollingPausedCount = 0; // 暂停轮询计数器
163
163
  node._discoveryPublished = false; // Discovery发布标志(避免重复)
164
+ node.lastSocketErrorTime = 0; // socket错误日志时间
164
165
 
165
166
  // 写入队列机制(确保所有写入操作串行执行,避免锁竞争)
166
167
  node.writeQueue = []; // 写入队列
167
168
  node.isProcessingWrite = false; // 是否正在处理写入队列
168
- node.writeQueueInterval = 50; // 写入队列处理间隔(50ms,厂家推荐间隔,确保总线稳定)
169
+ node.writeQueueInterval = 50; // 写入队列处理间隔(恢复为50ms,确保多设备控制响应速度)
170
+
171
+ // 新增:命令去重缓存(防止短时间内重复发送相同指令)
172
+ node.commandCache = new Map();
173
+ node.commandCacheTimeout = 500; // 500ms内相同的指令视为重复
169
174
 
170
175
  // 注册到全局主站实例表(用于调试和监控)
171
176
  masterInstances.set(node.masterId, {
@@ -329,23 +334,35 @@ module.exports = function(RED) {
329
334
  }
330
335
 
331
336
  // 设置Modbus超时时间
332
- // TCP模式:降低到1000ms,避免一个主站超时阻塞其他主站太久
333
- // 串口模式:保持2000ms,串口通信需要更长时间
334
- const timeout = node.config.connectionType === "serial" ? 2000 : 1000;
337
+ // TCP模式:提高到3000ms,适配网络波动较大的环境
338
+ // 串口模式:提高到3000ms,串口通信在复杂环境下需要更长时间
339
+ const timeout = 3000;
335
340
  node.client.setTimeout(timeout);
336
341
  node.log(`Modbus超时设置: ${timeout}ms`);
337
342
 
338
343
  // 设置错误处理器,防止未捕获的错误导致进程崩溃
339
344
  if (node.client._port) {
340
345
  node.client._port.on('error', (err) => {
341
- node.log(`串口错误(已忽略): ${err.message}`);
346
+ const now = Date.now();
347
+ if (now - (node.lastSocketErrorTime || 0) > 60000) { // 1分钟限流
348
+ node.log(`串口错误(已忽略): ${err.message}`);
349
+ node.lastSocketErrorTime = now;
350
+ } else {
351
+ node.debug(`串口错误(已忽略): ${err.message}`);
352
+ }
342
353
  });
343
354
  }
344
355
 
345
356
  // TCP模式:设置socket错误处理器
346
357
  if (node.client._client) {
347
358
  node.client._client.on('error', (err) => {
348
- node.log(`TCP socket错误(已忽略): ${err.message}`);
359
+ const now = Date.now();
360
+ if (now - (node.lastSocketErrorTime || 0) > 60000) { // 1分钟限流
361
+ node.log(`TCP socket错误(已忽略): ${err.message}`);
362
+ node.lastSocketErrorTime = now;
363
+ } else {
364
+ node.debug(`TCP socket错误(已忽略): ${err.message}`);
365
+ }
349
366
  });
350
367
  }
351
368
 
@@ -382,8 +399,15 @@ module.exports = function(RED) {
382
399
  node.log('轮询已在运行中');
383
400
  }
384
401
 
402
+ // 恢复处理写入队列(如果有积压的任务)
403
+ if (node.writeQueue.length > 0) {
404
+ node.log(`连接恢复,继续处理 ${node.writeQueue.length} 个积压的写入任务`);
405
+ node.processWriteQueue();
406
+ }
407
+
385
408
  } catch (err) {
386
409
  node.isConnected = false;
410
+ // 注意:不要清空 writeQueue,让任务在重连后继续尝试
387
411
 
388
412
  // 优化:如果错误消息没有变化,且不是第一次重试,则不输出 node.error 到全局日志
389
413
  // 这样可以避免 ECONNREFUSED 等持续性错误刷屏
@@ -901,17 +925,12 @@ module.exports = function(RED) {
901
925
 
902
926
  const coilCount = slave.coilEnd - slave.coilStart + 1;
903
927
 
904
- // 检查是否刚写入过(写入后50ms内不轮询该从站,避免读到旧值)
905
- const lastWrite = node.lastWriteTime[slaveId] || 0;
906
- const timeSinceWrite = Date.now() - lastWrite;
907
- if (timeSinceWrite < 50) {
908
- // 跳过该从站(不移动索引,由pollLoop统一管理)
909
- return;
910
- }
911
-
912
- // 检查锁状态(如果有写操作正在进行,或者写入队列不为空,跳过本次轮询,优先让出总线)
913
- if (node.modbusLock || node.writeQueue.length > 0 || node.isProcessingWrite) {
914
- // 跳过该从站(不移动索引,由pollLoop统一管理)
928
+ // 检查是否刚写入过(写入后50ms内不轮询该从站,避免读到旧值)
929
+ const lastWrite = node.lastWriteTime[slaveId] || 0;
930
+ const timeSinceWrite = Date.now() - lastWrite;
931
+ if (timeSinceWrite < 50) {
932
+ // 跳过该从站,但必须移动索引,否则会死锁在当前从站
933
+ node.currentSlaveIndex = (node.currentSlaveIndex + 1) % node.config.slaves.length;
915
934
  return;
916
935
  }
917
936
 
@@ -1069,6 +1088,7 @@ module.exports = function(RED) {
1069
1088
  }
1070
1089
 
1071
1090
  node.isConnected = false;
1091
+ node.writeQueue = []; // 断开连接时清空写入队列,不处理缓存数据
1072
1092
  // 原则:轮询永不停止,即使连接断开也由 startPolling 的 pollLoop 自动重试
1073
1093
  // 不再调用 node.stopPolling(),避免轮询线程彻底终止
1074
1094
  // node.stopPolling();
@@ -1209,14 +1229,16 @@ module.exports = function(RED) {
1209
1229
  // 统一使用 REPORT (0x04) 作为应答,避免 SET (0x03) 触发面板继电器动作
1210
1230
  // 仅在 buttonEvent.needFeedback 为 true 时发送,开关模式通常不需要反馈
1211
1231
  if (buttonEvent.needFeedback) {
1212
- const responseFrame = protocol.buildSingleLightReport(0x01, deviceAddr, channel, state);
1232
+ // 禁用 Master 的自动反馈,避免与 modbus-slave-switch 冲突
1233
+ // const responseFrame = protocol.buildSingleLightReport(0x01, deviceAddr, channel, state);
1213
1234
 
1214
1235
  // 通过 writeRaw 进入写入队列,实现非阻塞和串行化
1215
- node.writeRaw(responseFrame).then(() => {
1216
- node.debug(`Symi应答已发送(REPORT): 设备${deviceAddr} 通道${channel} 状态=${state ? 'ON' : 'OFF'}`);
1217
- }).catch(err => {
1218
- node.log(`Symi应答发送失败: ${err.message}`);
1219
- });
1236
+ // node.writeRaw(responseFrame).then(() => {
1237
+ // node.debug(`Symi应答已发送(REPORT): 设备${deviceAddr} 通道${channel} 状态=${state ? 'ON' : 'OFF'}`);
1238
+ // }).catch(err => {
1239
+ // node.log(`Symi应答发送失败: ${err.message}`);
1240
+ // });
1241
+ node.debug(`Symi按键事件(Master忽略反馈): 设备${deviceAddr} 通道${channel},交由从站开关节点处理`);
1220
1242
  } else {
1221
1243
  node.debug(`Symi按键事件无需应答: 设备${deviceAddr} 通道${channel} 模式=${isSceneMode ? '场景' : '开关'}`);
1222
1244
  }
@@ -1241,6 +1263,11 @@ module.exports = function(RED) {
1241
1263
  return;
1242
1264
  }
1243
1265
 
1266
+ // 如果未连接,静默退出,等待重连后自动触发,避免断连期间日志刷屏
1267
+ if (!node.isConnected || !node.client || node.isClosing) {
1268
+ return;
1269
+ }
1270
+
1244
1271
  node.isProcessingWrite = true;
1245
1272
 
1246
1273
  try {
@@ -1248,20 +1275,27 @@ module.exports = function(RED) {
1248
1275
  const queueStartTime = Date.now();
1249
1276
  const queueLength = node.writeQueue.length;
1250
1277
 
1278
+ // 只有在队列较长时才输出调试日志
1279
+ if (queueLength > 2) {
1280
+ node.log(`开始处理写入队列 (积压: ${queueLength}个任务)`);
1281
+ }
1282
+
1251
1283
  while (node.writeQueue.length > 0) {
1252
1284
  // 再次检查连接和客户端,防止处理中途断开
1253
1285
  if (node.isClosing || !node.isConnected || !node.client) {
1254
- node.log('处理写入队列时Modbus断开,等待重连...');
1286
+ node.log(`处理队列时连接断开,暂停处理 (剩余 ${node.writeQueue.length} 个任务)`);
1255
1287
  break;
1256
1288
  }
1257
1289
 
1258
1290
  const task = node.writeQueue.shift();
1259
1291
 
1260
1292
  try {
1293
+ node.log(`[Queue] 执行写入: 从站${task.slaveId} ${task.type === 'single' ? `线圈${task.coil}` : `批量${task.values ? task.values.length : ''}`} = ${task.value} (剩余${node.writeQueue.length})`);
1294
+
1261
1295
  if (task.type === 'single') {
1262
1296
  await node._writeSingleCoilInternal(task.slaveId, task.coil, task.value, task.triggerSource);
1263
1297
  } else if (task.type === 'multiple') {
1264
- await node._writeMultipleCoilsInternal(task.slaveId, task.startCoil, task.values);
1298
+ await node._writeMultipleCoilsInternal(task.slaveId, task.startCoil, task.values, task.triggerSource);
1265
1299
  } else if (task.type === 'raw') {
1266
1300
  await node._writeRawInternal(task.data);
1267
1301
  }
@@ -1293,8 +1327,8 @@ module.exports = function(RED) {
1293
1327
  } finally {
1294
1328
  node.isProcessingWrite = false;
1295
1329
 
1296
- // 检查是否在处理期间又有新任务加入
1297
- if (node.writeQueue.length > 0) {
1330
+ // 检查是否在处理期间又有新任务加入,且连接正常
1331
+ if (node.writeQueue.length > 0 && node.isConnected && !node.isClosing) {
1298
1332
  setImmediate(() => node.processWriteQueue());
1299
1333
  }
1300
1334
  }
@@ -1312,13 +1346,10 @@ module.exports = function(RED) {
1312
1346
 
1313
1347
  try {
1314
1348
  // 等待锁释放
1315
- // 等待锁释放(写入锁优先级更高,如果是轮询锁,缩短等待时间并尝试快速夺取)
1316
- const maxWait = 500;
1349
+ // 恢复为3000ms,合理的锁等待时间
1350
+ const maxWait = 3000;
1317
1351
  const startWait = Date.now();
1318
1352
  while (node.modbusLock && (Date.now() - startWait) < maxWait) {
1319
- if (node.modbusLockType === 'polling' && (Date.now() - startWait) > 100) {
1320
- break;
1321
- }
1322
1353
  await new Promise(resolve => setTimeout(resolve, 20));
1323
1354
  }
1324
1355
 
@@ -1400,18 +1431,11 @@ module.exports = function(RED) {
1400
1431
  const pauseStartTime = Date.now();
1401
1432
 
1402
1433
  try {
1403
- // 等待锁释放(写入锁优先级更高,如果是轮询锁,缩短等待时间并尝试快速夺取)
1404
- const maxWait = 500;
1434
+ // 等待锁释放(写入锁优先级更高)
1435
+ // 恢复为3000ms,合理的锁等待时间
1436
+ const maxWait = 3000;
1405
1437
  const startWait = Date.now();
1406
1438
  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
1439
  await new Promise(resolve => setTimeout(resolve, 20));
1416
1440
  }
1417
1441
 
@@ -1434,9 +1458,12 @@ module.exports = function(RED) {
1434
1458
  }
1435
1459
 
1436
1460
  node.client.setID(slaveId);
1461
+
1462
+ // 增加微小延迟,确保ID切换生效(某些网关/设备需要缓冲时间)
1463
+ await new Promise(resolve => setTimeout(resolve, 5));
1437
1464
 
1438
1465
  // 记录写入操作(帮助追踪总线数据来源)
1439
- node.log(`写入线圈: 从站${slaveId} 线圈${coil} = ${value ? 'ON' : 'OFF'} (触发源: ${triggerSource})`);
1466
+ node.log(`[Modbus写入] 从站${slaveId} 线圈${coil} = ${value ? 'ON' : 'OFF'} (触发源: ${triggerSource})`);
1440
1467
 
1441
1468
  await node.client.writeCoil(coil, value);
1442
1469
 
@@ -1447,7 +1474,7 @@ module.exports = function(RED) {
1447
1474
  const oldValue = node.deviceStates[slaveId].coils[coil];
1448
1475
  node.deviceStates[slaveId].coils[coil] = value;
1449
1476
 
1450
- node.log(`写入成功: 从站${slaveId} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
1477
+ node.log(`[Modbus成功] 从站${slaveId} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
1451
1478
 
1452
1479
  // 发布到MQTT和触发事件
1453
1480
  node.publishMqttState(slaveId, coil, value);
@@ -1492,6 +1519,34 @@ module.exports = function(RED) {
1492
1519
  // 写单个线圈(公共接口,通过队列执行)
1493
1520
  node.writeSingleCoil = function(slaveId, coil, value, triggerSource = 'unknown') {
1494
1521
  return new Promise((resolve, reject) => {
1522
+ // 1. 全局命令去重(防止上游节点重复触发)
1523
+ // 仅对非未知来源的触发进行去重,且仅针对同一指令(从站+线圈+值)
1524
+ const cmdKey = `${slaveId}-${coil}-${value}`;
1525
+ const now = Date.now();
1526
+ const lastTime = node.commandCache.get(cmdKey) || 0;
1527
+
1528
+ // 500ms 内完全相同的指令视为重复(连点保护)
1529
+ // 注意:如果 value 不同(例如快速 ON-OFF-ON),则不会被去重,这是正确的
1530
+ if (now - lastTime < node.commandCacheTimeout) {
1531
+ node.debug(`[Queue] 忽略重复写入: 从站${slaveId} 线圈${coil}=${value} (间隔${now - lastTime}ms)`);
1532
+ resolve();
1533
+ return;
1534
+ }
1535
+ node.commandCache.set(cmdKey, now);
1536
+
1537
+ // 2. 队列去重:移除旧任务
1538
+ // 如果队列中已经有针对同一从站同一线圈的写入任务,则移除旧任务,只保留最新的
1539
+ // 这能有效防止“连点”导致的队列积压和状态反复跳变
1540
+ node.writeQueue = node.writeQueue.filter(task => {
1541
+ if (task.type === 'single' && task.slaveId === slaveId && task.coil === coil) {
1542
+ // 如果任务还未开始执行(在队列中),直接移除,并调用其resolve防止Promise挂起
1543
+ if (task.resolve) task.resolve();
1544
+ node.log(`[Queue] 合并旧任务: 从站${task.slaveId} 线圈${task.coil}`);
1545
+ return false;
1546
+ }
1547
+ return true;
1548
+ });
1549
+
1495
1550
  // 添加到队列
1496
1551
  node.writeQueue.push({
1497
1552
  type: 'single',
@@ -1502,6 +1557,8 @@ module.exports = function(RED) {
1502
1557
  resolve: resolve,
1503
1558
  reject: reject
1504
1559
  });
1560
+
1561
+ node.debug(`[Queue] 任务入队: 从站${slaveId} 线圈${coil}=${value} (当前队列: ${node.writeQueue.length})`);
1505
1562
 
1506
1563
  // 触发队列处理
1507
1564
  node.processWriteQueue();
@@ -1509,7 +1566,7 @@ module.exports = function(RED) {
1509
1566
  };
1510
1567
 
1511
1568
  // 批量写入多个线圈(内部实现,不经过队列)
1512
- node._writeMultipleCoilsInternal = async function(slaveId, startCoil, values) {
1569
+ node._writeMultipleCoilsInternal = async function(slaveId, startCoil, values, triggerSource = 'unknown') {
1513
1570
  if (node.isClosing || !node.isConnected || !node.client) {
1514
1571
  throw new Error('Modbus未连接或节点正在关闭');
1515
1572
  }
@@ -1519,13 +1576,11 @@ module.exports = function(RED) {
1519
1576
  const pauseStartTime = Date.now();
1520
1577
 
1521
1578
  try {
1522
- // 等待锁释放(写入锁优先级更高,如果是轮询锁,缩短等待时间并尝试快速夺取)
1523
- const maxWait = 500;
1579
+ // 等待锁释放
1580
+ // 恢复为3000ms,合理的锁等待时间
1581
+ const maxWait = 3000;
1524
1582
  const startWait = Date.now();
1525
1583
  while (node.modbusLock && (Date.now() - startWait) < maxWait) {
1526
- if (node.modbusLockType === 'polling' && (Date.now() - startWait) > 100) {
1527
- break;
1528
- }
1529
1584
  await new Promise(resolve => setTimeout(resolve, 20));
1530
1585
  }
1531
1586
 
@@ -1545,7 +1600,15 @@ module.exports = function(RED) {
1545
1600
  if (!node.client) {
1546
1601
  throw new Error('Modbus客户端实例丢失');
1547
1602
  }
1603
+
1548
1604
  node.client.setID(slaveId);
1605
+
1606
+ // 增加微小延迟,确保ID切换生效(某些网关/设备需要缓冲时间)
1607
+ await new Promise(resolve => setTimeout(resolve, 5));
1608
+
1609
+ // 记录写入操作
1610
+ node.log(`[Modbus批量写入] 从站${slaveId} 起始线圈${startCoil} 共${values.length}个 (触发源: ${triggerSource})`);
1611
+
1549
1612
  await node.client.writeCoils(startCoil, values);
1550
1613
 
1551
1614
  // 记录写入时间(用于暂停轮询)
@@ -1574,7 +1637,8 @@ module.exports = function(RED) {
1574
1637
  slave: slaveId,
1575
1638
  coil: coilIndex,
1576
1639
  value: newValue,
1577
- source: 'write'
1640
+ source: 'write',
1641
+ triggerSource: triggerSource // 传递触发源标识
1578
1642
  });
1579
1643
  }
1580
1644
  }
@@ -1601,7 +1665,7 @@ module.exports = function(RED) {
1601
1665
  };
1602
1666
 
1603
1667
  // 批量写入多个线圈(公共接口,通过队列执行)
1604
- node.writeMultipleCoils = function(slaveId, startCoil, values) {
1668
+ node.writeMultipleCoils = function(slaveId, startCoil, values, triggerSource = 'unknown') {
1605
1669
  return new Promise((resolve, reject) => {
1606
1670
  // 添加到队列
1607
1671
  node.writeQueue.push({
@@ -1609,6 +1673,7 @@ module.exports = function(RED) {
1609
1673
  slaveId: slaveId,
1610
1674
  startCoil: startCoil,
1611
1675
  values: values,
1676
+ triggerSource: triggerSource,
1612
1677
  resolve: resolve,
1613
1678
  reject: reject
1614
1679
  });
@@ -1632,29 +1697,56 @@ module.exports = function(RED) {
1632
1697
 
1633
1698
  // 检查主站关联(如果指定了主站ID,则必须匹配)
1634
1699
  if (data.masterId && data.masterId !== node.id) {
1635
- // 如果是针对本主站的从站,但ID不匹配,记录日志辅助排查
1636
- const hasSlave = node.config.slaves.find(s => s.address === slave);
1637
- if (hasSlave) {
1638
- node.log(`忽略写入事件:从站${slave}在配置中,但masterId不匹配 (收到:${data.masterId}, 本机:${node.id})`);
1639
- }
1700
+ // 如果 masterId 不匹配,绝对不能处理,否则会导致多主站冲突
1640
1701
  return;
1641
1702
  }
1642
1703
 
1704
+ // 如果是广播事件(没有 masterId),但当前主站不是 relay-output 预期指向的主站,也应谨慎处理
1705
+ // 注意:内部事件通信强制要求 masterId,广播仅用于旧版兼容
1706
+ if (!data.masterId) {
1707
+ // 如果是广播,只有在配置中包含该从站时才尝试处理
1708
+ const hasSlave = node.config.slaves.find(s => parseInt(s.address) === slave);
1709
+ if (!hasSlave) return;
1710
+
1711
+ // 增加一条调试信息,提示广播可能带来的潜在冲突
1712
+ node.debug(`[免连线联动] 收到广播写入事件(从站${slave}),主站(${node.config.name || node.id})尝试执行。建议检查 relay-output 节点配置。`);
1713
+ }
1714
+
1643
1715
  // 检查该主站是否配置了此从站
1644
- const slaveConfig = node.config.slaves.find(s => s.address === slave);
1716
+ const slaveConfig = node.config.slaves.find(s => parseInt(s.address) === slave);
1645
1717
  if (!slaveConfig) {
1646
- // node.log(`忽略写入事件:主站未配置从站${slave}`);
1718
+ // 如果是针对本主站的明确写入(带 masterId),但未配置该从站,则给出警告
1719
+ if (data.masterId) {
1720
+ node.warn(`[免连线联动] 忽略明确写入:主站(${node.id})未配置从站${slave}。`);
1721
+ }
1647
1722
  return;
1648
1723
  }
1649
1724
 
1650
1725
  // 输出日志确认收到事件
1651
- node.log(`收到内部写入事件:从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'} (触发源: ${triggerSource})`);
1726
+ const masterName = node.config.name || "未命名主站";
1727
+ const targetMasterInfo = data.masterId === node.id ? `匹配成功 (${masterName})` : (data.masterId || '广播模式');
1728
+
1729
+ // 精简日志:明确主站写入时输出一条信息
1730
+ if (data.masterId) {
1731
+ // 增加校验:如果 masterId 存在但为空字符串,视为广播
1732
+ if (typeof data.masterId === 'string' && data.masterId.trim() === '') {
1733
+ node.debug(`[免连线联动] 收到无效masterId(空字符串),视为广播模式`);
1734
+ } else {
1735
+ // 增加 masterId 匹配校验(防御性编程,虽然上面已经 check 过了)
1736
+ if (data.masterId === node.id) {
1737
+ node.log(`[免连线联动] 触发写入: 从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'} (触发源: ${triggerSource})`);
1738
+ } else {
1739
+ // 理论上走不到这里,因为上面有 data.masterId !== node.id 的 check
1740
+ node.debug(`[免连线联动] 忽略不匹配的masterId: ${data.masterId} (本站: ${node.id})`);
1741
+ }
1742
+ }
1743
+ } else {
1744
+ node.debug(`[免连线联动] 目标主站: ${targetMasterInfo} | 触发源: ${triggerSource}`);
1745
+ }
1652
1746
 
1653
1747
  try {
1654
1748
  // 执行写入操作(writeSingleCoil内部已经会广播状态变化事件)
1655
1749
  await node.writeSingleCoil(slave, coil, value, triggerSource);
1656
-
1657
- node.log(`内部事件写入成功:从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
1658
1750
  } catch (err) {
1659
1751
  node.log(`内部事件写入失败: 从站${slave} 线圈${coil} - ${err.message}`);
1660
1752
  }
@@ -1711,10 +1803,11 @@ module.exports = function(RED) {
1711
1803
  const slave = parseInt(msg.payload.slave);
1712
1804
  const startCoil = parseInt(msg.payload.startCoil);
1713
1805
  const values = msg.payload.values.map(v => Boolean(v));
1806
+ const triggerSource = msg.payload.triggerSource || 'write-multiple-coils';
1714
1807
 
1715
- node.log(`接收到本地模式批量写入命令: 从站${slave} 起始线圈${startCoil} 共${values.length}个`);
1808
+ node.log(`接收到本地模式批量写入命令: 从站${slave} 起始线圈${startCoil} 共${values.length}个 (触发源: ${triggerSource})`);
1716
1809
 
1717
- node.writeMultipleCoils(slave, startCoil, values).catch(err => {
1810
+ node.writeMultipleCoils(slave, startCoil, values, triggerSource).catch(err => {
1718
1811
  node.log(`本地模式批量写入失败: ${err.message}`);
1719
1812
  });
1720
1813
  } else {
@@ -25,6 +25,10 @@ module.exports = function(RED) {
25
25
  // key: "serialPortConfigId:meshShortAddress", value: {timer, nodeId, serialPortConfig}
26
26
  const meshLedDebounceTimers = new Map();
27
27
 
28
+ // 全局按钮关联继电器状态表(用于多对一控制时的LED状态聚合)
29
+ // key: "serialPortConfigId:switchId:buttonNumber", value: Map<nodeId, boolean>
30
+ const globalButtonRelayStates = new Map();
31
+
28
32
  // 辅助函数:获取带作用域的键名
29
33
  // 确保configId始终有效,避免返回"unknown"前缀导致防抖键不匹配
30
34
  function getScopedKey(node, key) {
@@ -392,6 +396,8 @@ module.exports = function(RED) {
392
396
  targetCoilNumber: modbusCoilNumber, // 目标继电器线圈编号(0-31,从用户输入的1-32转换)
393
397
  modbusMaster: config.modbusMaster || "" // 关联的主站节点ID
394
398
  };
399
+
400
+ node.debug(`初始化开关节点: ID=${node.config.switchId} Btn=${node.config.buttonNumber} -> 目标从站=${node.config.targetSlaveAddress} 线圈=${node.config.targetCoilNumber} (用户输入:${userCoilNumber})`);
395
401
 
396
402
  node.currentState = false;
397
403
  node.isRs485Connected = false;
@@ -536,9 +542,11 @@ module.exports = function(RED) {
536
542
  // 检查是否是当前面板触发的操作
537
543
  const isTriggerSourcePanel = (node.serialPortConfig && node.serialPortConfig.triggerSource === panelKey);
538
544
 
539
- // 检查主站关联(如果配置了主站,则只响应对应主站的消息)
545
+ // 检查主站关联
540
546
  if (node.config.modbusMaster && masterId && node.config.modbusMaster !== masterId) {
541
- return;
547
+ // 严格隔离:只要 MasterID 不匹配,直接忽略
548
+ // 即使 Slave/Coil 相同,也是不同总线上的不同设备
549
+ return;
542
550
  }
543
551
 
544
552
  // 检查是否是我们关注的从站和线圈
@@ -555,6 +563,17 @@ module.exports = function(RED) {
555
563
  node.lastStateChange.timestamp = Date.now();
556
564
  node.lastStateChange.value = value;
557
565
 
566
+ // 更新全局继电器状态表(用于LED反馈聚合)
567
+ // 即使是单控模式,也通过此表统一管理状态
568
+ const relayStateKey = getScopedKey(node, `relay-state-${isMesh ? panelKey : node.config.switchId}-${node.config.buttonNumber}`);
569
+ if (!globalButtonRelayStates.has(relayStateKey)) {
570
+ globalButtonRelayStates.set(relayStateKey, new Map());
571
+ }
572
+ globalButtonRelayStates.get(relayStateKey).set(node.id, value);
573
+
574
+ // 计算聚合状态(OR逻辑:只要有一个关联继电器为ON,指示灯就为ON)
575
+ const aggregatedState = Array.from(globalButtonRelayStates.get(relayStateKey).values()).some(v => v === true);
576
+
558
577
  // Mesh 模式:更新全局共享状态缓存
559
578
  if (isMesh && panelKey) {
560
579
  const statesKey = getScopedKey(node, panelKey);
@@ -565,8 +584,9 @@ module.exports = function(RED) {
565
584
  if (node.config.meshButtonNumber > 0 && node.config.meshButtonNumber <= states.length) {
566
585
  // 优化:只有当值真正有效(非 null)或初始化时,才更新缓存
567
586
  // 避免在全控过程中,由于某些按键未同步到状态而导致的 null 覆盖有效值
568
- if (value !== null || states[node.config.meshButtonNumber - 1] === null) {
569
- states[node.config.meshButtonNumber - 1] = value;
587
+ // 使用聚合后的状态更新缓存
588
+ if (aggregatedState !== null || states[node.config.meshButtonNumber - 1] === null) {
589
+ states[node.config.meshButtonNumber - 1] = aggregatedState;
570
590
  }
571
591
  }
572
592
  }
@@ -574,67 +594,159 @@ module.exports = function(RED) {
574
594
  if (stateChanged) {
575
595
  // 核心逻辑:状态发生变化时,启动 LED 同步防抖队列
576
596
  // 防抖机制(200ms)确保在全开/全关等批量场景下,能够收集完整状态并平滑同步
577
- if (isInit) {
578
- node.debug(`[LED反馈] 跳过初始化阶段的同步请求`);
597
+ const panelId = isMesh ? panelKey : node.config.switchId;
598
+ const debounceKey = getScopedKey(node, panelId);
599
+ let timerObj = meshLedDebounceTimers.get(debounceKey);
600
+
601
+ if (!timerObj) {
602
+ timerObj = {
603
+ timer: null,
604
+ nodeId: node.id,
605
+ serialPortConfig: node.serialPortConfig,
606
+ firstChangeTime: Date.now(),
607
+ changeCount: 0,
608
+ targetNodes: new Set([node])
609
+ };
610
+ meshLedDebounceTimers.set(debounceKey, timerObj);
579
611
  } else {
580
- const panelId = isMesh ? panelKey : node.config.switchId;
581
- const debounceKey = getScopedKey(node, panelId);
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
- }
612
+ timerObj.targetNodes.add(node);
613
+ }
597
614
 
598
- timerObj.changeCount++;
599
- if (timerObj.timer) clearTimeout(timerObj.timer);
615
+ timerObj.changeCount++;
616
+ if (timerObj.timer) clearTimeout(timerObj.timer);
600
617
 
601
- // 统一使用 200ms 防抖,确保在大批量操作时收集完整状态
602
- const debounceTime = 200;
618
+ // 统一使用 200ms 防抖,确保在大批量操作时收集完整状态
619
+ const debounceTime = isInit ? 50 : 200;
603
620
 
604
- timerObj.timer = setTimeout(() => {
605
- const currentTimerObj = meshLedDebounceTimers.get(debounceKey);
606
- if (currentTimerObj) {
607
- meshLedDebounceTimers.delete(debounceKey);
608
-
609
- if (currentTimerObj.serialPortConfig && currentTimerObj.serialPortConfig.setFeedbackLock) {
610
- // 缩短锁定时间从 150ms 到 50ms,确保能及时捕获从站的真实状态报告
611
- currentTimerObj.serialPortConfig.setFeedbackLock(50);
621
+ timerObj.timer = setTimeout(() => {
622
+ const currentTimerObj = meshLedDebounceTimers.get(debounceKey);
623
+ if (currentTimerObj) {
624
+ meshLedDebounceTimers.delete(debounceKey);
625
+
626
+ if (currentTimerObj.serialPortConfig && currentTimerObj.serialPortConfig.setFeedbackLock) {
627
+ // 锁定反馈,防止回显
628
+ currentTimerObj.serialPortConfig.setFeedbackLock(100);
629
+ }
630
+
631
+ if (isMesh) {
632
+ const representativeNode = currentTimerObj.targetNodes.values().next().value;
633
+ if (representativeNode) {
634
+ const changedButtons = new Set();
635
+ currentTimerObj.targetNodes.forEach(n => {
636
+ if (n.config.meshButtonNumber) changedButtons.add(parseInt(n.config.meshButtonNumber));
637
+ });
638
+ // Mesh模式下,sendCommandToPanel 会从 meshDeviceStates 读取状态
639
+ // 我们已经在上面更新了 meshDeviceStates 为 aggregatedState
640
+ // 注意:传入第一个参数其实在 sendCommandToPanel 内部对于 Mesh 模式是不直接使用的(它用 currentStates)
641
+ // 但为了保持一致性,我们还是传 aggregatedState (虽然这里拿不到具体的 aggregatedState,传 local state 也没关系)
642
+ representativeNode.sendCommandToPanel(representativeNode.currentState, false, changedButtons);
612
643
  }
613
-
614
- if (isMesh) {
615
- const representativeNode = currentTimerObj.targetNodes.values().next().value;
616
- if (representativeNode) {
617
- const changedButtons = new Set();
618
- currentTimerObj.targetNodes.forEach(n => {
619
- if (n.config.meshButtonNumber) changedButtons.add(parseInt(n.config.meshButtonNumber));
644
+ } else {
645
+ // 485/Clowire 模式:按按钮分组发送,确保多控一/一对多场景下状态正确
646
+ // 排序:先按 switchId,再按 buttonNumber,确保处理顺序一致
647
+ const sortedNodes = Array.from(currentTimerObj.targetNodes).sort((a, b) => {
648
+ if (a.config.switchId !== b.config.switchId) return a.config.switchId - b.config.switchId;
649
+ return a.config.buttonNumber - b.config.buttonNumber;
650
+ });
651
+
652
+ // 找出所有涉及的面板 ID (switchId)
653
+ const panelIds = new Set();
654
+ sortedNodes.forEach(node => panelIds.add(node.config.switchId));
655
+
656
+ // 针对每个面板,单独处理
657
+ panelIds.forEach(panelId => {
658
+ // 找到该面板的一个代表节点(用于获取配置和发送指令)
659
+ const representativeNode = sortedNodes.find(n => n.config.switchId === panelId);
660
+ if (!representativeNode) return;
661
+
662
+ // 检查该面板的所有按键状态
663
+ // 1. 获取该面板下所有已配置的节点(不仅仅是本次变更的节点)
664
+ // 由于 globalButtonRelayStates 是全局的,我们可以遍历它来检查状态
665
+
666
+ const configId = (representativeNode.serialPortConfig && representativeNode.serialPortConfig.id) || representativeNode.id || "default";
667
+ let anyOn = false;
668
+
669
+ // 遍历检查该面板的所有可能按键(1-8)
670
+ // 只要发现有一个按键是 ON,就不能使用全关指令
671
+ for (let i = 1; i <= 8; i++) {
672
+ const key = `${configId}:relay-state-${panelId}-${i}`;
673
+ if (globalButtonRelayStates.has(key)) {
674
+ const stateMap = globalButtonRelayStates.get(key);
675
+ const isBtnOn = Array.from(stateMap.values()).some(v => v === true);
676
+ if (isBtnOn) {
677
+ anyOn = true;
678
+ // representativeNode.debug(`[LED优化] 面板${panelId} 按键${i} 仍为 ON,跳过全关`);
679
+ break;
680
+ }
681
+ }
682
+ }
683
+
684
+ // Clowire/Symi 协议优化:如果该面板所有已知的按键状态都为 OFF,则发送一条“全关”指令
685
+ let useGlobalOff = false;
686
+ if (!anyOn) {
687
+ if (representativeNode.config.switchBrand === 'clowire') {
688
+ useGlobalOff = true;
689
+ } else if (representativeNode.config.switchBrand === 'symi') {
690
+ useGlobalOff = false;
691
+ }
692
+ }
693
+
694
+ if (useGlobalOff && representativeNode.serialPortConfig) {
695
+ try {
696
+ let cmd = null;
697
+ if (representativeNode.config.switchBrand === 'clowire') {
698
+ // Clowire 全关指令 (0x1020 -> 0x0002)
699
+ cmd = clowireProtocol.buildTurnOffAllLedsCommand(panelId);
700
+ representativeNode.debug(`[LED优化] Clowire面板${panelId} 全关`);
701
+ } else if (representativeNode.config.switchBrand === 'symi') {
702
+ // Symi 全关指令 (OpCode=0x05, States=0x00)
703
+ // 使用 SET (0x03)
704
+ cmd = protocol.buildMultiLightCommand(0x00, panelId, 0, 0, 0);
705
+ representativeNode.debug(`[LED优化] Symi面板${panelId} 全关 (SET模式)`);
706
+ }
707
+
708
+ if (cmd) {
709
+ // 统一使用高优先级
710
+ representativeNode.serialPortConfig.write(cmd, (err) => {
711
+ if (err) {
712
+ representativeNode.error(`发送全关指令失败: ${err.message}`);
713
+ }
714
+ }, 1, panelId);
715
+ }
716
+ } catch (err) {
717
+ representativeNode.error(`构建全关指令异常: ${err.message}`);
718
+ }
719
+ } else {
720
+ // 标准逻辑:逐个发送
721
+ // 只发送本次变更涉及的节点
722
+ const processedButtons = new Set();
723
+ sortedNodes.forEach(targetNode => {
724
+ // 过滤掉非当前面板的节点
725
+ if (targetNode.config.switchId !== panelId) return;
726
+
727
+ const btnNum = targetNode.config.buttonNumber;
728
+ if (processedButtons.has(btnNum)) return;
729
+ processedButtons.add(btnNum);
730
+
731
+ // 获取该按钮的聚合状态
732
+ const rKey = getScopedKey(targetNode, `relay-state-${panelId}-${btnNum}`);
733
+ const stateMap = globalButtonRelayStates.get(rKey);
734
+ // 如果找不到映射(理论上不可能),降级使用节点自身状态
735
+ const aggState = stateMap ? Array.from(stateMap.values()).some(v => v === true) : targetNode.currentState;
736
+
737
+ targetNode.sendCommandToPanel(aggState, false);
620
738
  });
621
- // 强制 isTriggerSource=false,确保这里发出的都是同步指令
622
- representativeNode.sendCommandToPanel(representativeNode.currentState, false, changedButtons);
623
739
  }
624
- } else {
625
- currentTimerObj.targetNodes.forEach(targetNode => {
626
- targetNode.sendCommandToPanel(targetNode.currentState, false);
627
- });
628
- }
740
+ });
629
741
  }
630
- }, debounceTime);
631
- }
742
+ }
743
+ }, debounceTime);
632
744
  }
633
745
 
634
746
  node.updateStatus();
635
747
 
636
748
  if (isInit) {
637
- node.debug(`首次轮询状态同步:从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'}(不发送到下游)`);
749
+ node.debug(`初始化状态同步:从站${slave} 线圈${coil} = ${value ? 'ON' : 'OFF'}`);
638
750
  return;
639
751
  }
640
752
 
@@ -843,18 +955,28 @@ module.exports = function(RED) {
843
955
 
844
956
  // 全局防抖(基于面板ID和按键编号,不包含从站地址)
845
957
  // 这样同一个按键事件只会被处理一次LED反馈,但每个节点都会发送MQTT命令
958
+ // 修改:针对命令发送(MQTT/内部事件)的防抖,key 必须包含 targetSlaveAddress,
959
+ // 否则如果配置了多个从站开关节点(对应同一个物理按键但控制不同继电器),
960
+ // 会被误判为重复命令而只触发第一个。
846
961
  const ledDebounceKey = getScopedKey(node, `led-${node.config.switchId}-${node.config.buttonNumber}`);
847
- const commandDebounceKey = getScopedKey(node, `cmd-${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}`);
962
+ // 增加 targetCoilNumber key 中,确保同一从站不同线圈也能独立触发(虽然少见)
963
+ const commandDebounceKey = getScopedKey(node, `cmd-${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}-${node.config.targetCoilNumber}`);
848
964
  const now = Date.now();
849
965
 
850
966
  // 检查命令防抖(每个节点独立)
967
+ // 只有针对完全相同的目标(同一从站、同一线圈)才进行防抖
851
968
  const lastCommandTime = globalDebounceCache.get(commandDebounceKey) || 0;
852
969
  if (now - lastCommandTime < 200) {
970
+ // node.debug(`忽略重复命令: 面板${node.config.switchId} 按键${node.config.buttonNumber} -> 从站${node.config.targetSlaveAddress} (间隔${now - lastCommandTime}ms)`);
853
971
  return;
854
972
  }
855
973
  globalDebounceCache.set(commandDebounceKey, now);
856
974
 
857
975
  // 检查LED反馈防抖(同一面板同一按键共享)
976
+ // 调试日志:确认按键触发及目标
977
+ // node.debug(`[按键触发] 面板${node.config.switchId} 按键${node.config.buttonNumber} -> 目标从站${node.config.targetSlaveAddress} 线圈${node.config.targetCoilNumber}`);
978
+
979
+ // 多个节点共享同一个物理按键的 LED 反馈,只需要发送一次
858
980
  const lastLedTime = globalDebounceCache.get(ledDebounceKey) || 0;
859
981
  const shouldSendLed = (now - lastLedTime >= 200);
860
982
  if (shouldSendLed) {
@@ -889,7 +1011,10 @@ module.exports = function(RED) {
889
1011
  if (shouldSendLed) {
890
1012
  if (node.serialPortConfig && node.serialPortConfig.setFeedbackLock) {
891
1013
  // 统一使用 50ms 锁定,防止反馈回显触发误判
892
- node.serialPortConfig.setFeedbackLock(50);
1014
+ // 使用 setTimeout 延迟锁定,确保当前帧能被所有并行的节点处理完毕
1015
+ setTimeout(() => {
1016
+ node.serialPortConfig.setFeedbackLock(50);
1017
+ }, 1);
893
1018
  }
894
1019
  node.sendCommandToPanel(node.currentState);
895
1020
  }
@@ -917,7 +1042,10 @@ module.exports = function(RED) {
917
1042
  if (shouldSendLed) {
918
1043
  if (node.serialPortConfig && node.serialPortConfig.setFeedbackLock) {
919
1044
  // 延长反馈锁时间至 100ms,确保总线回显被彻底过滤
920
- node.serialPortConfig.setFeedbackLock(100);
1045
+ // 使用 setTimeout 延迟锁定,确保当前帧能被所有并行的节点处理完毕
1046
+ setTimeout(() => {
1047
+ node.serialPortConfig.setFeedbackLock(100);
1048
+ }, 1);
921
1049
  }
922
1050
  // 传入 true 表示这是由物理按键触发的强制反馈
923
1051
  node.sendCommandToPanel(buttonEvent.state, true);
@@ -961,7 +1089,7 @@ module.exports = function(RED) {
961
1089
  // 处理感应器事件(通道0x0F,buttonNumber=15)
962
1090
  if (event.type === 'sensor' && node.config.buttonNumber === 15) {
963
1091
  // 感应器模式:有人/无人状态,复用按键背光灯配置
964
- const debounceKey = getScopedKey(node, `clowire-sensor-${node.config.switchId}-${node.config.targetSlaveAddress}`);
1092
+ const debounceKey = getScopedKey(node, `clowire-sensor-${node.config.switchId}-${node.config.targetSlaveAddress}-${node.config.targetCoilNumber}`);
965
1093
  const now = Date.now();
966
1094
  const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
967
1095
 
@@ -994,13 +1122,13 @@ module.exports = function(RED) {
994
1122
  if (event.type === 'button' && event.buttonNumber === node.config.buttonNumber) {
995
1123
  // 全局防抖(基于面板ID和按键编号,不包含从站地址)
996
1124
  const ledDebounceKey = getScopedKey(node, `clowire-led-${node.config.switchId}-${node.config.buttonNumber}`);
997
- const commandDebounceKey = getScopedKey(node, `clowire-cmd-${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}`);
1125
+ const commandDebounceKey = getScopedKey(node, `clowire-cmd-${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}-${node.config.targetCoilNumber}`);
998
1126
  const now = Date.now();
999
1127
 
1000
1128
  // 检查命令防抖(每个节点独立)
1001
1129
  const lastCommandTime = globalDebounceCache.get(commandDebounceKey) || 0;
1002
1130
  if (now - lastCommandTime < 200) {
1003
- continue;
1131
+ return;
1004
1132
  }
1005
1133
  globalDebounceCache.set(commandDebounceKey, now);
1006
1134
 
@@ -1036,7 +1164,10 @@ module.exports = function(RED) {
1036
1164
 
1037
1165
  // 只有第一个处理的节点发送LED反馈
1038
1166
  if (shouldSendLed) {
1039
- node.sendCommandToPanel(node.currentState);
1167
+ // 延迟 1200ms 发送 LED 反馈,避开 Modbus Master 控制继电器的总线高峰期
1168
+ setTimeout(() => {
1169
+ node.sendCommandToPanel(node.currentState);
1170
+ }, 1200);
1040
1171
  }
1041
1172
 
1042
1173
  // 输出消息
@@ -1112,11 +1243,11 @@ module.exports = function(RED) {
1112
1243
  if (isSceneMode) {
1113
1244
  // 场景模式:全局防抖(200ms内只触发一次)
1114
1245
  // 包含targetSlaveAddress,允许一个按键控制多个不同从站的继电器
1115
- const debounceKey = getScopedKey(node, `mesh-scene-${node.config.meshShortAddress}-${node.config.meshButtonNumber}-${node.config.targetSlaveAddress}`);
1246
+ const debounceKey = getScopedKey(node, `mesh-scene-${node.config.meshShortAddress}-${node.config.meshButtonNumber}-${node.config.targetSlaveAddress}-${node.config.targetCoilNumber}`);
1116
1247
  const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
1117
1248
 
1118
1249
  if (now - lastTriggerTime < 200) {
1119
- node.debug(`[Mesh场景] 全局防抖忽略:设备${meshAddr} 按键${node.config.meshButtonNumber} (${now - lastTriggerTime}ms内)`);
1250
+ // node.debug(`[Mesh场景] 全局防抖忽略:设备${meshAddr} 按键${node.config.meshButtonNumber} (${now - lastTriggerTime}ms内)`);
1120
1251
  return;
1121
1252
  }
1122
1253
  globalDebounceCache.set(debounceKey, now);
@@ -1164,13 +1295,13 @@ module.exports = function(RED) {
1164
1295
 
1165
1296
  // 第四步:全局防抖:防止同一个按键的同一个目标重复触发
1166
1297
  // 包含targetSlaveAddress,允许一个按键控制多个不同从站的继电器
1167
- const debounceKey = getScopedKey(node, `mesh-${node.config.meshShortAddress}-${node.config.meshButtonNumber}-${node.config.targetSlaveAddress}`);
1298
+ const debounceKey = getScopedKey(node, `mesh-${node.config.meshShortAddress}-${node.config.meshButtonNumber}-${node.config.targetSlaveAddress}-${node.config.targetCoilNumber}`);
1168
1299
  now = Date.now();
1169
1300
  const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
1170
1301
 
1171
1302
  // 全局防抖:200ms内只触发一次
1172
1303
  if (now - lastTriggerTime < 200) {
1173
- node.debug(`[Mesh事件] 全局防抖忽略:设备${meshAddr} 按键${node.config.meshButtonNumber} (${now - lastTriggerTime}ms内)`);
1304
+ // node.debug(`[Mesh事件] 全局防抖忽略:设备${meshAddr} 按键${node.config.meshButtonNumber} (${now - lastTriggerTime}ms内)`);
1174
1305
  return; // 静默忽略重复触发
1175
1306
  }
1176
1307
  globalDebounceCache.set(debounceKey, now);
@@ -1250,14 +1381,30 @@ module.exports = function(RED) {
1250
1381
  // 模式2:内部事件模式(通过RED内部事件系统,免连线通信)
1251
1382
  // 当MQTT未启用或未连接时,通过内部事件发送控制命令
1252
1383
  // 主站节点会监听这些事件并执行写入操作
1384
+
1385
+ // 确定目标主站 ID
1386
+ // 如果配置了主站但节点不存在,则降级为广播模式(不带 masterId),让所有主站尝试处理
1387
+ let targetMasterId = node.config.modbusMaster || "";
1388
+ if (targetMasterId && !RED.nodes.getNode(targetMasterId)) {
1389
+ node.debug(`指定的配置主站 ${targetMasterId} 不存在,切换到广播模式`);
1390
+ targetMasterId = undefined;
1391
+ }
1392
+
1393
+ // 明确触发源
1394
+ const triggerSource = isSceneMode ? 'scene-trigger' : 'button-press';
1395
+ const slaveAddr = parseInt(node.config.targetSlaveAddress);
1396
+ const coilNum = parseInt(node.config.targetCoilNumber);
1397
+
1398
+ node.debug(`[sendMqttCommand] 准备发送内部事件: 从站${slaveAddr} 线圈${coilNum} = ${state} (源: ${triggerSource}, 目标主站: ${targetMasterId || '广播'})`);
1399
+
1253
1400
  RED.events.emit('modbus:writeCoil', {
1254
- slave: node.config.targetSlaveAddress,
1255
- coil: node.config.targetCoilNumber,
1401
+ slave: slaveAddr,
1402
+ coil: coilNum,
1256
1403
  value: state,
1257
1404
  source: 'slave-switch',
1258
- triggerSource: isSceneMode ? 'scene-trigger' : 'button-press', // 场景模式使用scene-trigger,开关模式使用button-press
1405
+ triggerSource: triggerSource, // 场景模式使用scene-trigger,开关模式使用button-press
1259
1406
  nodeId: node.id,
1260
- masterId: node.config.modbusMaster || "", // 指定目标主站ID
1407
+ masterId: targetMasterId, // 指定目标主站ID
1261
1408
  serialPortConfigId: config.serialPortConfig // 增加串口配置ID,用于主站过滤,防止多主站冲突
1262
1409
  });
1263
1410
 
@@ -1823,6 +1970,15 @@ module.exports = function(RED) {
1823
1970
  const panelKey = isMesh ? node.config.meshShortAddress : node.config.switchId;
1824
1971
  const debounceKey = getScopedKey(node, isMesh ? panelKey : `${panelKey}-${node.id}`);
1825
1972
 
1973
+ // 从全局继电器状态表中移除当前节点
1974
+ const relayStateKey = getScopedKey(node, `relay-state-${isMesh ? panelKey : node.config.switchId}-${node.config.buttonNumber}`);
1975
+ if (globalButtonRelayStates.has(relayStateKey)) {
1976
+ globalButtonRelayStates.get(relayStateKey).delete(node.id);
1977
+ if (globalButtonRelayStates.get(relayStateKey).size === 0) {
1978
+ globalButtonRelayStates.delete(relayStateKey);
1979
+ }
1980
+ }
1981
+
1826
1982
  if (panelKey && meshLedDebounceTimers.has(debounceKey)) {
1827
1983
  const timerObj = meshLedDebounceTimers.get(debounceKey);
1828
1984
  if (timerObj && timerObj.timer) {
@@ -44,7 +44,7 @@
44
44
  // 填充主站节点选择器
45
45
  var masterNodeSelect = $("#node-input-masterNode");
46
46
  masterNodeSelect.empty();
47
- masterNodeSelect.append('<option value="">请选择主站节点</option>');
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': '翻转' };
@@ -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
- if (parseInt(msgButtonNumber) !== node.buttonNumber) {
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: node.masterNodeId,
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.log(`继电器输出: 从站${node.slaveAddress} 线圈${node.coilNumber} ${actionText}${sourceText}`);
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
- break;
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",
3
+ "version": "2.10.6",
4
4
  "description": "Node-RED Modbus节点,支持TCP/串口通信、多主站独立运行、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步(支持Symi/Clowire品牌),工控机长期稳定运行",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {