node-red-contrib-symi-modbus 2.10.27 → 2.10.29

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
@@ -34,7 +34,7 @@ Node-RED的Modbus继电器控制节点,支持TCP/串口通信和MQTT集成。
34
34
  | **HomeKit网桥** | 一键将所有 Modbus 继电器接入 Apple HomeKit,支持自定义名称。 |
35
35
  | **控制看板** | 在 Node-RED 编辑器内直接控制和查看所有继电器的实时状态。 |
36
36
  | **Mesh透传** | 适配 Symi 蓝牙 Mesh 网关,支持窗帘、杜亚电机及各种子设备。 |
37
- | **继电器输出** | 灵活的联动节点,支持按键触发、延时控制和多种动作逻辑。 |
37
+ | **继电器输出** | 灵活的联动节点,支持按键触发、延时控制、多种动作逻辑,LF 品牌支持指示灯跟随与睡眠模式。 |
38
38
  | **Modbus调试** | 实时监控原始 485 总线报文,方便现场调试和协议分析。 |
39
39
  | **自定义协议** | 支持通过 JavaScript 编写自定义协议转换逻辑。 |
40
40
  | **配置节点** | 包含串口配置、MQTT配置和Modbus服务器配置。 |
@@ -134,8 +134,11 @@ node-red-restart
134
134
  - **面板地址**:1–42(出厂默认 2),开关 ID 填十进制地址
135
135
  - **按键上报**(主动发送 8 字节):`[地址][03][00][02][绝对键值][按键掩码][CRC]`
136
136
  - **指示灯控制**:写寄存器 `0x1008` 一次写入完整 bitmask(变背光产品高字节 BIT8=1,节点自动处理)
137
+ - **待机指示灯**:继电器全部关闭时默认下发 `0x0100`(仅 BIT8 背光),**不会**自动全灭;全灭 `0x0000` 仅用于显式熄灭
138
+ - **继电器输出(LF)**:可选 **指示灯跟随状态**——勾选则按键灯与绑定线圈同步;不勾选则按键后瞬时亮灯,本按键关联的全部继电器动作完成后恢复待机背光
139
+ - **睡眠模式(LF)**:继电器输出节点勾选 **睡眠模式** 后,该按键触发的继电器动作完成时面板全部指示灯熄灭(含背光);任意面板按键按下后自动退出睡眠,恢复正常显示
137
140
  - **RCU 模式**:首次 LED 控制自动写 `0x1003=0x0006` 并保存 `0x100F`
138
- - **多键同步**:同面板 8 个从站开关节点共享状态,**只发一条** `0x1008` 帧(复用写入队列 / 200ms 防抖 / 面板级去重)
141
+ - **多键同步**:同面板任意按键/继电器变化,经 `scheduleLfPanelLedSync` **面板级防抖** 后只发 **一条** `0x1008` 帧(非逐节点、非逐线圈各发一帧)
139
142
 
140
143
  **面板地址 1 · 变背光 · 单键亮(测试码):**
141
144
 
@@ -149,6 +152,7 @@ node-red-restart
149
152
  | 按键6亮 | `01 06 10 08 01 20 0C 80` |
150
153
  | 按键7亮 | `01 06 10 08 01 40 0C A8` |
151
154
  | 按键8亮 | `01 06 10 08 01 80 0C F8` |
155
+ | **待机指示灯** | `01 06 10 08 01 00 0D 58` |
152
156
  | **全灭** | `01 06 10 08 00 00 0C C8` |
153
157
 
154
158
  **单键灭(`0x1008` 写剩余 bitmask,非逐键单独灭指令):**
@@ -522,12 +526,14 @@ msg.payload = 1; // 或 0
522
526
 
523
527
  ### 更新日志
524
528
 
525
- #### v2.10.27 (2026-07-09)
529
+ #### v2.10.29 (2026-07-09)
526
530
 
527
- - **[LF485 开关]** 生产发布:拉斐 LF 485 弱电开关完整对接(主动收发、RCU 模式、变背光 BIT8、面板级 0x1008 单帧聚合)。
528
- - **[LF485 开关]** `sendLfPanelLedSync` 同面板 8 键只发一条指示灯帧;复用 `serial-port-config` 50ms 队列、200ms 防抖、粘包解析与全局缓存。
531
+ - **[LF485 开关]** 拉斐 LF 485 弱电开关完整对接:主动收发、RCU 模式自动启用、变背光 BIT8、面板级 `0x1008` 单帧聚合(同面板 8 键只发一条指示灯帧)。
532
+ - **[LF485 睡眠模式]** 继电器输出节点新增 **睡眠模式** 勾选项(仅 LF 品牌):勾选后该按键触发的继电器动作完成时,面板全部指示灯熄灭(`0x1008=0x0000`,含背光);任意面板按键按下后自动退出睡眠,恢复正常显示。
533
+ - **[LF485 指示灯跟随]** 继电器输出节点 LF 品牌新增 **指示灯跟随状态**(默认不跟随):勾选=按键灯与绑定线圈同步;不勾选=瞬时亮灯,同按键全部继电器动作完成后回待机背光。
534
+ - **[LF485 待机背光]** 继电器全关时默认 **待机背光**(`0x1008=0x0100`),不再误发全灭。
529
535
  - **[适配]** 从站开关 / 继电器输出 / Mesh 透传节点支持 LF 品牌;`lf-protocol.js` 单元测试覆盖 PDF 范例与用户抓包。
530
- - **[打包]** npm 包审计:不含 `doc/`、PDF、开发缓存;兼容 Node-RED ≥2.0、Node.js ≥14。
536
+ - **[修复]** `RED.nodes.eachNode` 空指针崩溃防护;补全按键 7/8 从站开关节点;场景键不再误写 Modbus 线圈;移除未使用参数。
531
537
 
532
538
  #### v2.10.20 (2026-06-30)
533
539
 
