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 +12 -6
- package/lib/lf-led-registry.js +364 -0
- package/nodes/lf-protocol.js +10 -0
- package/nodes/modbus-slave-switch.js +214 -99
- package/nodes/relay-output.html +51 -39
- package/nodes/relay-output.js +43 -5
- package/package.json +1 -1
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
|
-
-
|
|
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.
|
|
529
|
+
#### v2.10.29 (2026-07-09)
|
|
526
530
|
|
|
527
|
-
- **[LF485 开关]**
|
|
528
|
-
- **[LF485
|
|
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
|
-
- **[
|
|
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
|
+
};
|
package/nodes/lf-protocol.js
CHANGED
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
164
|
+
const panelId = parseInt(callerNode.config.switchId, 10);
|
|
165
|
+
const configId = callerNode.serialPortConfig.id || callerNode.id;
|
|
166
|
+
const ledKey = `${configId}:${panelId}`;
|
|
115
167
|
|
|
116
|
-
|
|
168
|
+
ensureLfRcuMode(callerNode, panelId);
|
|
117
169
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
184
|
+
lfPanelLastSentMask.set(ledKey, { mask, timestamp: now });
|
|
185
|
+
lfPanelLedMask.set(ledKey, mask);
|
|
132
186
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
204
|
+
if (typeof callerNode.serialPortConfig.setFeedbackLock === "function") {
|
|
205
|
+
callerNode.serialPortConfig.setFeedbackLock(150);
|
|
206
|
+
}
|
|
153
207
|
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
1643
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/nodes/relay-output.html
CHANGED
|
@@ -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
|
-
<
|
|
198
|
-
<
|
|
199
|
-
<
|
|
200
|
-
|
|
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
|
-
<
|
|
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
|
|
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>
|
package/nodes/relay-output.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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": {
|