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

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
@@ -30,7 +30,7 @@ Node-RED的Modbus继电器控制节点,支持TCP/串口通信和MQTT集成。
30
30
  | 节点名称 | 功能描述 |
31
31
  | :--- | :--- |
32
32
  | **Modbus主站** | 核心调度中心,负责协议解析、多设备轮询和状态管理。 |
33
- | **从站开关** | 监听物理面板(Symi/Clowire)按键事件并联动继电器,支持LED同步。 |
33
+ | **从站开关** | 监听物理面板(Symi/Clowire/LF拉斐)按键事件并联动继电器,支持LED同步。 |
34
34
  | **HomeKit网桥** | 一键将所有 Modbus 继电器接入 Apple HomeKit,支持自定义名称。 |
35
35
  | **控制看板** | 在 Node-RED 编辑器内直接控制和查看所有继电器的实时状态。 |
36
36
  | **Mesh透传** | 适配 Symi 蓝牙 Mesh 网关,支持窗帘、杜亚电机及各种子设备。 |
@@ -117,7 +117,7 @@ node-red-restart
117
117
  1. 拖拽 **从站开关** 节点到流程画布
118
118
  2. 选择刚创建的RS-485连接配置
119
119
  3. 配置开关面板信息:
120
- - 面板品牌: `Symi` / `Clowire`(克伦威尔)
120
+ - 面板品牌: `Symi` / `Clowire`(克伦威尔)/ `LF`(拉斐)
121
121
  - 开关ID: 物理面板地址 (0-255)
122
122
  - 按钮编号: 按键编号 (1-8)
123
123
  - 按钮类型:
@@ -128,6 +128,41 @@ node-red-restart
128
128
  - 目标线圈编号: `0`
129
129
  5. 部署流程
130
130
 
131
+ #### LF(拉斐)485 弱电开关
132
+
133
+ - **串口**:9600 8N1,指令间隔 ≥50ms(由 `serial-port-config` 写入队列保证)
134
+ - **面板地址**:1–42(出厂默认 2),开关 ID 填十进制地址
135
+ - **按键上报**(主动发送 8 字节):`[地址][03][00][02][绝对键值][按键掩码][CRC]`
136
+ - **指示灯控制**:写寄存器 `0x1008` 一次写入完整 bitmask(变背光产品高字节 BIT8=1,节点自动处理)
137
+ - **RCU 模式**:首次 LED 控制自动写 `0x1003=0x0006` 并保存 `0x100F`
138
+ - **多键同步**:同面板 8 个从站开关节点共享状态,**只发一条** `0x1008` 帧(复用写入队列 / 200ms 防抖 / 面板级去重)
139
+
140
+ **面板地址 1 · 变背光 · 单键亮(测试码):**
141
+
142
+ | 操作 | Hex |
143
+ |------|-----|
144
+ | 按键1亮 | `01 06 10 08 01 01 CC 98` |
145
+ | 按键2亮 | `01 06 10 08 01 02 8C 99` |
146
+ | 按键3亮 | `01 06 10 08 01 04 0C 9B` |
147
+ | 按键4亮 | `01 06 10 08 01 08 0C 9E` |
148
+ | 按键5亮 | `01 06 10 08 01 10 0C 94` |
149
+ | 按键6亮 | `01 06 10 08 01 20 0C 80` |
150
+ | 按键7亮 | `01 06 10 08 01 40 0C A8` |
151
+ | 按键8亮 | `01 06 10 08 01 80 0C F8` |
152
+ | **全灭** | `01 06 10 08 00 00 0C C8` |
153
+
154
+ **单键灭(`0x1008` 写剩余 bitmask,非逐键单独灭指令):**
155
+
156
+ | 操作 | Hex |
157
+ |------|-----|
158
+ | 仅灭当前唯一亮键(全灭) | `01 06 10 08 00 00 0C C8` |
159
+ | 灭键1、键2仍亮 | `01 06 10 08 01 02 8C 99` |
160
+ | 灭键2、键1+3仍亮 | `01 06 10 08 01 05 CD 5B` |
161
+ | 键1+3亮 | `01 06 10 08 01 05 CD 5B` |
162
+ | 键1–4全亮 | `01 06 10 08 01 0F 4D 5C` |
163
+
164
+ **修改面板地址**:广播 `FF 06 10 00 00 [新地址] [CRC]`,再向新地址发 `XX 06 10 0F 00 FE [CRC]` 保存。
165
+
131
166
  ### 6. 进阶功能配置
132
167
 
133
168
  #### HomeKit 接入
@@ -487,47 +522,28 @@ msg.payload = 1; // 或 0
487
522
 
488
523
  ### 更新日志
489
524
 
490
- #### v2.10.19 (2026-06-30)
491
-
492
- - **[工程]** 提取 `lib/lru-map.js`、`lib/slave-switch-cache.js`,精简 `modbus-slave-switch` 全局缓存逻辑并限制 Mesh LED 定时器上限(5000)。
493
- - **[工程]** 新增 `lib/http-admin-utils.js`,14 个 HTTP Admin 路由统一权限校验(read/write)。
494
- - **[工程]** `modbus-master` 串口列表 API 复用 `lib/serial-utils`,消除重复实现。
495
- - **[测试]** 新增 `mesh-protocol`、`lightweight-protocol` 协议帧解析单元测试(`npm test`)。
496
-
497
- #### v2.10.18 (2026-06-30)
498
-
499
- - **[打包]** 修复 npm 发布包误含 `.codebase-memory/` 开发索引文件的问题,包体积从约 493KB 降至约 158KB,避免将本地知识图谱索引发布到 npm。
500
- - **[工程]** 在 `.npmignore` 与 `.gitignore` 中排除 `.codebase-memory/`,确保开发工具产物不会进入发布包或版本库。
525
+ #### v2.10.27 (2026-07-09)
501
526
 
502
- #### v2.10.17 (2026-02-07)
527
+ - **[LF485 开关]** 生产发布:拉斐 LF 485 弱电开关完整对接(主动收发、RCU 模式、变背光 BIT8、面板级 0x1008 单帧聚合)。
528
+ - **[LF485 开关]** `sendLfPanelLedSync` 同面板 8 键只发一条指示灯帧;复用 `serial-port-config` 50ms 队列、200ms 防抖、粘包解析与全局缓存。
529
+ - **[适配]** 从站开关 / 继电器输出 / Mesh 透传节点支持 LF 品牌;`lf-protocol.js` 单元测试覆盖 PDF 范例与用户抓包。
530
+ - **[打包]** npm 包审计:不含 `doc/`、PDF、开发缓存;兼容 Node-RED ≥2.0、Node.js ≥14。
503
531
 
504
- - **Mesh 窗帘协议兼容性**:按照《蓝牙MESH网关(初级版)串口协议V1.0》修正 0x53 0x80 0x06 节点状态帧解析逻辑,明确区分事件类型 RESULT_EVENT_NODE_STATUS(0x06) 与 `VD_MSG_TYPE_CURT_RUN_STATUS(0x05)` / `VD_MSG_TYPE_CURT_RUN_PER_POS(0x06)`,不再误把 `subOp` 当成窗帘 msg_type 使用,严格从数据区解析 `[msg_type][param]`。
505
- - **窗帘状态与端点记忆**:窗帘动作(0x05) 与位置(0x06) 在内部状态中分离存储,位置只由真正的 0x06 位置帧(含杜亚485位置反馈)更新;仅当位置为 0%/100% 且确认为当前运动方向的目标端点时才标记到达端点并更新 `lastAction/lastDirection`,避免 0x05 帧或“打开中收到0% / 关闭中收到100%”被误判为端点。
506
- - **开停关停 / 打开-关闭 行为**:
507
- - 引入 `isMoving` 统一表示“运动过程”,只要执行过开/关且未到端点(0%/100%),物理按键再次点击必发送暂停(3),不会直接翻转方向。
508
- - 停在 0%/100% 后会进行“端点收敛”:端点上报后不会再多走一次暂停,下一次按键一定是「关着→打开 / 开着→关闭」,到顶/到底后再次点击必然反向(关/开)。
509
- - 修正 cycle 模式下起点 0%/100% 刚起步时第二下按键误判为翻转的问题;到端点仅在 `isMoving=false && reachedEndpoint=true && endpointPosition=0/100` 时生效,并移除“>50% / <=50% 位置兜底”,中途暂停后的方向完全由 `lastDirection/lastAction` 决定,保证绝对的「开-停-关-停」节奏。
510
- - **Mesh 透传与日志**:合并重复 close 监听、Mesh 透传热路径日志改为 debug,减少生产环境刷屏;仍兼容 Mesh 动作反馈(0x05) 与位置反馈(0x06),但不再因为偶发“停止/运动中”状态抖动误判为已停止,避免运动过程误触发开/关。
511
- - **Clowire 场景按键**:Clowire 面板按键在从站开关 / 继电器输出 / Mesh 透传中统一按“场景按钮”处理,每次物理按下都会触发一次事件;运动中的窗帘再次按下必然发送暂停,不会误翻转方向。
532
+ #### v2.10.20 (2026-06-30)
512
533
 
513
- #### v2.10.16 (2026-02-06)
514
-
515
- - **按键记忆**:同一帧内若同时上报位置与动作(如 position=100 + action=打开中),以位置为准到端点即按「已停止」记忆,避免被动作覆盖导致下次按键误判。
516
- - **开停关停 / 打开关闭**:状态记忆更稳,到顶/到底后再次按键必为关/开;开停关停模式下运动中点击会正确发送暂停(3)。
517
- - **场景按键**:场景模式下不再因 value=false 忽略一次按下,每次物理按键都会触发控制,解决「多按一次」才生效的问题。
518
-
519
- #### v2.10.15 (2026-02-05)
520
-
521
- - **Mesh 窗帘控制**:修复重启后实体消失、到头不记忆位置、再次点击误发暂停等问题;新增「打开/关闭」单键循环逻辑;「开-停-关-停」循环逻辑完整可用。
522
- - **配置界面**:控制逻辑选项正确保存与显示,选择「打开/关闭」后不再被覆盖为「循环」。
523
- - **Mesh 透传与杜亚**:与杜亚电机状态双向同步优化;窗帘到端点(0%/100%)状态正确同步到按键,点击时能正确执行开/关。
524
- - **稳定性**:实体持久化与存储校验增强,兼容更多固件版本的窗帘协议。
534
+ - **[打包]** 修复 npm 发布包误含 `.codebase-memory/` 开发索引的问题,包体积约 158KB;规范 `.npmignore` / `.gitignore`,开发缓存目录不进入发布包。
535
+ - **[工程]** 提取 `lib/lru-map.js`、`lib/slave-switch-cache.js`,全局 LRU 缓存与 Mesh LED 定时器上限(5000);新增 `lib/http-admin-utils.js`,14 个 HTTP Admin 路由统一 read/write 权限校验。
536
+ - **[工程]** `modbus-master` 串口列表 API 复用 `lib/serial-utils`,消除重复实现。
537
+ - **[测试]** 新增 `mesh-protocol`、`lightweight-protocol` 协议帧解析单元测试(`npm test`)。
538
+ - **[修复]** 修正 `isMeshFrame` 请求帧长度判定,与 `parseAllFrames` 保持一致。
539
+ - **[Mesh 窗帘]** 按《蓝牙MESH网关(初级版)串口协议V1.0》修正 0x53 0x80 0x06 状态帧解析;窗帘动作/位置分离存储;开停关停与打开-关闭逻辑完善;Clowire 场景按键统一处理。
540
+ - **[Mesh 透传]** 合并重复 close 监听;热路径日志降为 debug;杜亚电机状态双向同步优化;实体持久化增强。
525
541
 
526
- #### v2.10.14 (2026-02-03)
542
+ #### 更早版本 (v2.10.14 ~ v2.10.17)
527
543
 
528
- - 多种 Modbus 协议支持(Telnet ASCII、RTU over TCP、TCP、串口);智能轮询暂停,面板事件响应更及时。
529
- - Home Assistant MQTT Discovery 与实体唯一 ID;串口热插拔与 TCP 故障自动恢复。
530
- - 开关/场景按钮双模式,LED 反馈同步。
544
+ - 多种 Modbus 协议(Telnet ASCII、RTU over TCP、TCP、串口)与智能轮询暂停。
545
+ - Home Assistant MQTT Discovery、HomeKit 网桥、控制看板、Mesh 透传节点、继电器输出。
546
+ - 按键记忆与端点收敛、场景按键每次按下均触发、LED 反馈同步、串口热插拔与 TCP 自动恢复。
531
547
 
532
548
  ## 许可证
533
549
  MIT License
@@ -0,0 +1,285 @@
1
+ /**
2
+ * LF485 通讯协议(拉斐 Laffey 智能酒店版)
3
+ * 参考:LF485通讯协议智能酒店版(轮询)V3.0
4
+ *
5
+ * 开关面板主动发送帧(8字节):
6
+ * [地址][03][00][02][绝对键值][按键位掩码][CRC_L][CRC_H]
7
+ *
8
+ * 按键位掩码 BIT0~BIT7 对应按键 1~8
9
+ */
10
+
11
+ module.exports = {
12
+ CMD_READ: 0x03,
13
+ CMD_WRITE: 0x06,
14
+
15
+ REG_PANEL_ADDR: 0x1000,
16
+ REG_PANEL_CONFIG: 0x1003,
17
+ REG_LED_CONTROL: 0x1008,
18
+ REG_BUTTON_VALUE: 0x100B,
19
+ REG_SAVE_CONFIG: 0x100F,
20
+
21
+ DEFAULT_PANEL_ADDR: 0x02,
22
+ ACTIVE_FRAME_LEN: 8,
23
+
24
+ // 0x1003 工作模式位定义(PDF V3.0)
25
+ CONFIG_DEMO_MODE: 0x0001, // BIT0 演示模式(本地控制,非 RCU)
26
+ CONFIG_RCU_MODE: 0x0002, // BIT1 RCU 模式(主机控制指示灯,必须开启)
27
+ CONFIG_ACTIVE_SEND: 0x0004, // BIT2 按键主动发送
28
+ CONFIG_SENSOR_ENABLE: 0x0008, // BIT3 感应使能
29
+ CONFIG_BACKLIGHT_DELAY: 0x0010, // BIT4 背光延时灭
30
+ CONFIG_RELEASE_SEND: 0x0020, // BIT5 按键松开发送
31
+
32
+ // RCU + 主动发送(酒店/智能家居常用组合)
33
+ CONFIG_RCU_ACTIVE: 0x0006,
34
+
35
+ // 0x1008 变背光类产品需 BIT8 才允许 LED 显示
36
+ LED_BACKLIGHT_ENABLE: 0x0100,
37
+
38
+ /**
39
+ * Modbus CRC16(低字节在前)
40
+ */
41
+ calculateCRC16: function(buffer, len) {
42
+ let crc = 0xFFFF;
43
+ const end = len === undefined ? buffer.length : len;
44
+ for (let i = 0; i < end; i++) {
45
+ crc ^= buffer[i];
46
+ for (let j = 0; j < 8; j++) {
47
+ if (crc & 0x0001) {
48
+ crc = (crc >> 1) ^ 0xA001;
49
+ } else {
50
+ crc >>= 1;
51
+ }
52
+ }
53
+ }
54
+ return crc;
55
+ },
56
+
57
+ verifyCRC: function(buffer) {
58
+ if (!buffer || buffer.length < 3) {
59
+ return false;
60
+ }
61
+ const calc = this.calculateCRC16(buffer, buffer.length - 2);
62
+ const recv = buffer[buffer.length - 2] | (buffer[buffer.length - 1] << 8);
63
+ return calc === recv;
64
+ },
65
+
66
+ /**
67
+ * 绝对键值 = (面板地址 - 1) * 6 + 键编号
68
+ */
69
+ calcAbsoluteKey: function(panelAddr, buttonNumber) {
70
+ return ((panelAddr - 1) * 6) + buttonNumber;
71
+ },
72
+
73
+ /**
74
+ * 按键编号 -> 位掩码(1~8)
75
+ */
76
+ buttonToMask: function(buttonNumber) {
77
+ const n = parseInt(buttonNumber, 10);
78
+ if (n < 1 || n > 8) {
79
+ return 0;
80
+ }
81
+ return 1 << (n - 1);
82
+ },
83
+
84
+ /**
85
+ * 位掩码 -> 按键编号(仅支持单键按下)
86
+ */
87
+ maskToButton: function(mask) {
88
+ let m = mask & 0xFF;
89
+ if (m === 0 || (m & (m - 1)) !== 0) {
90
+ return null;
91
+ }
92
+ let bit = 0;
93
+ while (m > 1) {
94
+ m >>= 1;
95
+ bit++;
96
+ }
97
+ return bit + 1;
98
+ },
99
+
100
+ isLfActiveFrame: function(buffer) {
101
+ if (!buffer || buffer.length !== this.ACTIVE_FRAME_LEN) {
102
+ return false;
103
+ }
104
+ if (buffer[1] !== this.CMD_READ) {
105
+ return false;
106
+ }
107
+ if (buffer[2] !== 0x00 || buffer[3] !== 0x02) {
108
+ return false;
109
+ }
110
+ return this.verifyCRC(buffer);
111
+ },
112
+
113
+ /**
114
+ * 解析主动发送按键帧
115
+ */
116
+ parseActiveButtonFrame: function(buffer) {
117
+ if (!this.isLfActiveFrame(buffer)) {
118
+ return null;
119
+ }
120
+ const panelAddr = buffer[0];
121
+ const absKey = buffer[4];
122
+ const buttonMask = buffer[5];
123
+ const buttonNumber = this.maskToButton(buttonMask);
124
+ if (!buttonNumber) {
125
+ return null;
126
+ }
127
+ return {
128
+ panelAddr,
129
+ absKey,
130
+ buttonMask,
131
+ buttonNumber,
132
+ pressed: true,
133
+ type: "button"
134
+ };
135
+ },
136
+
137
+ parseAllFrames: function(buffer) {
138
+ const events = [];
139
+ if (!buffer || buffer.length < this.ACTIVE_FRAME_LEN) {
140
+ return events;
141
+ }
142
+ let offset = 0;
143
+ while (offset + this.ACTIVE_FRAME_LEN <= buffer.length) {
144
+ const slice = buffer.slice(offset, offset + this.ACTIVE_FRAME_LEN);
145
+ if (this.isLfActiveFrame(slice)) {
146
+ const event = this.parseActiveButtonFrame(slice);
147
+ if (event) {
148
+ events.push(event);
149
+ }
150
+ offset += this.ACTIVE_FRAME_LEN;
151
+ } else {
152
+ offset++;
153
+ }
154
+ }
155
+ return events;
156
+ },
157
+
158
+ /**
159
+ * 写寄存器 0x1008 控制状态指示灯(RCU 模式)
160
+ * @param {number} panelAddr 面板地址 1-42
161
+ * @param {number} ledMask 指示灯位掩码 BIT0~BIT7 对应按键1~8
162
+ */
163
+ buildLedMaskCommand: function(panelAddr, ledMask) {
164
+ const buffer = Buffer.alloc(8);
165
+ buffer[0] = panelAddr & 0xFF;
166
+ buffer[1] = this.CMD_WRITE;
167
+ buffer[2] = 0x10;
168
+ buffer[3] = 0x08;
169
+ buffer[4] = (ledMask >> 8) & 0xFF;
170
+ buffer[5] = ledMask & 0xFF;
171
+ const crc = this.calculateCRC16(buffer, 6);
172
+ buffer[6] = crc & 0xFF;
173
+ buffer[7] = (crc >> 8) & 0xFF;
174
+ return buffer;
175
+ },
176
+
177
+ buildLedControlCommand: function(panelAddr, buttonNumber, state, useBacklight) {
178
+ const bit = this.buttonToMask(buttonNumber);
179
+ const buttonMask = state ? bit : 0;
180
+ return this.buildAggregatedLedMaskCommand(panelAddr, buttonMask, useBacklight);
181
+ },
182
+
183
+ buildTurnOffAllLedsCommand: function(panelAddr) {
184
+ return this.buildLedMaskCommand(panelAddr, 0x0000);
185
+ },
186
+
187
+ /**
188
+ * 聚合按键位掩码并应用变背光 BIT8(PDF:变背光产品 BIT8=1 才显示 LED)
189
+ * @param {number} buttonMask BIT0~7 按键状态
190
+ * @param {boolean} useBacklight 变背光面板(默认 true)
191
+ */
192
+ finalizeLedMask: function(buttonMask, useBacklight) {
193
+ let mask = buttonMask & 0xFF;
194
+ const backlight = useBacklight !== false;
195
+ if (backlight && mask !== 0) {
196
+ mask |= this.LED_BACKLIGHT_ENABLE;
197
+ }
198
+ return mask;
199
+ },
200
+
201
+ buildAggregatedLedMaskCommand: function(panelAddr, buttonMask, useBacklight) {
202
+ return this.buildLedMaskCommand(panelAddr, this.finalizeLedMask(buttonMask, useBacklight));
203
+ },
204
+
205
+ /**
206
+ * 清除单个按键位(0x1008 写完整掩码,灭某一键需写剩余键状态)
207
+ * @param {number} currentButtonMask 当前按键位 BIT0~7
208
+ * @param {number} buttonNumber 要熄灭的按键 1~8
209
+ */
210
+ clearButtonFromMask: function(currentButtonMask, buttonNumber) {
211
+ const bit = this.buttonToMask(buttonNumber);
212
+ return (currentButtonMask & 0xFF) & (~bit & 0xFF);
213
+ },
214
+
215
+ buildSingleButtonOffCommand: function(panelAddr, buttonNumber, currentButtonMask, useBacklight) {
216
+ const newMask = this.clearButtonFromMask(currentButtonMask || 0, buttonNumber);
217
+ return this.buildAggregatedLedMaskCommand(panelAddr, newMask, useBacklight);
218
+ },
219
+
220
+ /**
221
+ * 修改面板地址(写 0x1000),修改后需写 0x100F 保存
222
+ */
223
+ buildSetPanelAddressCommand: function(currentAddr, newAddr, useBroadcast) {
224
+ const addr = useBroadcast ? 0xFF : (currentAddr & 0xFF);
225
+ const buffer = Buffer.alloc(8);
226
+ buffer[0] = addr;
227
+ buffer[1] = this.CMD_WRITE;
228
+ buffer[2] = 0x10;
229
+ buffer[3] = 0x00;
230
+ buffer[4] = 0x00;
231
+ buffer[5] = newAddr & 0xFF;
232
+ const crc = this.calculateCRC16(buffer, 6);
233
+ buffer[6] = crc & 0xFF;
234
+ buffer[7] = (crc >> 8) & 0xFF;
235
+ return buffer;
236
+ },
237
+
238
+ /**
239
+ * 配置面板工作模式(0x1003)
240
+ * bit1=RCU模式 bit2=按键主动发送
241
+ */
242
+ buildSetPanelConfigCommand: function(panelAddr, configValue) {
243
+ const buffer = Buffer.alloc(8);
244
+ buffer[0] = panelAddr & 0xFF;
245
+ buffer[1] = this.CMD_WRITE;
246
+ buffer[2] = 0x10;
247
+ buffer[3] = 0x03;
248
+ buffer[4] = (configValue >> 8) & 0xFF;
249
+ buffer[5] = configValue & 0xFF;
250
+ const crc = this.calculateCRC16(buffer, 6);
251
+ buffer[6] = crc & 0xFF;
252
+ buffer[7] = (crc >> 8) & 0xFF;
253
+ return buffer;
254
+ },
255
+
256
+ buildSaveConfigCommand: function(panelAddr) {
257
+ const buffer = Buffer.alloc(8);
258
+ buffer[0] = panelAddr & 0xFF;
259
+ buffer[1] = this.CMD_WRITE;
260
+ buffer[2] = 0x10;
261
+ buffer[3] = 0x0F;
262
+ buffer[4] = 0x00;
263
+ buffer[5] = 0xFE;
264
+ const crc = this.calculateCRC16(buffer, 6);
265
+ buffer[6] = crc & 0xFF;
266
+ buffer[7] = (crc >> 8) & 0xFF;
267
+ return buffer;
268
+ },
269
+
270
+ /**
271
+ * 启用 RCU 模式 + 按键主动发送(PDF:BIT1=RCU 控制指示灯,BIT2=主动发送)
272
+ * 例:02 06 10 03 00 06 FD 3B
273
+ */
274
+ buildEnableRcuModeCommand: function(panelAddr) {
275
+ return this.buildSetPanelConfigCommand(panelAddr, this.CONFIG_RCU_ACTIVE);
276
+ },
277
+
278
+ /**
279
+ * 变背光类产品:按键1亮(需 BIT8=1 才显示 LED)
280
+ * 例:02 06 10 08 01 01 CC AB
281
+ */
282
+ buildLedControlCommandBacklight: function(panelAddr, buttonNumber, state) {
283
+ return this.buildLedControlCommand(panelAddr, buttonNumber, state, true);
284
+ }
285
+ };
@@ -512,18 +512,31 @@ module.exports = function(_RED) {
512
512
  * @returns {boolean} 是否有效
513
513
  */
514
514
  function isMeshFrame(buffer) {
515
- if (!buffer || buffer.length < 5) {return false;}
515
+ if (!buffer || buffer.length < 4) {return false;}
516
516
  if (buffer[0] !== PROTOCOL.HEADER) {return false;}
517
-
518
- const dataLen = buffer[3];
519
- const totalLen = 4 + dataLen + 1;
520
-
517
+
518
+ const opCode = buffer[1];
519
+ let dataLenIndex;
520
+ let overhead;
521
+
522
+ if (opCode >= 0x80) {
523
+ dataLenIndex = 3;
524
+ overhead = 5;
525
+ } else {
526
+ dataLenIndex = 2;
527
+ overhead = 4;
528
+ }
529
+
530
+ if (dataLenIndex >= buffer.length) {return false;}
531
+
532
+ const dataLen = buffer[dataLenIndex];
533
+ const totalLen = overhead + dataLen;
534
+
521
535
  if (buffer.length !== totalLen) {return false;}
522
-
523
- // 校验和验证
536
+
524
537
  const receivedChecksum = buffer[buffer.length - 1];
525
538
  const calculatedChecksum = calculateChecksum(buffer.slice(0, buffer.length - 1));
526
-
539
+
527
540
  return receivedChecksum === calculatedChecksum;
528
541
  }
529
542
 
@@ -393,6 +393,7 @@
393
393
  <select id="node-input-switchBrand" style="width: 200px;">
394
394
  <option value="symi">亖米(Symi)</option>
395
395
  <option value="clowire">Clowire(克伦威尔)</option>
396
+ <option value="lf">LF(拉斐)</option>
396
397
  </select>
397
398
  <span style="margin-left: 10px; font-size: 11px; color: #666; font-style: italic;">支持1-8键开关</span>
398
399
  </div>
@@ -3,6 +3,7 @@ module.exports = function(RED) {
3
3
  const meshProtocol = require("./mesh-protocol")(RED);
4
4
  const protocol = require("./lightweight-protocol");
5
5
  const clowireProtocol = require("./clowire-protocol");
6
+ const lfProtocol = require("./lf-protocol");
6
7
  const dooyaProtocol = require("./dooya-protocol");
7
8
 
8
9
  function MeshTransparentNode(config) {
@@ -243,6 +244,8 @@ module.exports = function(RED) {
243
244
  let command = null;
244
245
  if (node.switchBrand === "clowire") {
245
246
  command = clowireProtocol.buildLedControlCommand(node.switchId, node.buttonNumber, state);
247
+ } else if (node.switchBrand === "lf") {
248
+ command = lfProtocol.buildLedControlCommand(node.switchId, node.buttonNumber, state);
246
249
  } else {
247
250
  // Symi: 使用 REPORT (0x04) 协议同步状态,不触发动作
248
251
  const targetAddr = node.panelAddr || node.switchId || 1;
@@ -42,7 +42,12 @@
42
42
  // 开关模式和场景模式显示
43
43
  const coilDisplay = this.targetCoilNumber || 1;
44
44
  const btnLabel = this.buttonNumber == 15 ? '背光灯' : `按钮${this.buttonNumber}`;
45
- const brandLabel = this.switchBrand === 'clowire' ? 'Clowire' : '开关';
45
+ let brandLabel = '开关';
46
+ if (this.switchBrand === 'clowire') {
47
+ brandLabel = 'Clowire';
48
+ } else if (this.switchBrand === 'lf') {
49
+ brandLabel = 'LF';
50
+ }
46
51
  return this.name || `${brandLabel}${this.switchId}-${btnLabel} → 继电器${this.targetSlaveAddress}-${coilDisplay}路`;
47
52
  }
48
53
  },
@@ -63,15 +68,38 @@
63
68
  }
64
69
  });
65
70
 
71
+ function updateBrandUi() {
72
+ const brand = $("#node-input-switchBrand").val();
73
+ const backlightOpt = $("#node-input-buttonNumber option[value='15']");
74
+ if (brand === 'lf') {
75
+ $("#node-input-switchId").attr("min", 1).attr("max", 42);
76
+ $("#lf-brand-hint").show();
77
+ backlightOpt.hide();
78
+ if ($("#node-input-buttonNumber").val() === '15') {
79
+ $("#node-input-buttonNumber").val(1);
80
+ $("#backlight-hint").hide();
81
+ }
82
+ } else {
83
+ $("#node-input-switchId").attr("min", 0).attr("max", 255);
84
+ $("#lf-brand-hint").hide();
85
+ backlightOpt.show();
86
+ }
87
+ }
88
+
66
89
  // 品牌切换控制
67
90
  $("#node-input-switchBrand").on("change", function() {
68
91
  const brand = $(this).val();
69
92
  if (brand === 'clowire') {
70
- // Clowire默认地址是2
71
93
  if ($("#node-input-switchId").val() === '0') {
72
94
  $("#node-input-switchId").val(2);
73
95
  }
96
+ } else if (brand === 'lf') {
97
+ const sid = $("#node-input-switchId").val();
98
+ if (sid === '0' || sid === '2' || sid === '') {
99
+ $("#node-input-switchId").val(1);
100
+ }
74
101
  }
102
+ updateBrandUi();
75
103
  });
76
104
 
77
105
  // 按钮类型切换控制显示/隐藏
@@ -212,6 +240,7 @@
212
240
  // 初始化显示状态
213
241
  $("#node-input-enableMqtt").trigger("change");
214
242
  $("#node-input-buttonType").trigger("change");
243
+ updateBrandUi();
215
244
 
216
245
  // 始终加载已保存的Mesh设备列表(无论当前是什么模式)
217
246
  // 这样切换到Mesh模式时,列表已经加载好了
@@ -321,10 +350,14 @@
321
350
  <select id="node-input-switchBrand" style="width: 200px;">
322
351
  <option value="symi">亖米(Symi)</option>
323
352
  <option value="clowire">Clowire(克伦威尔)</option>
353
+ <option value="lf">LF(拉斐)</option>
324
354
  </select>
325
355
  <span style="margin-left: 10px; font-size: 11px; color: #666; font-style: italic;">支持1-8键开关</span>
326
356
  </div>
327
357
 
358
+ <div id="lf-brand-hint" style="display: none; font-size: 11px; color: #888; margin: -8px 0 8px 110px;">
359
+ LF485:9600 8N1,地址 1-42;RCU 模式 + 变背光 BIT8 自动控制 0x1008 指示灯
360
+ </div>
328
361
 
329
362
 
330
363
  <div class="form-row" style="display: flex; align-items: center;">
@@ -3,6 +3,7 @@ module.exports = function(RED) {
3
3
  const mqtt = require("mqtt");
4
4
  const protocol = require("./lightweight-protocol");
5
5
  const clowireProtocol = require("./clowire-protocol");
6
+ const lfProtocol = require("./lf-protocol");
6
7
  const meshProtocol = require("./mesh-protocol")(RED);
7
8
  const mqttHelper = require("./mqtt-helper");
8
9
  const storage = require("node-persist");
@@ -23,6 +24,143 @@ module.exports = function(RED) {
23
24
  } = createSlaveSwitchCaches(RED);
24
25
 
25
26
  startGlobalDebounceCacheCleanup();
27
+
28
+ // LF 面板指示灯聚合掩码(同一485总线上多节点共享)
29
+ const lfPanelLedMask = new Map();
30
+ // LF 面板上次已下发的 0x1008 掩码(每面板一条,避免多节点重复发送)
31
+ const lfPanelLastSentMask = new Map();
32
+ // LF 面板 RCU 模式已配置标记(PDF:0x1003 BIT1=1 才能由主机控制 0x1008 指示灯)
33
+ const lfRcuConfiguredPanels = new Set();
34
+
35
+ function ensureLfRcuMode(node, panelId) {
36
+ if (!node.serialPortConfig || typeof node.serialPortConfig.write !== "function") {
37
+ return;
38
+ }
39
+ const configId = node.serialPortConfig.id || node.id;
40
+ const rcuKey = `${configId}:${panelId}`;
41
+ if (lfRcuConfiguredPanels.has(rcuKey)) {
42
+ return;
43
+ }
44
+ lfRcuConfiguredPanels.add(rcuKey);
45
+
46
+ const cfgCmd = lfProtocol.buildEnableRcuModeCommand(panelId);
47
+ const saveCmd = lfProtocol.buildSaveConfigCommand(panelId);
48
+ node.log(`[LF RCU] 面板${panelId} 自动启用 RCU 模式(0x1003=0x0006) 并保存,之后主机可控制 0x1008 指示灯`);
49
+
50
+ node.serialPortConfig.write(cfgCmd, (err) => {
51
+ if (err) {
52
+ node.log(`[LF RCU] 写入 0x1003 失败: ${err.message}`);
53
+ }
54
+ }, 4, panelId);
55
+ node.serialPortConfig.write(saveCmd, (err) => {
56
+ if (err) {
57
+ node.log(`[LF RCU] 写入 0x100F 保存失败: ${err.message}`);
58
+ }
59
+ }, 4, panelId);
60
+ }
61
+
62
+ 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);
82
+ }
83
+ });
84
+ return lfProtocol.finalizeLedMask(mask, true);
85
+ }
86
+
87
+ function updateLfRelayStateCache(node, state) {
88
+ const relayStateKey = getScopedKey(node, `relay-state-${node.config.targetSlaveAddress}-${node.config.targetCoilNumber}`);
89
+ if (!globalButtonRelayStates.has(relayStateKey)) {
90
+ globalButtonRelayStates.set(relayStateKey, new Map());
91
+ }
92
+ globalButtonRelayStates.get(relayStateKey).set(node.id, state);
93
+ }
94
+
95
+ /**
96
+ * LF 面板 LED 同步:聚合同面板全部按键状态,只发一条 0x1008 写寄存器帧
97
+ */
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指示灯反馈");
108
+ }
109
+ return false;
110
+ }
111
+
112
+ const panelId = parseInt(callerNode.config.switchId, 10);
113
+ const configId = callerNode.serialPortConfig.id || callerNode.id;
114
+ const ledKey = `${configId}:${panelId}`;
115
+
116
+ ensureLfRcuMode(callerNode, panelId);
117
+
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
+ }
125
+
126
+ const command = mask === 0
127
+ ? lfProtocol.buildTurnOffAllLedsCommand(panelId)
128
+ : lfProtocol.buildLedMaskCommand(panelId, mask);
129
+
130
+ lfPanelLastSentMask.set(ledKey, { mask, timestamp: now });
131
+ lfPanelLedMask.set(ledKey, mask);
132
+
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
+ });
149
+
150
+ if (typeof callerNode.serialPortConfig.setFeedbackLock === "function") {
151
+ callerNode.serialPortConfig.setFeedbackLock(150);
152
+ }
153
+
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}]`);
156
+
157
+ callerNode.serialPortConfig.write(command, (err) => {
158
+ if (err) {
159
+ callerNode.log(`LF LED反馈失败: ${err.message}`);
160
+ }
161
+ }, 3, panelId);
162
+ return true;
163
+ }
26
164
  // 初始化Mesh设备持久化存储
27
165
  const meshPersistDir = path.join(RED.settings.userDir || os.homedir() + "/.node-red", "mesh-devices-persist");
28
166
 
@@ -313,7 +451,7 @@ module.exports = function(RED) {
313
451
  mqttPassword: node.mqttServerConfig ? (node.mqttServerConfig.credentials ? node.mqttServerConfig.credentials.password : "") : "",
314
452
  mqttBaseTopic: node.mqttServerConfig ? node.mqttServerConfig.baseTopic : "modbus/relay",
315
453
  // 开关面板配置
316
- switchBrand: config.switchBrand || "symi", // 面板品牌(默认Symi,支持clowire)
454
+ switchBrand: config.switchBrand || "symi", // 面板品牌(symi/clowire/lf
317
455
  buttonType: config.buttonType || "switch", // 按钮类型:switch=开关模式,scene=场景模式,mesh=Mesh模式
318
456
  syncLight: config.syncLight !== false, // 指示灯同步:默认开启
319
457
  switchId: parseInt(config.switchId) || 0, // 开关ID(0-255,物理面板地址)
@@ -794,12 +932,16 @@ module.exports = function(RED) {
794
932
  representativeNode.debug(`检查面板按键状态时出错: ${err.message}`);
795
933
  }
796
934
 
797
- // Clowire/Symi 协议优化:如果该面板所有已知的按键状态都为 OFF,则发送一条“全关”指令
935
+ // LF:聚合同面板全部按键,只发一条 0x1008
936
+ if (representativeNode.config.switchBrand === "lf") {
937
+ sendLfPanelLedSync(representativeNode, false);
938
+ return;
939
+ }
940
+
941
+ // Clowire/Symi 协议优化:如果该面板所有按键都为 OFF,则发送一条“全关”指令
798
942
  let useGlobalOff = false;
799
943
  if (!anyOn) {
800
944
  if (representativeNode.config.switchBrand === "clowire") {
801
- // v2.10.7 回退:Clowire 全关指令 (0x1020=0x0002) 在部分面板上无法同步指示灯状态
802
- // 暂时禁用该优化,改回逐个发送关闭指令
803
945
  useGlobalOff = false;
804
946
  } else if (representativeNode.config.switchBrand === "symi") {
805
947
  useGlobalOff = false;
@@ -944,6 +1086,17 @@ module.exports = function(RED) {
944
1086
  }
945
1087
  }
946
1088
  }
1089
+ } else if (node.config.switchBrand === "lf") {
1090
+ // LF485 拉斐面板:固定8字节主动发送帧
1091
+ while (node.rs485Buffer.length >= lfProtocol.ACTIVE_FRAME_LEN) {
1092
+ const slice = node.rs485Buffer.slice(0, lfProtocol.ACTIVE_FRAME_LEN);
1093
+ if (lfProtocol.isLfActiveFrame(slice)) {
1094
+ node.handleLfData(slice);
1095
+ node.rs485Buffer = node.rs485Buffer.slice(lfProtocol.ACTIVE_FRAME_LEN);
1096
+ } else {
1097
+ node.rs485Buffer = node.rs485Buffer.slice(1);
1098
+ }
1099
+ }
947
1100
  } else if (node.config.switchBrand === "clowire") {
948
1101
  // Clowire 协议拼包逻辑 (克伦威尔)
949
1102
  while (node.rs485Buffer.length >= 9) {
@@ -1439,6 +1592,80 @@ module.exports = function(RED) {
1439
1592
  }
1440
1593
  };
1441
1594
 
1595
+ // 处理LF485拉斐面板协议数据
1596
+ node.handleLfData = function(data) {
1597
+ try {
1598
+ const frames = Buffer.isBuffer(data) ? [data] : [data];
1599
+ for (const frame of frames) {
1600
+ const event = lfProtocol.parseActiveButtonFrame(frame);
1601
+ if (!event) {
1602
+ continue;
1603
+ }
1604
+ if (event.panelAddr !== parseInt(node.config.switchId, 10)) {
1605
+ continue;
1606
+ }
1607
+ if (event.buttonNumber !== parseInt(node.config.buttonNumber, 10)) {
1608
+ continue;
1609
+ }
1610
+
1611
+ 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
+ const now = Date.now();
1614
+
1615
+ const lastCommandTime = globalDebounceCache.get(commandDebounceKey) || 0;
1616
+ if (now - lastCommandTime < 500) {
1617
+ return;
1618
+ }
1619
+ globalDebounceCache.set(commandDebounceKey, now);
1620
+
1621
+ const lastLedTime = globalDebounceCache.get(ledDebounceKey) || 0;
1622
+ const shouldSendLed = (now - lastLedTime >= 300);
1623
+ if (shouldSendLed) {
1624
+ globalDebounceCache.set(ledDebounceKey, now);
1625
+ }
1626
+
1627
+ if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === "function") {
1628
+ node.serialPortConfig.setTriggerSource(node.config.switchId);
1629
+ }
1630
+
1631
+ node.debug(`[LF按键] 面板${node.config.switchId} 按键${node.config.buttonNumber} 按下`);
1632
+
1633
+ node.currentState = !node.currentState;
1634
+
1635
+ updateLfRelayStateCache(node, node.currentState);
1636
+
1637
+ RED.events.emit("modbus:buttonPressed", {
1638
+ switchId: node.config.switchId,
1639
+ button: node.config.buttonNumber,
1640
+ value: node.currentState,
1641
+ brand: "lf",
1642
+ type: "scene",
1643
+ serialPortConfigId: node.serialPortConfig ? node.serialPortConfig.id : null
1644
+ });
1645
+
1646
+ node.sendMqttCommand(node.currentState);
1647
+
1648
+ if (shouldSendLed) {
1649
+ setTimeout(() => {
1650
+ sendLfPanelLedSync(node, true);
1651
+ }, 200);
1652
+ }
1653
+
1654
+ node.send({
1655
+ payload: node.currentState,
1656
+ topic: `lf_switch_${node.config.switchId}_btn${node.config.buttonNumber}`,
1657
+ switchId: node.config.switchId,
1658
+ button: node.config.buttonNumber,
1659
+ brand: "lf",
1660
+ targetSlave: node.config.targetSlaveAddress,
1661
+ targetCoil: node.config.targetCoilNumber
1662
+ });
1663
+ }
1664
+ } catch (err) {
1665
+ node.log(`处理LF协议数据失败: ${err.message}`);
1666
+ }
1667
+ };
1668
+
1442
1669
  // 处理Clowire协议数据
1443
1670
  node.handleClowireData = function(data) {
1444
1671
  try {
@@ -1997,6 +2224,9 @@ module.exports = function(RED) {
1997
2224
 
1998
2225
  const hexStr = command.toString("hex").toUpperCase().match(/.{1,2}/g).join(" ");
1999
2226
  node.debug(`[Clowire LED] 控制帧已构建:[${hexStr}]`);
2227
+ } else if (node.config.switchBrand === "lf") {
2228
+ sendLfPanelLedSync(node, isTriggerSource);
2229
+ return;
2000
2230
  } else {
2001
2231
  // RS-485模式:使用轻量级协议
2002
2232
  // 修正:设备地址应为面板地址 (switchId)
@@ -2479,6 +2709,15 @@ module.exports = function(RED) {
2479
2709
  node.sendMqttCommand(value);
2480
2710
  }
2481
2711
 
2712
+ // 本地输入同步面板指示灯(无需 Modbus 主站在线)
2713
+ if (node.config.syncLight && node.config.buttonType !== "mesh") {
2714
+ if (node.config.switchBrand === "lf") {
2715
+ sendLfPanelLedSync(node, false);
2716
+ } else {
2717
+ node.sendCommandToPanel(value, false);
2718
+ }
2719
+ }
2720
+
2482
2721
  // 更新状态显示
2483
2722
  node.updateStatus();
2484
2723
  });
@@ -182,6 +182,7 @@
182
182
  <select id="node-input-switchBrand" style="width: 200px;">
183
183
  <option value="symi">亖米(Symi)</option>
184
184
  <option value="clowire">Clowire(克伦威尔)</option>
185
+ <option value="lf">LF(拉斐)</option>
185
186
  </select>
186
187
  <span style="margin-left: 10px; font-size: 11px; color: #666; font-style: italic;">支持1-8键开关</span>
187
188
  </div>
@@ -98,11 +98,11 @@ module.exports = function(RED) {
98
98
 
99
99
  // 获取触发值
100
100
  const triggerValue = data.value !== undefined ? data.value : data.payload;
101
- const isClowire = msgBrand === "clowire";
101
+ const isToggleBrand = msgBrand === "clowire" || msgBrand === "lf";
102
102
 
103
103
  // 忽略释放信号
104
- // 注意:Clowire 协议上报的是“切换状态”,false 也是一次有效按键事件,不能当作释放信号丢弃
105
- if (node.ignoreRelease && !triggerValue && !isClowire) {
104
+ // Clowire/LF 协议上报的是“切换状态”,false 也是一次有效按键事件
105
+ if (node.ignoreRelease && !triggerValue && !isToggleBrand) {
106
106
  return;
107
107
  }
108
108
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-modbus",
3
- "version": "2.10.19",
4
- "description": "Node-RED Modbus节点,支持TCP/串口通信、多主站独立运行、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步(支持Symi/Clowire品牌),工控机长期稳定运行",
3
+ "version": "2.10.27",
4
+ "description": "Node-RED Modbus节点,支持TCP/串口通信、多主站独立运行、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步(支持Symi/Clowire/LF拉斐品牌),工控机长期稳定运行",
5
5
  "main": "nodes/modbus-master.js",
6
6
  "scripts": {
7
7
  "lint": "eslint nodes/*.js lib/*.js --ext .js",