@@ -0,0 +1,364 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * LF 面板指示灯聚合状态(从站开关 + 继电器输出共享)
5
+ *
6
+ * 设计原则:同一面板 (serialPortConfigId + switchId) 任意时刻只通过
7
+ * scheduleLfPanelLedSync 汇聚为 **一条** 0x1008 帧;禁止多路径各自立即发送。
8
+ *
9
+ * 待机:0x1008 = 0x0100(仅 BIT8 背光)
10
+ * 全灭:0x0000(仅显式熄灭场景)
11
+ */
12
+
13
+ let syncCallback = null;
14
+ const lfRelayOutputs = new Map();
15
+ const lfMomentaryButtons = new Map();
16
+ const lfPendingActions = new Map();
17
+ const lfPanelSyncTimers = new Map();
18
+ // 睡眠模式:面板级状态,激活时指示灯全灭(0x0000),任意按键退出
19
+ const lfSleepModePanels = new Set();
20
+
21
+ const BATCH_FALLBACK_MARGIN_MS = 3000;
22
+ const PANEL_LED_DEBOUNCE_MS = 200;
23
+ const PANEL_LED_DEDUPE_MS = 200;
24
+
25
+ function getPanelKey(serialPortConfigId, switchId) {
26
+ return `${serialPortConfigId}:${parseInt(switchId, 10)}`;
27
+ }
28
+
29
+ /**
30
+ * 设置面板睡眠模式
31
+ * @param {string} serialPortConfigId 串口配置ID
32
+ * @param {number|string} switchId 面板地址
33
+ * @param {boolean} enabled true=进入睡眠(全灭),false=退出睡眠
34
+ */
35
+ function setPanelSleepMode(serialPortConfigId, switchId, enabled) {
36
+ const panelKey = getPanelKey(serialPortConfigId, switchId);
37
+ if (enabled) {
38
+ lfSleepModePanels.add(panelKey);
39
+ } else {
40
+ lfSleepModePanels.delete(panelKey);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 查询面板是否处于睡眠模式
46
+ */
47
+ function isPanelSleepMode(serialPortConfigId, switchId) {
48
+ return lfSleepModePanels.has(getPanelKey(serialPortConfigId, switchId));
49
+ }
50
+
51
+ /**
52
+ * 清除所有睡眠模式状态(节点卸载/重启时调用)
53
+ */
54
+ function clearAllSleepMode() {
55
+ lfSleepModePanels.clear();
56
+ }
57
+
58
+ function getPendingKey(serialPortConfigId, switchId, buttonNumber) {
59
+ return `${getPanelKey(serialPortConfigId, switchId)}:${parseInt(buttonNumber, 10)}`;
60
+ }
61
+
62
+ function setLfLedSyncCallback(fn) {
63
+ syncCallback = fn;
64
+ }
65
+
66
+ function cancelPanelSyncTimer(panelKey) {
67
+ const timer = lfPanelSyncTimers.get(panelKey);
68
+ if (timer) {
69
+ clearTimeout(timer);
70
+ lfPanelSyncTimers.delete(panelKey);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * 面板级 LED 调度:多次请求合并为一次 0x1008 发送
76
+ * @param {object} [options]
77
+ * @param {boolean} [options.immediate] 立即发送(仅按键瞬时亮灯等需要即时反馈的场景)
78
+ * @param {number} [options.debounceMs] 防抖毫秒,默认 200
79
+ */
80
+ function scheduleLfPanelLedSync(serialPortConfigId, switchId, options) {
81
+ const opts = options || {};
82
+ const panelId = parseInt(switchId, 10);
83
+ const panelKey = getPanelKey(serialPortConfigId, panelId);
84
+
85
+ cancelPanelSyncTimer(panelKey);
86
+
87
+ if (opts.immediate === true) {
88
+ if (typeof syncCallback === "function") {
89
+ syncCallback(serialPortConfigId, panelId);
90
+ }
91
+ return;
92
+ }
93
+
94
+ const debounceMs = opts.debounceMs !== undefined ? opts.debounceMs : PANEL_LED_DEBOUNCE_MS;
95
+ const timer = setTimeout(() => {
96
+ lfPanelSyncTimers.delete(panelKey);
97
+ if (typeof syncCallback === "function") {
98
+ syncCallback(serialPortConfigId, panelId);
99
+ }
100
+ }, debounceMs);
101
+ lfPanelSyncTimers.set(panelKey, timer);
102
+ }
103
+
104
+ /** @deprecated 请使用 scheduleLfPanelLedSync;保留兼容入口 */
105
+ function requestLfPanelLedSync(serialPortConfigId, switchId) {
106
+ scheduleLfPanelLedSync(serialPortConfigId, switchId, { debounceMs: PANEL_LED_DEBOUNCE_MS });
107
+ }
108
+
109
+ function registerLfRelayOutput(node) {
110
+ if (!node || node.switchBrand !== "lf" || node.buttonType === "input") {
111
+ return;
112
+ }
113
+ const configId = node.serialPortConfig || node.id || "global";
114
+ const key = getPanelKey(configId, node.switchId);
115
+ if (!lfRelayOutputs.has(key)) {
116
+ lfRelayOutputs.set(key, new Set());
117
+ }
118
+ lfRelayOutputs.get(key).add(node);
119
+ }
120
+
121
+ function unregisterLfRelayOutput(node) {
122
+ if (!node) {
123
+ return;
124
+ }
125
+ const configId = node.serialPortConfig || node.id || "global";
126
+ const key = getPanelKey(configId, node.switchId);
127
+ const set = lfRelayOutputs.get(key);
128
+ if (!set) {
129
+ return;
130
+ }
131
+ set.delete(node);
132
+ if (set.size === 0) {
133
+ lfRelayOutputs.delete(key);
134
+ }
135
+ }
136
+
137
+ function getRelayOutputsForButton(serialPortConfigId, switchId, buttonNumber) {
138
+ const key = getPanelKey(serialPortConfigId, switchId);
139
+ const set = lfRelayOutputs.get(key);
140
+ if (!set) {
141
+ return [];
142
+ }
143
+ const btn = parseInt(buttonNumber, 10);
144
+ return Array.from(set).filter((ro) => parseInt(ro.buttonNumber, 10) === btn);
145
+ }
146
+
147
+ function getMomentaryOutputs(serialPortConfigId, switchId, buttonNumber) {
148
+ return getRelayOutputsForButton(serialPortConfigId, switchId, buttonNumber)
149
+ .filter((ro) => ro.lfLedFollowState !== true);
150
+ }
151
+
152
+ function hasMomentaryRelayOutputs(serialPortConfigId, switchId, buttonNumber) {
153
+ return getMomentaryOutputs(serialPortConfigId, switchId, buttonNumber).length > 0;
154
+ }
155
+
156
+ function clearMomentary(serialPortConfigId, switchId, buttonNumber) {
157
+ const key = getPanelKey(serialPortConfigId, switchId);
158
+ const active = lfMomentaryButtons.get(key);
159
+ if (!active) {
160
+ return;
161
+ }
162
+ active.delete(parseInt(buttonNumber, 10));
163
+ if (active.size === 0) {
164
+ lfMomentaryButtons.delete(key);
165
+ }
166
+ }
167
+
168
+ function finishMomentaryBatch(serialPortConfigId, switchId, buttonNumber, pendingKey) {
169
+ const pending = lfPendingActions.get(pendingKey);
170
+ if (!pending) {
171
+ return;
172
+ }
173
+ if (pending.fallbackTimer) {
174
+ clearTimeout(pending.fallbackTimer);
175
+ }
176
+ lfPendingActions.delete(pendingKey);
177
+ clearMomentary(serialPortConfigId, switchId, buttonNumber);
178
+ // 批次结束:只调度一次聚合同步(含继电器状态稳定等待)
179
+ scheduleLfPanelLedSync(serialPortConfigId, switchId, { debounceMs: PANEL_LED_DEBOUNCE_MS });
180
+ }
181
+
182
+ /**
183
+ * 面板上是否有进行中的瞬时亮灯批次(继电器动作尚未全部完成)
184
+ */
185
+ function hasActiveMomentaryBatch(serialPortConfigId, switchId) {
186
+ const prefix = `${getPanelKey(serialPortConfigId, switchId)}:`;
187
+ for (const [key, pending] of lfPendingActions.entries()) {
188
+ if (key.startsWith(prefix) && pending.completed < pending.expected) {
189
+ return true;
190
+ }
191
+ }
192
+ return false;
193
+ }
194
+
195
+ function isMomentaryBatchActive(serialPortConfigId, switchId, buttonNumber) {
196
+ const pendingKey = getPendingKey(serialPortConfigId, switchId, buttonNumber);
197
+ const pending = lfPendingActions.get(pendingKey);
198
+ return !!(pending && pending.completed < pending.expected);
199
+ }
200
+
201
+ /**
202
+ * 在 modbus:buttonPressed 派发之前调用
203
+ */
204
+ function beginLfButtonActionBatch(serialPortConfigId, switchId, buttonNumber) {
205
+ const configId = serialPortConfigId;
206
+ const panelId = parseInt(switchId, 10);
207
+ const btn = parseInt(buttonNumber, 10);
208
+ const panelKey = getPanelKey(configId, panelId);
209
+ const pendingKey = getPendingKey(configId, panelId, btn);
210
+
211
+ const momentaryOutputs = getMomentaryOutputs(configId, panelId, btn);
212
+ if (momentaryOutputs.length === 0) {
213
+ return false;
214
+ }
215
+
216
+ if (lfPendingActions.has(pendingKey)) {
217
+ const old = lfPendingActions.get(pendingKey);
218
+ if (old && old.fallbackTimer) {
219
+ clearTimeout(old.fallbackTimer);
220
+ }
221
+ }
222
+
223
+ if (!lfMomentaryButtons.has(panelKey)) {
224
+ lfMomentaryButtons.set(panelKey, new Set());
225
+ }
226
+ lfMomentaryButtons.get(panelKey).add(btn);
227
+
228
+ const maxDelay = momentaryOutputs.reduce((max, ro) => Math.max(max, ro.delayMs || 0), 0);
229
+ const pending = {
230
+ expected: momentaryOutputs.length,
231
+ completed: 0,
232
+ maxDelay: maxDelay,
233
+ startedAt: Date.now()
234
+ };
235
+ pending.fallbackTimer = setTimeout(() => {
236
+ if (lfPendingActions.has(pendingKey)) {
237
+ finishMomentaryBatch(configId, panelId, btn, pendingKey);
238
+ }
239
+ }, maxDelay + BATCH_FALLBACK_MARGIN_MS);
240
+
241
+ lfPendingActions.set(pendingKey, pending);
242
+ return true;
243
+ }
244
+
245
+ function ensureLfButtonActionBatch(serialPortConfigId, switchId, buttonNumber) {
246
+ const pendingKey = getPendingKey(serialPortConfigId, switchId, buttonNumber);
247
+ if (lfPendingActions.has(pendingKey)) {
248
+ return true;
249
+ }
250
+ return beginLfButtonActionBatch(serialPortConfigId, switchId, buttonNumber);
251
+ }
252
+
253
+ function onLfRelayActionComplete(node) {
254
+ if (!node || node.switchBrand !== "lf" || node.lfLedFollowState === true) {
255
+ return;
256
+ }
257
+ const configId = node.serialPortConfig || node.id || "global";
258
+ const btn = parseInt(node.buttonNumber, 10);
259
+ const pendingKey = getPendingKey(configId, node.switchId, btn);
260
+ const pending = lfPendingActions.get(pendingKey);
261
+ if (!pending) {
262
+ return;
263
+ }
264
+
265
+ pending.completed += 1;
266
+ if (pending.completed < pending.expected) {
267
+ return;
268
+ }
269
+
270
+ finishMomentaryBatch(configId, node.switchId, btn, pendingKey);
271
+ }
272
+
273
+ function computeLfPanelLedMask(serialPortConfigId, switchId, lfProtocol, lfPanelNodes) {
274
+ // 睡眠模式优先:指示灯全灭(0x0000,连背光都灭),任意按键退出后自动恢复
275
+ if (lfSleepModePanels.has(getPanelKey(serialPortConfigId, switchId))) {
276
+ return 0;
277
+ }
278
+
279
+ let keyMask = 0;
280
+ const panelKey = getPanelKey(serialPortConfigId, switchId);
281
+
282
+ const collectFromSlave = function(n) {
283
+ if (!n || !n.config || n.config.switchBrand !== "lf") {
284
+ return;
285
+ }
286
+ if (parseInt(n.config.switchId, 10) !== parseInt(switchId, 10)) {
287
+ return;
288
+ }
289
+ if (!n.serialPortConfig || n.serialPortConfig.id !== serialPortConfigId) {
290
+ return;
291
+ }
292
+ if (n.config.syncLight === false) {
293
+ return;
294
+ }
295
+ if (n.config.buttonType === "scene") {
296
+ return;
297
+ }
298
+ if (n.currentState) {
299
+ keyMask |= lfProtocol.buttonToMask(n.config.buttonNumber);
300
+ }
301
+ };
302
+
303
+ const registered = lfPanelNodes.get(panelKey);
304
+ if (registered && registered.size > 0) {
305
+ registered.forEach(collectFromSlave);
306
+ }
307
+
308
+ const momentary = lfMomentaryButtons.get(panelKey);
309
+ if (momentary) {
310
+ momentary.forEach((btn) => {
311
+ keyMask |= lfProtocol.buttonToMask(btn);
312
+ });
313
+ }
314
+
315
+ const relaySet = lfRelayOutputs.get(panelKey);
316
+ if (relaySet) {
317
+ const followByButton = new Map();
318
+ relaySet.forEach((ro) => {
319
+ if (ro.lfLedFollowState !== true) {
320
+ return;
321
+ }
322
+ const btn = parseInt(ro.buttonNumber, 10);
323
+ const lit = ro.currentCoilState === true;
324
+ if (!followByButton.has(btn)) {
325
+ followByButton.set(btn, false);
326
+ }
327
+ if (lit) {
328
+ followByButton.set(btn, true);
329
+ }
330
+ });
331
+ followByButton.forEach((lit, btn) => {
332
+ if (lit) {
333
+ keyMask |= lfProtocol.buttonToMask(btn);
334
+ }
335
+ });
336
+ }
337
+
338
+ if (keyMask === 0) {
339
+ return lfProtocol.LED_STANDBY_MASK;
340
+ }
341
+ return lfProtocol.finalizeLedMask(keyMask, true);
342
+ }
343
+
344
+ module.exports = {
345
+ PANEL_LED_DEBOUNCE_MS,
346
+ PANEL_LED_DEDUPE_MS,
347
+ setLfLedSyncCallback,
348
+ scheduleLfPanelLedSync,
349
+ requestLfPanelLedSync,
350
+ registerLfRelayOutput,
351
+ unregisterLfRelayOutput,
352
+ beginLfButtonActionBatch,
353
+ ensureLfButtonActionBatch,
354
+ onLfRelayActionComplete,
355
+ hasActiveMomentaryBatch,
356
+ isMomentaryBatchActive,
357
+ hasMomentaryRelayOutputs,
358
+ getMomentaryOutputs,
359
+ computeLfPanelLedMask,
360
+ getPanelKey,
361
+ setPanelSleepMode,
362
+ isPanelSleepMode,
363
+ clearAllSleepMode
364
+ };
@@ -34,6 +34,8 @@ module.exports = {
34
34
 
35
35
  // 0x1008 变背光类产品需 BIT8 才允许 LED 显示
36
36
  LED_BACKLIGHT_ENABLE: 0x0100,
37
+ // 待机指示灯:仅背光 BIT8,继电器全关时的默认外观(非全灭)
38
+ LED_STANDBY_MASK: 0x0100,
37
39
 
38
40
  /**
39
41
  * Modbus CRC16(低字节在前)
@@ -184,6 +186,14 @@ module.exports = {
184
186
  return this.buildLedMaskCommand(panelAddr, 0x0000);
185
187
  },
186
188
 
189
+ /**
190
+ * 待机指示灯(继电器全关默认态):仅 BIT8 背光,按键位全 0
191
+ * 例:01 06 10 08 01 00 0D 58
192
+ */
193
+ buildStandbyLedCommand: function(panelAddr) {
194
+ return this.buildLedMaskCommand(panelAddr, this.LED_STANDBY_MASK);
195
+ },
196
+
187
197
  /**
188
198
  * 聚合按键位掩码并应用变背光 BIT8(PDF:变背光产品 BIT8=1 才显示 LED)
189
199
  * @param {number} buttonMask BIT0~7 按键状态
@@ -12,6 +12,7 @@ module.exports = function(RED) {
12
12
  const serialUtils = require("../lib/serial-utils");
13
13
  const httpAdmin = require("../lib/http-admin-utils");
14
14
  const { createSlaveSwitchCaches } = require("../lib/slave-switch-cache");
15
+ const lfLedRegistry = require("../lib/lf-led-registry");
15
16
 
16
17
  const {
17
18
  globalDebounceCache,
@@ -31,6 +32,54 @@ module.exports = function(RED) {
31
32
  const lfPanelLastSentMask = new Map();
32
33
  // LF 面板 RCU 模式已配置标记(PDF:0x1003 BIT1=1 才能由主机控制 0x1008 指示灯)
33
34
  const lfRcuConfiguredPanels = new Set();
35
+ // LF 面板运行时节点注册表(避免 RED.nodes.eachNode 找不到带 currentState 的实例)
36
+ const lfPanelNodes = new Map();
37
+
38
+ function getLfPanelKey(serialPortConfigId, switchId) {
39
+ return `${serialPortConfigId}:${parseInt(switchId, 10)}`;
40
+ }
41
+
42
+ function registerLfPanelNode(node) {
43
+ if (!node || !node.config || node.config.switchBrand !== "lf") {
44
+ return;
45
+ }
46
+ const configId = (node.serialPortConfig && node.serialPortConfig.id) || node.id;
47
+ const key = getLfPanelKey(configId, node.config.switchId);
48
+ if (!lfPanelNodes.has(key)) {
49
+ lfPanelNodes.set(key, new Set());
50
+ }
51
+ lfPanelNodes.get(key).add(node);
52
+ }
53
+
54
+ function unregisterLfPanelNode(node) {
55
+ if (!node || !node.config || node.config.switchBrand !== "lf") {
56
+ return;
57
+ }
58
+ const configId = (node.serialPortConfig && node.serialPortConfig.id) || node.id;
59
+ const key = getLfPanelKey(configId, node.config.switchId);
60
+ const nodes = lfPanelNodes.get(key);
61
+ if (!nodes) {
62
+ return;
63
+ }
64
+ nodes.delete(node);
65
+ if (nodes.size === 0) {
66
+ lfPanelNodes.delete(key);
67
+ }
68
+ }
69
+
70
+ function isLfSerialReady(serialPortConfig) {
71
+ if (!serialPortConfig) {
72
+ return false;
73
+ }
74
+ const conn = serialPortConfig.connection;
75
+ if (!conn) {
76
+ return false;
77
+ }
78
+ if (serialPortConfig.connectionType === "tcp") {
79
+ return !conn.destroyed;
80
+ }
81
+ return conn.isOpen === true;
82
+ }
34
83
 
35
84
  function ensureLfRcuMode(node, panelId) {
36
85
  if (!node.serialPortConfig || typeof node.serialPortConfig.write !== "function") {
@@ -60,30 +109,35 @@ module.exports = function(RED) {
60
109
  }
61
110
 
62
111
  function computeLfPanelLedMask(serialPortConfigId, switchId) {
63
- let mask = 0;
64
- RED.nodes.eachNode(function(n) {
65
- if (n.type !== "modbus-slave-switch") {
66
- return;
67
- }
68
- if (n.config.switchBrand !== "lf") {
69
- return;
70
- }
71
- if (parseInt(n.config.switchId, 10) !== parseInt(switchId, 10)) {
72
- return;
73
- }
74
- if (!n.serialPortConfig || n.serialPortConfig.id !== serialPortConfigId) {
75
- return;
76
- }
77
- if (n.config.syncLight === false) {
78
- return;
79
- }
80
- if (n.currentState) {
81
- mask |= lfProtocol.buttonToMask(n.config.buttonNumber);
112
+ return lfLedRegistry.computeLfPanelLedMask(
113
+ serialPortConfigId,
114
+ switchId,
115
+ lfProtocol,
116
+ lfPanelNodes
117
+ );
118
+ }
119
+
120
+ function findLfPanelRepresentativeNode(serialPortConfigId, switchId) {
121
+ const panelKey = getLfPanelKey(serialPortConfigId, switchId);
122
+ const registered = lfPanelNodes.get(panelKey);
123
+ if (!registered || registered.size === 0) {
124
+ return null;
125
+ }
126
+ for (const n of registered) {
127
+ if (n && n.serialPortConfig && isLfSerialReady(n.serialPortConfig)) {
128
+ return n;
82
129
  }
83
- });
84
- return lfProtocol.finalizeLedMask(mask, true);
130
+ }
131
+ return null;
85
132
  }
86
133
 
134
+ lfLedRegistry.setLfLedSyncCallback(function(serialPortConfigId, switchId) {
135
+ const rep = findLfPanelRepresentativeNode(serialPortConfigId, switchId);
136
+ if (rep) {
137
+ sendLfPanelLedSync(rep);
138
+ }
139
+ });
140
+
87
141
  function updateLfRelayStateCache(node, state) {
88
142
  const relayStateKey = getScopedKey(node, `relay-state-${node.config.targetSlaveAddress}-${node.config.targetCoilNumber}`);
89
143
  if (!globalButtonRelayStates.has(relayStateKey)) {
@@ -95,71 +149,77 @@ module.exports = function(RED) {
95
149
  /**
96
150
  * LF 面板 LED 同步:聚合同面板全部按键状态,只发一条 0x1008 写寄存器帧
97
151
  */
98
- function sendLfPanelLedSync(callerNode, isTriggerSource) {
99
- if (!callerNode || !callerNode.config || callerNode.config.switchBrand !== "lf") {
100
- return false;
101
- }
102
- if (callerNode.config.syncLight === false) {
103
- return false;
104
- }
105
- if (!callerNode.serialPortConfig || !callerNode.serialPortConfig.connection) {
106
- if (!callerNode.isInitializing && !callerNode.isClosing) {
107
- callerNode.log("RS-485连接未建立,无法发送LF指示灯反馈");
152
+ function sendLfPanelLedSync(callerNode) {
153
+ try {
154
+ if (!callerNode || !callerNode.config || callerNode.config.switchBrand !== "lf") {
155
+ return false;
156
+ }
157
+ if (!isLfSerialReady(callerNode.serialPortConfig)) {
158
+ if (!callerNode.isInitializing && !callerNode.isClosing) {
159
+ callerNode.log("RS-485连接未建立,无法发送LF指示灯反馈");
160
+ }
161
+ return false;
108
162
  }
109
- return false;
110
- }
111
163
 
112
- const panelId = parseInt(callerNode.config.switchId, 10);
113
- const configId = callerNode.serialPortConfig.id || callerNode.id;
114
- const ledKey = `${configId}:${panelId}`;
164
+ const panelId = parseInt(callerNode.config.switchId, 10);
165
+ const configId = callerNode.serialPortConfig.id || callerNode.id;
166
+ const ledKey = `${configId}:${panelId}`;
115
167
 
116
- ensureLfRcuMode(callerNode, panelId);
168
+ ensureLfRcuMode(callerNode, panelId);
117
169
 
118
- const mask = computeLfPanelLedMask(configId, panelId);
119
- const now = Date.now();
120
- const lastSent = lfPanelLastSentMask.get(ledKey);
121
- if (!isTriggerSource && lastSent && lastSent.mask === mask && (now - lastSent.timestamp) < 50) {
122
- callerNode.debug(`[LF LED] 跳过重复 面板${panelId} 掩码=0x${mask.toString(16).padStart(4, "0")}`);
123
- return false;
124
- }
170
+ const mask = computeLfPanelLedMask(configId, panelId);
171
+ const now = Date.now();
172
+ const lastSent = lfPanelLastSentMask.get(ledKey);
173
+ if (lastSent && lastSent.mask === mask && (now - lastSent.timestamp) < lfLedRegistry.PANEL_LED_DEDUPE_MS) {
174
+ callerNode.debug(`[LF LED] 跳过重复 面板${panelId} 掩码=0x${mask.toString(16).padStart(4, "0")}`);
175
+ return false;
176
+ }
125
177
 
126
- const command = mask === 0
127
- ? lfProtocol.buildTurnOffAllLedsCommand(panelId)
128
- : lfProtocol.buildLedMaskCommand(panelId, mask);
178
+ const command = mask === lfProtocol.LED_STANDBY_MASK
179
+ ? lfProtocol.buildStandbyLedCommand(panelId)
180
+ : (mask === 0
181
+ ? lfProtocol.buildTurnOffAllLedsCommand(panelId)
182
+ : lfProtocol.buildLedMaskCommand(panelId, mask));
129
183
 
130
- lfPanelLastSentMask.set(ledKey, { mask, timestamp: now });
131
- lfPanelLedMask.set(ledKey, mask);
184
+ lfPanelLastSentMask.set(ledKey, { mask, timestamp: now });
185
+ lfPanelLedMask.set(ledKey, mask);
132
186
 
133
- RED.nodes.eachNode(function(n) {
134
- if (n.type !== "modbus-slave-switch") {
135
- return;
136
- }
137
- if (n.config.switchBrand !== "lf") {
138
- return;
139
- }
140
- if (parseInt(n.config.switchId, 10) !== panelId) {
141
- return;
142
- }
143
- if (!n.serialPortConfig || n.serialPortConfig.id !== configId) {
144
- return;
145
- }
146
- n.lastSentLedState.value = n.currentState;
147
- n.lastSentLedState.timestamp = now;
148
- });
187
+ RED.nodes.eachNode(function(n) {
188
+ if (!n || !n.config || n.type !== "modbus-slave-switch") {
189
+ return;
190
+ }
191
+ if (n.config.switchBrand !== "lf") {
192
+ return;
193
+ }
194
+ if (parseInt(n.config.switchId, 10) !== panelId) {
195
+ return;
196
+ }
197
+ if (!n.serialPortConfig || n.serialPortConfig.id !== configId) {
198
+ return;
199
+ }
200
+ n.lastSentLedState.value = n.currentState;
201
+ n.lastSentLedState.timestamp = now;
202
+ });
149
203
 
150
- if (typeof callerNode.serialPortConfig.setFeedbackLock === "function") {
151
- callerNode.serialPortConfig.setFeedbackLock(150);
152
- }
204
+ if (typeof callerNode.serialPortConfig.setFeedbackLock === "function") {
205
+ callerNode.serialPortConfig.setFeedbackLock(150);
206
+ }
153
207
 
154
- const hexStr = command.toString("hex").toUpperCase().match(/.{1,2}/g).join(" ");
155
- callerNode.debug(`[LF LED] 面板${panelId} 聚合掩码=0x${mask.toString(16).padStart(4, "0")} 单帧=[${hexStr}]`);
208
+ const hexStr = command.toString("hex").toUpperCase().match(/.{1,2}/g).join(" ");
209
+ callerNode.log(`[LF LED] 面板${panelId} 聚合掩码=0x${mask.toString(16).padStart(4, "0")} 帧=[${hexStr}]`);
156
210
 
157
- callerNode.serialPortConfig.write(command, (err) => {
158
- if (err) {
159
- callerNode.log(`LF LED反馈失败: ${err.message}`);
211
+ callerNode.serialPortConfig.write(command, (err) => {
212
+ if (err) {
213
+ callerNode.log(`LF LED反馈失败: ${err.message}`);
214
+ }
215
+ }, 3, panelId);
216
+ return true;
217
+ } catch (err) {
218
+ if (callerNode && typeof callerNode.log === "function") {
219
+ callerNode.log(`[LF LED] 同步异常: ${err.message}`);
160
220
  }
161
- }, 3, panelId);
162
- return true;
221
+ return false;
222
+ }
163
223
  }
164
224
  // 初始化Mesh设备持久化存储
165
225
  const meshPersistDir = path.join(RED.settings.userDir || os.homedir() + "/.node-red", "mesh-devices-persist");
@@ -722,6 +782,7 @@ module.exports = function(RED) {
722
782
  !isInit &&
723
783
  (
724
784
  stateChanged ||
785
+ (node.config.switchBrand === "lf" && isButtonPress) ||
725
786
  (
726
787
  isRelayControl &&
727
788
  (
@@ -749,6 +810,22 @@ module.exports = function(RED) {
749
810
  }
750
811
 
751
812
  if (shouldSyncLed) {
813
+ // LF:统一走面板级调度,批次进行中抑制继电器侧中间帧
814
+ if (node.config.switchBrand === "lf") {
815
+ const configId = node.serialPortConfig ? node.serialPortConfig.id : null;
816
+ const panelId = node.config.switchId;
817
+ if (isRelayControl && configId && lfLedRegistry.hasActiveMomentaryBatch(configId, panelId)) {
818
+ node.debug(`[LF LED] 批次进行中,跳过继电器中间同步 面板${panelId}`);
819
+ return;
820
+ }
821
+ if (configId) {
822
+ lfLedRegistry.scheduleLfPanelLedSync(configId, panelId, {
823
+ debounceMs: lfLedRegistry.PANEL_LED_DEBOUNCE_MS
824
+ });
825
+ }
826
+ return;
827
+ }
828
+
752
829
  node.debug(`[主站状态变化] 准备同步LED: 从站=${slave} 线圈=${coil} 值=${value ? "ON" : "OFF"} 触发源=${triggerSource}`);
753
830
  // 核心逻辑:状态发生变化时,启动 LED 同步防抖队列
754
831
  // 防抖机制(200ms)确保在全开/全关等批量场景下,能够收集完整状态并平滑同步
@@ -934,7 +1011,7 @@ module.exports = function(RED) {
934
1011
 
935
1012
  // LF:聚合同面板全部按键,只发一条 0x1008
936
1013
  if (representativeNode.config.switchBrand === "lf") {
937
- sendLfPanelLedSync(representativeNode, false);
1014
+ sendLfPanelLedSync(representativeNode);
938
1015
  return;
939
1016
  }
940
1017
 
@@ -1609,8 +1686,16 @@ module.exports = function(RED) {
1609
1686
  }
1610
1687
 
1611
1688
  const commandDebounceKey = getScopedKey(node, `lf-cmd-${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}-${node.config.targetCoilNumber}`);
1612
- const ledDebounceKey = getScopedKey(node, `lf-panel-led-${node.config.switchId}`);
1613
1689
  const now = Date.now();
1690
+ const isSceneMode = node.config.buttonType === "scene";
1691
+ const configId = node.serialPortConfig ? node.serialPortConfig.id : null;
1692
+ const isSceneKey = isSceneMode || (
1693
+ configId && lfLedRegistry.hasMomentaryRelayOutputs(
1694
+ configId,
1695
+ node.config.switchId,
1696
+ node.config.buttonNumber
1697
+ )
1698
+ );
1614
1699
 
1615
1700
  const lastCommandTime = globalDebounceCache.get(commandDebounceKey) || 0;
1616
1701
  if (now - lastCommandTime < 500) {
@@ -1618,39 +1703,50 @@ module.exports = function(RED) {
1618
1703
  }
1619
1704
  globalDebounceCache.set(commandDebounceKey, now);
1620
1705
 
1621
- const lastLedTime = globalDebounceCache.get(ledDebounceKey) || 0;
1622
- const shouldSendLed = (now - lastLedTime >= 300);
1623
- if (shouldSendLed) {
1624
- globalDebounceCache.set(ledDebounceKey, now);
1625
- }
1626
-
1627
1706
  if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === "function") {
1628
1707
  node.serialPortConfig.setTriggerSource(node.config.switchId);
1629
1708
  }
1630
1709
 
1631
- node.debug(`[LF按键] 面板${node.config.switchId} 按键${node.config.buttonNumber} 按下`);
1710
+ // 退出睡眠模式:任意按键触发即退出,恢复正常显示
1711
+ if (configId && lfLedRegistry.isPanelSleepMode(configId, node.config.switchId)) {
1712
+ lfLedRegistry.setPanelSleepMode(configId, node.config.switchId, false);
1713
+ node.log(`[LF 睡眠] 面板${node.config.switchId} 退出睡眠模式(按键${node.config.buttonNumber}触发)`);
1714
+ lfLedRegistry.scheduleLfPanelLedSync(configId, node.config.switchId, {
1715
+ debounceMs: lfLedRegistry.PANEL_LED_DEBOUNCE_MS
1716
+ });
1717
+ }
1718
+
1719
+ node.debug(`[LF按键] 面板${node.config.switchId} 按键${node.config.buttonNumber} 按下 mode=${isSceneKey ? "scene" : "switch"}`);
1632
1720
 
1633
- node.currentState = !node.currentState;
1721
+ if (!isSceneKey) {
1722
+ node.currentState = !node.currentState;
1723
+ updateLfRelayStateCache(node, node.currentState);
1724
+ node.sendMqttCommand(node.currentState);
1725
+ if (configId) {
1726
+ lfLedRegistry.scheduleLfPanelLedSync(configId, node.config.switchId, {
1727
+ debounceMs: lfLedRegistry.PANEL_LED_DEBOUNCE_MS
1728
+ });
1729
+ }
1730
+ }
1634
1731
 
1635
- updateLfRelayStateCache(node, node.currentState);
1732
+ if (isSceneKey && configId) {
1733
+ lfLedRegistry.beginLfButtonActionBatch(
1734
+ configId,
1735
+ node.config.switchId,
1736
+ node.config.buttonNumber
1737
+ );
1738
+ }
1636
1739
 
1637
1740
  RED.events.emit("modbus:buttonPressed", {
1638
1741
  switchId: node.config.switchId,
1639
1742
  button: node.config.buttonNumber,
1640
- value: node.currentState,
1743
+ value: isSceneKey ? true : node.currentState,
1641
1744
  brand: "lf",
1642
- type: "scene",
1643
- serialPortConfigId: node.serialPortConfig ? node.serialPortConfig.id : null
1745
+ type: "button",
1746
+ isSceneMode: false,
1747
+ serialPortConfigId: configId
1644
1748
  });
1645
1749
 
1646
- node.sendMqttCommand(node.currentState);
1647
-
1648
- if (shouldSendLed) {
1649
- setTimeout(() => {
1650
- sendLfPanelLedSync(node, true);
1651
- }, 200);
1652
- }
1653
-
1654
1750
  node.send({
1655
1751
  payload: node.currentState,
1656
1752
  topic: `lf_switch_${node.config.switchId}_btn${node.config.buttonNumber}`,
@@ -2225,7 +2321,13 @@ module.exports = function(RED) {
2225
2321
  const hexStr = command.toString("hex").toUpperCase().match(/.{1,2}/g).join(" ");
2226
2322
  node.debug(`[Clowire LED] 控制帧已构建:[${hexStr}]`);
2227
2323
  } else if (node.config.switchBrand === "lf") {
2228
- sendLfPanelLedSync(node, isTriggerSource);
2324
+ const configId = node.serialPortConfig ? node.serialPortConfig.id : null;
2325
+ if (configId) {
2326
+ lfLedRegistry.scheduleLfPanelLedSync(configId, node.config.switchId, {
2327
+ immediate: isTriggerSource === true,
2328
+ debounceMs: lfLedRegistry.PANEL_LED_DEBOUNCE_MS
2329
+ });
2330
+ }
2229
2331
  return;
2230
2332
  } else {
2231
2333
  // RS-485模式:使用轻量级协议
@@ -2712,7 +2814,12 @@ module.exports = function(RED) {
2712
2814
  // 本地输入同步面板指示灯(无需 Modbus 主站在线)
2713
2815
  if (node.config.syncLight && node.config.buttonType !== "mesh") {
2714
2816
  if (node.config.switchBrand === "lf") {
2715
- sendLfPanelLedSync(node, false);
2817
+ const configId = node.serialPortConfig ? node.serialPortConfig.id : null;
2818
+ if (configId) {
2819
+ lfLedRegistry.scheduleLfPanelLedSync(configId, node.config.switchId, {
2820
+ debounceMs: lfLedRegistry.PANEL_LED_DEBOUNCE_MS
2821
+ });
2822
+ }
2716
2823
  } else {
2717
2824
  node.sendCommandToPanel(value, false);
2718
2825
  }
@@ -2726,6 +2833,10 @@ module.exports = function(RED) {
2726
2833
  node.on("close", function(done) {
2727
2834
  node.isClosing = true;
2728
2835
 
2836
+ if (node.config && node.config.switchBrand === "lf") {
2837
+ unregisterLfPanelNode(node);
2838
+ }
2839
+
2729
2840
  // 清除重连定时器
2730
2841
  if (node.reconnectTimer) {
2731
2842
  clearTimeout(node.reconnectTimer);
@@ -2829,6 +2940,10 @@ module.exports = function(RED) {
2829
2940
  node.log(`[无线模式] 注册按键:设备${meshAddr} 按键${node.config.meshButtonNumber}`);
2830
2941
  }
2831
2942
 
2943
+ if (node.config.switchBrand === "lf") {
2944
+ registerLfPanelNode(node);
2945
+ }
2946
+
2832
2947
  node.updateStatus();
2833
2948
  node.connectRs485(); // 连接RS-485总线
2834
2949
  node.connectMqtt(); // 连接MQTT
@@ -24,7 +24,9 @@
24
24
  // 门禁ID过滤(可选)
25
25
  filterDeviceId: { value: 0, validate: RED.validators.number() },
26
26
  // 跨网关控制
27
- ignoreGatewayId: { value: false }
27
+ ignoreGatewayId: { value: false },
28
+ lfLedFollowState: { value: false },
29
+ lfSleepMode: { value: false }
28
30
  },
29
31
  inputs: 1,
30
32
  outputs: 1,
@@ -87,8 +89,21 @@
87
89
  } else {
88
90
  $(".form-row-switch").show();
89
91
  }
92
+ updateLfLedFollowVisibility();
90
93
  }).trigger("change");
91
94
 
95
+ function updateLfLedFollowVisibility() {
96
+ const isLf = $("#node-input-switchBrand").val() === "lf";
97
+ const isInput = $("#node-input-buttonType").val() === "input";
98
+ if (isLf && !isInput) {
99
+ $(".form-row-lf-led-follow").show();
100
+ } else {
101
+ $(".form-row-lf-led-follow").hide();
102
+ }
103
+ }
104
+
105
+ $("#node-input-switchBrand").on("change", updateLfLedFollowVisibility).trigger("change");
106
+
92
107
  // 按钮编号切换时显示/隐藏背光灯提示
93
108
  $("#node-input-buttonNumber").on("change", function() {
94
109
  const buttonNumber = $(this).val();
@@ -147,19 +162,13 @@
147
162
  <label style="width: 100%; margin-bottom: 8px;">
148
163
  <i class="fa fa-plug" style="color: #ff9800;"></i>
149
164
  <span style="font-size: 14px; font-weight: 600; color: #333;">RS-485连接配置</span>
165
+ <span style="font-size: 11px; color: #999; margin-left: 10px;">多个节点可共享同一配置,无需连线到主站</span>
150
166
  </label>
151
- <div style="font-size: 11px; color: #555; padding: 10px 12px; background: linear-gradient(135deg, #fff3cd 0%, #fffbe6 100%); border-left: 4px solid #ffc107; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
152
- <strong>说明:</strong>多个继电器输出节点可以共享同一个RS-485连接配置(支持TCP网关或串口)<br>
153
- <strong>提示:</strong>此配置用于监听开关按键事件,无需连线到主站节点
154
- </div>
155
167
  </div>
156
168
 
157
169
  <div class="form-row">
158
170
  <label for="node-input-serialPortConfig" style="width: 110px;"><i class="fa fa-server"></i> 连接配置</label>
159
171
  <input type="text" id="node-input-serialPortConfig" placeholder="选择或添加RS-485连接配置" style="width: calc(70% - 110px);">
160
- <div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
161
- 选择已配置的RS-485连接(支持多个节点共享)
162
- </div>
163
172
  </div>
164
173
 
165
174
  <div class="form-row">
@@ -172,11 +181,11 @@
172
181
  <hr style="margin: 15px 0; border: 0; border-top: 2px solid #e0e0e0;">
173
182
  <div class="form-row">
174
183
  <label style="width: 100%; margin-bottom: 8px;">
175
- <i class="fa fa-hand-pointer-o" style="color: #3f51b5;"></i>
184
+ <i class="fa fa-hand-pointer-o" style="color: #3f51b5;"></i>
176
185
  <span style="font-size: 14px; font-weight: 600; color: #333;">按键触发配置</span>
177
186
  </label>
178
187
  </div>
179
-
188
+
180
189
  <div class="form-row">
181
190
  <label for="node-input-switchBrand" style="width: 110px;"><i class="fa fa-trademark"></i> 面板品牌</label>
182
191
  <select id="node-input-switchBrand" style="width: 200px;">
@@ -194,10 +203,17 @@
194
203
  <option value="scene">场景按钮(RS-485)</option>
195
204
  <option value="input">输入端触发(外部设备)</option>
196
205
  </select>
197
- <div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
198
- <strong>开关按钮</strong>:RS-485开关,有开/关状态<br>
199
- <strong>场景按钮</strong>:RS-485场景触发<br>
200
- <strong>输入端触发</strong>:海康门禁等外部设备
206
+ <span class="form-row-lf-led-follow" style="margin-left: 16px; white-space: nowrap;">
207
+ <input type="checkbox" id="node-input-lfLedFollowState" style="width: auto; vertical-align: middle;">
208
+ <label for="node-input-lfLedFollowState" style="width: auto; margin-left: 4px; font-weight: normal;">指示灯跟随状态</label>
209
+ </span>
210
+ <span class="form-row-lf-led-follow" style="margin-left: 16px; white-space: nowrap;">
211
+ <input type="checkbox" id="node-input-lfSleepMode" style="width: auto; vertical-align: middle;">
212
+ <label for="node-input-lfSleepMode" style="width: auto; margin-left: 4px; font-weight: normal;">睡眠模式</label>
213
+ </span>
214
+ <div class="form-row-lf-led-follow" style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
215
+ <strong>指示灯跟随</strong>:按键灯与绑定线圈同步;不勾选则瞬时亮灯,继电器动作完成后回待机背光。
216
+ <strong>睡眠模式</strong>:继电器动作后面板指示灯全灭,任意按键退出。
201
217
  </div>
202
218
  </div>
203
219
 
@@ -205,9 +221,6 @@
205
221
  <label for="node-input-switchId" style="width: 110px;"><i class="fa fa-id-card"></i> 开关ID</label>
206
222
  <input type="number" id="node-input-switchId" placeholder="0" min="0" max="255" style="width: 90px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;">
207
223
  <span style="margin-left: 10px; color: #666; font-size: 12px;">物理面板地址:<strong>0-255</strong></span>
208
- <div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
209
- RS-485总线上的设备地址标识
210
- </div>
211
224
  </div>
212
225
 
213
226
  <div class="form-row form-row-switch">
@@ -239,43 +252,35 @@
239
252
  <hr style="margin: 15px 0; border: 0; border-top: 2px solid #e0e0e0;">
240
253
  <div class="form-row">
241
254
  <label style="width: 100%; margin-bottom: 8px;">
242
- <i class="fa fa-arrow-right" style="color: #4caf50;"></i>
255
+ <i class="fa fa-arrow-right" style="color: #4caf50;"></i>
243
256
  <span style="font-size: 14px; font-weight: 600; color: #333;">目标继电器配置</span>
244
257
  </label>
245
258
  </div>
246
-
259
+
247
260
  <div class="form-row">
248
261
  <label for="node-input-masterNode" style="width: 110px;"><i class="fa fa-microchip"></i> 主站节点</label>
249
262
  <select id="node-input-masterNode" style="width: calc(70% - 110px);">
250
263
  <option value="">请选择主站节点</option>
251
264
  </select>
252
- <div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
253
- 选择要控制的目标Modbus主站节点
254
- </div>
255
265
  </div>
256
-
266
+
257
267
  <div class="form-row">
258
268
  <label for="node-input-slaveAddress" style="width: 110px;"><i class="fa fa-map-marker"></i> 从站地址</label>
259
269
  <input type="number" id="node-input-slaveAddress" placeholder="10" min="1" max="247" style="width: 90px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;">
260
270
  <span style="margin-left: 10px; color: #666; font-size: 12px;">Modbus继电器:<strong>10-19</strong></span>
261
- <div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
262
- 主站节点中配置的从站设备地址
263
- </div>
264
271
  </div>
265
-
272
+
266
273
  <div class="form-row">
267
274
  <label for="node-input-coilNumber" style="width: 110px;"><i class="fa fa-plug"></i> 继电器路数</label>
268
275
  <input type="number" id="node-input-coilNumber" placeholder="1" min="1" max="32" style="width: 90px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;">
269
- <div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
270
- 继电器1-32路,对应线圈0-31,只需填写正确的继电器通道即可
271
- </div>
276
+ <span style="margin-left: 10px; color: #666; font-size: 12px;">1-32路,对应线圈0-31</span>
272
277
  </div>
273
278
 
274
279
  <!-- 动作配置 -->
275
280
  <hr style="margin: 15px 0; border: 0; border-top: 2px solid #e0e0e0;">
276
281
  <div class="form-row">
277
282
  <label style="width: 100%; margin-bottom: 8px;">
278
- <i class="fa fa-bolt" style="color: #ff5722;"></i>
283
+ <i class="fa fa-bolt" style="color: #ff5722;"></i>
279
284
  <span style="font-size: 14px; font-weight: 600; color: #333;">动作配置</span>
280
285
  </label>
281
286
  </div>
@@ -295,16 +300,13 @@
295
300
  <input type="number" id="node-input-delayMs" min="0" max="60000" style="width: 120px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px;">
296
301
  <span style="margin-left: 5px;">ms</span>
297
302
  <span id="delay-hint" style="margin-left: 10px; color: #f57c00; font-size: 11px;"></span>
298
- <div style="font-size: 11px; color: #888; margin-left: 110px; margin-top: 3px;">
299
- 执行延时(0-60000毫秒),用于实现级联开灯效果
300
- </div>
301
303
  </div>
302
304
 
303
305
  <div class="form-row">
304
306
  <input type="checkbox" id="node-input-ignoreRelease" style="width: auto; margin-left: 110px;">
305
307
  <label for="node-input-ignoreRelease" style="width: auto;">忽略释放信号(只响应按下)</label>
306
308
  </div>
307
-
309
+
308
310
  <!-- 配置摘要 -->
309
311
  <div class="form-row" style="margin-top: 20px; padding: 14px; background: linear-gradient(135deg, #e8f5e9 0%, #f1f8f4 100%); border-left: 4px solid #4caf50; border-radius: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.08);">
310
312
  <div style="font-size: 12px; color: #333; line-height: 1.8;">
@@ -313,9 +315,6 @@
313
315
  </div>
314
316
  <div id="config-summary" style="background: white; padding: 8px; border-radius: 4px; border: 1px solid #c8e6c9; font-size: 12px; color: #555;">
315
317
  </div>
316
- <div style="margin-top: 10px; font-size: 11px; color: #666;">
317
- <strong>工作原理:</strong>监听按键事件,通过内部事件发送控制命令到主站,<strong>无需连线</strong>
318
- </div>
319
318
  </div>
320
319
  </div>
321
320
  </div>
@@ -334,7 +333,7 @@
334
333
  <h3>按键触发配置</h3>
335
334
  <dl class="message-properties">
336
335
  <dt>面板品牌 <span class="property-type">string</span></dt>
337
- <dd>开关面板品牌,目前支持亖米(Symi)和 Clowire(克伦威尔)</dd>
336
+ <dd>开关面板品牌,支持亖米(Symi)、Clowire(克伦威尔)、LF(拉斐)</dd>
338
337
 
339
338
  <dt>按钮类型 <span class="property-type">string</span></dt>
340
339
  <dd>开关按钮/场景按钮/输入端触发</dd>
@@ -368,6 +367,12 @@
368
367
 
369
368
  <dt>忽略释放信号 <span class="property-type">boolean</span></dt>
370
369
  <dd>勾选后,只响应按下信号,忽略释放信号</dd>
370
+
371
+ <dt>指示灯跟随状态 <span class="property-type">boolean</span></dt>
372
+ <dd>仅 LF 品牌:勾选后按键指示灯与绑定的继电器线圈状态同步;不勾选则按键后瞬时亮灯,全部关联继电器动作完成后恢复待机背光</dd>
373
+
374
+ <dt>睡眠模式 <span class="property-type">boolean</span></dt>
375
+ <dd>仅 LF 品牌:勾选后本按键触发的继电器动作完成时,该面板全部指示灯熄灭(含背光);任意面板按键按下后自动退出睡眠,恢复正常显示</dd>
371
376
  </dl>
372
377
 
373
378
  <h3>使用场景</h3>
@@ -383,6 +388,13 @@
383
388
  <p><b>场景3:海康门禁触发(使用输入端)</b></p>
384
389
  <pre>海康门禁事件 → 继电器输出(按钮类型=输入端触发 目标从站10 1路)</pre>
385
390
 
391
+ <p><b>场景4:睡眠模式(LF 品牌)</b></p>
392
+ <p>将某个按键(如按键8)配置为全关场景并勾选 <b>睡眠模式</b>,按下后关闭全部继电器并熄灭面板所有指示灯(含背光);任意面板按键按下后自动退出睡眠,恢复正常显示。</p>
393
+ <pre>开关ID=1 按钮=8 LF品牌 睡眠模式 → 从站10 1路 关闭
394
+ → 从站10 2路 关闭
395
+ → 从站10 3路 关闭
396
+ → 从站10 4路 关闭</pre>
397
+
386
398
  <h3>工作原理</h3>
387
399
  <p>节点监听 <code>modbus:buttonPressed</code> 事件(按键触发),
388
400
  通过 <code>modbus:writeCoil</code> 事件发送控制命令到主站。</p>
@@ -8,6 +8,7 @@
8
8
  module.exports = function(RED) {
9
9
  "use strict";
10
10
  const { ensureRedEventsMaxListeners } = require("../lib/red-events-utils");
11
+ const lfLedRegistry = require("../lib/lf-led-registry");
11
12
  ensureRedEventsMaxListeners(RED);
12
13
 
13
14
  function RelayOutputNode(config) {
@@ -35,6 +36,10 @@ module.exports = function(RED) {
35
36
  node.delayMs = parseInt(config.delayMs) || 0;
36
37
  node.ignoreRelease = config.ignoreRelease !== false; // 默认忽略释放信号
37
38
  node.ignoreGatewayId = config.ignoreGatewayId === true; // 是否忽略网关ID校验
39
+ // LF:指示灯是否跟随绑定的继电器线圈状态(默认不跟随=瞬时亮灯,全部继电器完成后回待机)
40
+ node.lfLedFollowState = config.lfLedFollowState === true;
41
+ // LF:睡眠模式(触发后该面板指示灯全灭,任意按键退出)
42
+ node.lfSleepMode = config.lfSleepMode === true;
38
43
 
39
44
  // 节点状态
40
45
  node.isClosing = false;
@@ -48,10 +53,11 @@ module.exports = function(RED) {
48
53
  node.initTimer = setTimeout(() => {
49
54
  node.initialized = true;
50
55
  const btnLabel = node.buttonNumber === 15 ? "背光灯" : `按键${node.buttonNumber}`;
51
- const triggerInfo = node.buttonType === "input"
52
- ? "← 输入端"
56
+ const triggerInfo = node.buttonType === "input"
57
+ ? "← 输入端"
53
58
  : `← 开关${node.switchId}-${btnLabel}`;
54
- node.status({ fill: "blue", shape: "ring", text: `就绪 ${triggerInfo} → 从站${node.slaveAddress}-${node.coilNumber}路` });
59
+ const sleepHint = (node.switchBrand === "lf" && node.lfSleepMode) ? " [睡眠]" : "";
60
+ node.status({ fill: "blue", shape: "ring", text: `就绪 ${triggerInfo} → 从站${node.slaveAddress}-${node.coilNumber}路${sleepHint}` });
55
61
  }, 2000);
56
62
 
57
63
  // 更新状态显示
@@ -106,7 +112,7 @@ module.exports = function(RED) {
106
112
  return;
107
113
  }
108
114
 
109
- // 执行控制
115
+ // 场景键批次由从站开关在派发事件前统一登记,relay-output 只负责写线圈
110
116
  const btnLabel = msgButtonNumber === 15 ? "背光灯" : `按键${msgButtonNumber}`;
111
117
  const triggerSource = (data.type === "scene" || data.isSceneMode) ? "scene-trigger" : "button-press";
112
118
  node.executeControl(triggerValue, `开关${msgSwitchId}-${btnLabel}`, triggerSource);
@@ -114,7 +120,14 @@ module.exports = function(RED) {
114
120
 
115
121
  RED.events.on("modbus:buttonPressed", node.stateChangeListener);
116
122
  const btnLabel = node.buttonNumber === 15 ? "背光灯" : `按键${node.buttonNumber}`;
117
- node.log(`绑定触发源: 开关${node.switchId} ${btnLabel}`);
123
+ const lfLedHint = (node.switchBrand === "lf")
124
+ ? (node.lfLedFollowState ? " LED跟随" : " LED瞬时") + (node.lfSleepMode ? " 睡眠模式" : "")
125
+ : "";
126
+ node.log(`绑定触发源: 开关${node.switchId} ${btnLabel}${lfLedHint}`);
127
+ }
128
+
129
+ if (node.switchBrand === "lf" && node.buttonType !== "input") {
130
+ lfLedRegistry.registerLfRelayOutput(node);
118
131
  }
119
132
 
120
133
  // 监听目标线圈状态变化(用于状态同步和实现 Toggle 逻辑)
@@ -132,6 +145,14 @@ module.exports = function(RED) {
132
145
  const actionText = node.currentCoilState ? "ON" : "OFF";
133
146
  node.updateStatus(`同步状态: ${actionText} ← 从站${node.slaveAddress}`, node.currentCoilState ? "green" : "grey");
134
147
  }
148
+
149
+ if (node.switchBrand === "lf" && node.lfLedFollowState && node.serialPortConfig) {
150
+ if (!lfLedRegistry.hasActiveMomentaryBatch(node.serialPortConfig, node.switchId)) {
151
+ lfLedRegistry.scheduleLfPanelLedSync(node.serialPortConfig, node.switchId, {
152
+ debounceMs: lfLedRegistry.PANEL_LED_DEBOUNCE_MS
153
+ });
154
+ }
155
+ }
135
156
  }
136
157
  };
137
158
  RED.events.on("modbus:coilStateChanged", node.coilStateListener);
@@ -171,6 +192,19 @@ module.exports = function(RED) {
171
192
  const sourceText = source ? ` (${source})` : "";
172
193
  node.updateStatus(`${actionText} → 从站${node.slaveAddress}线圈${node.coilNumber}${sourceText}`, value ? "green" : "grey");
173
194
  node.debug(`[联动执行] 触发写入: 从站=${node.slaveAddress}, 线圈=${node.coilNumber}, 值=${value}, 目标主站=${targetMasterId || "广播"}${sourceText}`);
195
+
196
+ if (node.switchBrand === "lf" && node.lfLedFollowState !== true) {
197
+ lfLedRegistry.onLfRelayActionComplete(node);
198
+ }
199
+
200
+ // LF 睡眠模式:本节点配置了睡眠模式,指令发送后激活该面板睡眠(指示灯全灭)
201
+ if (node.switchBrand === "lf" && node.lfSleepMode && node.serialPortConfig) {
202
+ lfLedRegistry.setPanelSleepMode(node.serialPortConfig, node.switchId, true);
203
+ lfLedRegistry.scheduleLfPanelLedSync(node.serialPortConfig, node.switchId, {
204
+ debounceMs: lfLedRegistry.PANEL_LED_DEBOUNCE_MS
205
+ });
206
+ node.log(`[LF 睡眠] 面板${node.switchId} 进入睡眠模式(按键${node.buttonNumber}触发,指示灯全灭)`);
207
+ }
174
208
  };
175
209
 
176
210
  // 执行控制逻辑
@@ -316,6 +350,10 @@ module.exports = function(RED) {
316
350
  node.coilStateListener = null;
317
351
  }
318
352
 
353
+ if (node.switchBrand === "lf") {
354
+ lfLedRegistry.unregisterLfRelayOutput(node);
355
+ }
356
+
319
357
  if (removed) {
320
358
  // 节点被删除时的额外清理(如果有)
321
359
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.10.27",
3
+ "version": "2.10.29",
4
4
  "description": "Node-RED Modbus节点,支持TCP/串口通信、多主站独立运行、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步(支持Symi/Clowire/LF拉斐品牌),工控机长期稳定运行",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {