node-red-contrib-symi-modbus 2.9.7 → 2.9.8
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 +19 -1
- package/nodes/clowire-protocol.js +409 -0
- package/nodes/modbus-slave-switch.html +21 -6
- package/nodes/modbus-slave-switch.js +137 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -952,7 +952,25 @@ HomeKit网桥节点无需输入消息,自动同步主站配置和状态。
|
|
|
952
952
|
|
|
953
953
|
## 版本信息
|
|
954
954
|
|
|
955
|
-
**当前版本**: v2.9.
|
|
955
|
+
**当前版本**: v2.9.8 (2026-01-06)
|
|
956
|
+
|
|
957
|
+
**v2.9.8 更新内容**:
|
|
958
|
+
- **新增品牌支持**:Clowire(克伦威尔)智能面板
|
|
959
|
+
- 支持Clowire 1-8键开关面板,与亖米面板使用方式完全一致
|
|
960
|
+
- 支持单击、双击、长按等按键事件(统一当作单击处理,触发一次状态切换)
|
|
961
|
+
- 支持红外感应器(有人/无人状态检测,复用按键背光灯配置buttonNumber=15)
|
|
962
|
+
- 支持LED指示灯双向同步(继电器状态→面板LED)
|
|
963
|
+
- 协议文档:`doc/Clowire-标准面板485协议-V1.3.md`
|
|
964
|
+
- **界面优化**:
|
|
965
|
+
- 面板品牌下拉框新增"Clowire(克伦威尔)"选项
|
|
966
|
+
- Clowire默认面板地址为2(符合出厂设置)
|
|
967
|
+
- 节点标签显示品牌名称(如"Clowire[2]-按钮1")
|
|
968
|
+
- 感应器复用按键背光灯配置(buttonNumber=15),无需单独配置
|
|
969
|
+
- **协议实现**:
|
|
970
|
+
- 新增`clowire-protocol.js`协议模块
|
|
971
|
+
- 支持MODBUS RTU通讯格式(9600 8N1)
|
|
972
|
+
- 支持CRC16校验和帧尾0xAA
|
|
973
|
+
- 支持批量LED控制(0x25命令码)
|
|
956
974
|
|
|
957
975
|
**v2.9.7 更新内容**:
|
|
958
976
|
- **新增节点**:继电器输出节点(relay-output)
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clowire 标准面板485协议实现
|
|
3
|
+
* 协议版本:V1.3
|
|
4
|
+
* 厂家:宁波星宏智能技术有限公司
|
|
5
|
+
*
|
|
6
|
+
* 协议特点:
|
|
7
|
+
* - 基于 MODBUS RTU 通讯格式
|
|
8
|
+
* - 面板主动发送模式(按键按下时主动上报)
|
|
9
|
+
* - 支持单击、双击、三击、长按
|
|
10
|
+
* - 支持 LED 指示灯控制
|
|
11
|
+
* - 支持红外感应信号上报
|
|
12
|
+
*
|
|
13
|
+
* 通讯帧格式:
|
|
14
|
+
* [面板地址][命令码][寄存器地址高][寄存器地址低][数据高][数据低][CRC低][CRC高][帧尾0xAA]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
// 帧尾
|
|
19
|
+
FRAME_TAIL: 0xAA,
|
|
20
|
+
|
|
21
|
+
// 广播地址
|
|
22
|
+
BROADCAST_ADDR: 0xFF,
|
|
23
|
+
|
|
24
|
+
// 默认面板地址
|
|
25
|
+
DEFAULT_PANEL_ADDR: 0x02,
|
|
26
|
+
|
|
27
|
+
// 命令码
|
|
28
|
+
CMD_READ: 0x03, // 读取寄存器
|
|
29
|
+
CMD_WRITE: 0x06, // 写入单个寄存器
|
|
30
|
+
CMD_BUTTON_EVENT: 0x20, // 按键事件上报
|
|
31
|
+
CMD_BATCH_CONTROL: 0x25, // 批量控制
|
|
32
|
+
|
|
33
|
+
// 寄存器地址
|
|
34
|
+
REG_PANEL_ADDR: 0x1000, // 面板地址
|
|
35
|
+
REG_BUTTON_S1: 0x1011, // 按键S1状态
|
|
36
|
+
REG_BUTTON_S2: 0x1012, // 按键S2状态
|
|
37
|
+
REG_BUTTON_S3: 0x1013, // 按键S3状态
|
|
38
|
+
REG_BUTTON_S4: 0x1014, // 按键S4状态
|
|
39
|
+
REG_BUTTON_S5: 0x1015, // 按键S5状态
|
|
40
|
+
REG_BUTTON_S6: 0x1016, // 按键S6状态
|
|
41
|
+
REG_BUTTON_S7: 0x1017, // 按键S7状态
|
|
42
|
+
REG_BUTTON_S8: 0x1018, // 按键S8状态
|
|
43
|
+
REG_DOUBLE_CLICK_INTERVAL: 0x1019, // 双击时间间隔
|
|
44
|
+
REG_TRIPLE_CLICK_INTERVAL: 0x101A, // 三击时间间隔
|
|
45
|
+
REG_LONG_PRESS_TIME: 0x101B, // 长按时间
|
|
46
|
+
REG_BACKLIGHT: 0x1020, // 字符背光
|
|
47
|
+
REG_LED1: 0x1021, // LED1指示灯
|
|
48
|
+
REG_LED2: 0x1022, // LED2指示灯
|
|
49
|
+
REG_LED3: 0x1023, // LED3指示灯
|
|
50
|
+
REG_LED4: 0x1024, // LED4指示灯
|
|
51
|
+
REG_LED5: 0x1025, // LED5指示灯
|
|
52
|
+
REG_LED6: 0x1026, // LED6指示灯
|
|
53
|
+
REG_LED7: 0x1027, // LED7指示灯
|
|
54
|
+
REG_LED8: 0x1028, // LED8指示灯
|
|
55
|
+
REG_SENSOR: 0x1040, // 感应信号
|
|
56
|
+
REG_VERSION: 0x1041, // 版本查询
|
|
57
|
+
REG_LONG_PRESS: 0x2050, // 长按寄存器
|
|
58
|
+
|
|
59
|
+
// 按键状态值
|
|
60
|
+
BUTTON_SINGLE_CLICK: 0x0080, // 单击 (bit7=1)
|
|
61
|
+
BUTTON_DOUBLE_CLICK: 0x0040, // 双击 (bit6=1)
|
|
62
|
+
BUTTON_TRIPLE_CLICK: 0x0020, // 三击 (bit5=1)
|
|
63
|
+
BUTTON_LONG_PRESS: 0x0180, // 长按
|
|
64
|
+
BUTTON_LONG_RELEASE: 0x01FE, // 长按松开
|
|
65
|
+
|
|
66
|
+
// 感应信号值
|
|
67
|
+
SENSOR_DETECTED: 0x0080, // 有感应信号
|
|
68
|
+
SENSOR_NONE: 0x0000, // 无感应信号
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 计算 MODBUS CRC16 校验
|
|
72
|
+
* @param {Buffer} buffer - 数据缓冲区
|
|
73
|
+
* @param {number} len - 数据长度(不包含CRC和帧尾)
|
|
74
|
+
* @returns {number} CRC16校验值(低字节在前)
|
|
75
|
+
*/
|
|
76
|
+
calculateCRC16: function(buffer, len) {
|
|
77
|
+
let crc = 0xFFFF;
|
|
78
|
+
for (let i = 0; i < len; i++) {
|
|
79
|
+
crc ^= buffer[i];
|
|
80
|
+
for (let j = 0; j < 8; j++) {
|
|
81
|
+
if (crc & 0x0001) {
|
|
82
|
+
crc = (crc >> 1) ^ 0xA001;
|
|
83
|
+
} else {
|
|
84
|
+
crc >>= 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return crc;
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 构建LED指示灯控制指令
|
|
93
|
+
* @param {number} panelAddr - 面板地址(1-127)
|
|
94
|
+
* @param {number} buttonNumber - 按钮编号(1-8)
|
|
95
|
+
* @param {boolean} state - LED状态(true=开,false=关)
|
|
96
|
+
* @returns {Buffer} 完整的指令帧
|
|
97
|
+
*/
|
|
98
|
+
buildLedControlCommand: function(panelAddr, buttonNumber, state) {
|
|
99
|
+
const buffer = Buffer.alloc(9);
|
|
100
|
+
let idx = 0;
|
|
101
|
+
|
|
102
|
+
// 面板地址
|
|
103
|
+
buffer[idx++] = panelAddr;
|
|
104
|
+
// 命令码:写入单个寄存器
|
|
105
|
+
buffer[idx++] = this.CMD_WRITE;
|
|
106
|
+
// 寄存器地址(LED1=0x1021, LED2=0x1022, ...)
|
|
107
|
+
const regAddr = 0x1020 + buttonNumber;
|
|
108
|
+
buffer[idx++] = (regAddr >> 8) & 0xFF; // 高字节
|
|
109
|
+
buffer[idx++] = regAddr & 0xFF; // 低字节
|
|
110
|
+
// 数据(00 01=开,00 00=关)
|
|
111
|
+
buffer[idx++] = 0x00;
|
|
112
|
+
buffer[idx++] = state ? 0x01 : 0x00;
|
|
113
|
+
|
|
114
|
+
// 计算CRC16
|
|
115
|
+
const crc = this.calculateCRC16(buffer, 6);
|
|
116
|
+
buffer[idx++] = crc & 0xFF; // CRC低字节
|
|
117
|
+
buffer[idx++] = (crc >> 8) & 0xFF; // CRC高字节
|
|
118
|
+
// 帧尾
|
|
119
|
+
buffer[idx++] = this.FRAME_TAIL;
|
|
120
|
+
|
|
121
|
+
return buffer;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 构建批量LED控制指令(使用0x25命令码)
|
|
126
|
+
* @param {number} panelAddr - 面板地址(1-127)
|
|
127
|
+
* @param {Array<boolean>} ledStates - LED状态数组(最多8个)
|
|
128
|
+
* @param {boolean} backlight - 字符背光状态
|
|
129
|
+
* @returns {Buffer} 完整的指令帧
|
|
130
|
+
*/
|
|
131
|
+
buildBatchLedCommand: function(panelAddr, ledStates, backlight = true) {
|
|
132
|
+
const buffer = Buffer.alloc(11);
|
|
133
|
+
let idx = 0;
|
|
134
|
+
|
|
135
|
+
// 广播地址
|
|
136
|
+
buffer[idx++] = this.BROADCAST_ADDR;
|
|
137
|
+
// 命令码:批量控制
|
|
138
|
+
buffer[idx++] = this.CMD_BATCH_CONTROL;
|
|
139
|
+
// 面板开始地址(高字节、低字节)
|
|
140
|
+
buffer[idx++] = 0x00;
|
|
141
|
+
buffer[idx++] = panelAddr;
|
|
142
|
+
// 连续面板数量(高字节、低字节)
|
|
143
|
+
buffer[idx++] = 0x00;
|
|
144
|
+
buffer[idx++] = 0x01;
|
|
145
|
+
|
|
146
|
+
// 面板数据(2字节)
|
|
147
|
+
// 高字节:B8=背光,B9-B12=继电器(预留)
|
|
148
|
+
// 低字节:B0-B5=LED1-LED6
|
|
149
|
+
let highByte = backlight ? 0x01 : 0x00; // B8=背光
|
|
150
|
+
let lowByte = 0x00;
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < Math.min(ledStates.length, 6); i++) {
|
|
153
|
+
if (ledStates[i]) {
|
|
154
|
+
lowByte |= (1 << i);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
buffer[idx++] = highByte;
|
|
159
|
+
buffer[idx++] = lowByte;
|
|
160
|
+
|
|
161
|
+
// 计算CRC16
|
|
162
|
+
const crc = this.calculateCRC16(buffer, 8);
|
|
163
|
+
buffer[idx++] = crc & 0xFF; // CRC低字节
|
|
164
|
+
buffer[idx++] = (crc >> 8) & 0xFF; // CRC高字节
|
|
165
|
+
// 帧尾
|
|
166
|
+
buffer[idx++] = this.FRAME_TAIL;
|
|
167
|
+
|
|
168
|
+
return buffer;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 构建字符背光控制指令
|
|
173
|
+
* @param {number} panelAddr - 面板地址(1-127)
|
|
174
|
+
* @param {boolean} state - 背光状态(true=开,false=关)
|
|
175
|
+
* @returns {Buffer} 完整的指令帧
|
|
176
|
+
*/
|
|
177
|
+
buildBacklightCommand: function(panelAddr, state) {
|
|
178
|
+
const buffer = Buffer.alloc(9);
|
|
179
|
+
let idx = 0;
|
|
180
|
+
|
|
181
|
+
buffer[idx++] = panelAddr;
|
|
182
|
+
buffer[idx++] = this.CMD_WRITE;
|
|
183
|
+
buffer[idx++] = 0x10; // 寄存器地址高字节
|
|
184
|
+
buffer[idx++] = 0x20; // 寄存器地址低字节(0x1020)
|
|
185
|
+
buffer[idx++] = 0x00;
|
|
186
|
+
buffer[idx++] = state ? 0x01 : 0x00;
|
|
187
|
+
|
|
188
|
+
const crc = this.calculateCRC16(buffer, 6);
|
|
189
|
+
buffer[idx++] = crc & 0xFF;
|
|
190
|
+
buffer[idx++] = (crc >> 8) & 0xFF;
|
|
191
|
+
buffer[idx++] = this.FRAME_TAIL;
|
|
192
|
+
|
|
193
|
+
return buffer;
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 解析接收到的协议帧
|
|
198
|
+
* @param {Buffer} buffer - 接收到的数据
|
|
199
|
+
* @returns {Object|null} 解析结果或null
|
|
200
|
+
*/
|
|
201
|
+
parseFrame: function(buffer) {
|
|
202
|
+
if (!buffer || buffer.length < 9) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 查找帧尾位置
|
|
207
|
+
let endIndex = -1;
|
|
208
|
+
for (let i = buffer.length - 1; i >= 0; i--) {
|
|
209
|
+
if (buffer[i] === this.FRAME_TAIL) {
|
|
210
|
+
endIndex = i;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (endIndex === -1 || endIndex < 8) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 提取帧数据(从开头到帧尾)
|
|
220
|
+
const frameBuffer = buffer.slice(0, endIndex + 1);
|
|
221
|
+
|
|
222
|
+
// 验证CRC16
|
|
223
|
+
const dataLen = frameBuffer.length - 3; // 不包含CRC(2字节)和帧尾(1字节)
|
|
224
|
+
const receivedCRC = frameBuffer[dataLen] | (frameBuffer[dataLen + 1] << 8);
|
|
225
|
+
const calculatedCRC = this.calculateCRC16(frameBuffer, dataLen);
|
|
226
|
+
|
|
227
|
+
// CRC校验(宽松模式:按键事件允许CRC不匹配)
|
|
228
|
+
const cmdCode = frameBuffer[1];
|
|
229
|
+
const isButtonEvent = (cmdCode === this.CMD_BUTTON_EVENT || cmdCode === this.CMD_READ);
|
|
230
|
+
|
|
231
|
+
if (receivedCRC !== calculatedCRC && !isButtonEvent) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 解析帧
|
|
236
|
+
const frame = {
|
|
237
|
+
panelAddr: frameBuffer[0],
|
|
238
|
+
cmdCode: frameBuffer[1],
|
|
239
|
+
regAddrHigh: frameBuffer[2],
|
|
240
|
+
regAddrLow: frameBuffer[3],
|
|
241
|
+
regAddr: (frameBuffer[2] << 8) | frameBuffer[3],
|
|
242
|
+
dataHigh: frameBuffer[4],
|
|
243
|
+
dataLow: frameBuffer[5],
|
|
244
|
+
data: (frameBuffer[4] << 8) | frameBuffer[5],
|
|
245
|
+
crc: receivedCRC,
|
|
246
|
+
frameLength: frameBuffer.length
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return frame;
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 解析所有协议帧(处理粘包)
|
|
254
|
+
* @param {Buffer} buffer - 接收到的数据(可能包含多个帧)
|
|
255
|
+
* @returns {Array} 解析出的所有帧数组
|
|
256
|
+
*/
|
|
257
|
+
parseAllFrames: function(buffer) {
|
|
258
|
+
const frames = [];
|
|
259
|
+
if (!buffer || buffer.length < 9) {
|
|
260
|
+
return frames;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let offset = 0;
|
|
264
|
+
let maxIterations = 10;
|
|
265
|
+
|
|
266
|
+
while (offset < buffer.length && maxIterations > 0) {
|
|
267
|
+
maxIterations--;
|
|
268
|
+
|
|
269
|
+
// 查找帧尾
|
|
270
|
+
let endIndex = -1;
|
|
271
|
+
for (let i = offset; i < buffer.length; i++) {
|
|
272
|
+
if (buffer[i] === this.FRAME_TAIL) {
|
|
273
|
+
endIndex = i;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (endIndex === -1) {
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 检查帧长度(最小9字节)
|
|
283
|
+
const frameLen = endIndex - offset + 1;
|
|
284
|
+
if (frameLen < 9) {
|
|
285
|
+
offset = endIndex + 1;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 提取帧
|
|
290
|
+
const frameBuffer = buffer.slice(offset, endIndex + 1);
|
|
291
|
+
const frame = this.parseFrame(frameBuffer);
|
|
292
|
+
|
|
293
|
+
if (frame) {
|
|
294
|
+
frames.push(frame);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
offset = endIndex + 1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return frames;
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 检测是否是按键事件
|
|
305
|
+
* @param {Object} frame - 解析后的帧
|
|
306
|
+
* @returns {Object|null} 按键事件信息或null
|
|
307
|
+
*/
|
|
308
|
+
detectButtonEvent: function(frame) {
|
|
309
|
+
if (!frame) return null;
|
|
310
|
+
|
|
311
|
+
// 检查命令码是否是按键事件上报(0x20)或读取(0x03)
|
|
312
|
+
if (frame.cmdCode !== this.CMD_BUTTON_EVENT && frame.cmdCode !== this.CMD_READ) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 检查寄存器地址是否在按键范围内(0x1011-0x1018)
|
|
317
|
+
if (frame.regAddr >= this.REG_BUTTON_S1 && frame.regAddr <= this.REG_BUTTON_S8) {
|
|
318
|
+
// 按键事件
|
|
319
|
+
const buttonNumber = frame.regAddr - this.REG_BUTTON_S1 + 1; // 1-8
|
|
320
|
+
const data = frame.data;
|
|
321
|
+
|
|
322
|
+
// 判断按键类型
|
|
323
|
+
let eventType = 'unknown';
|
|
324
|
+
let state = true; // 默认触发状态
|
|
325
|
+
|
|
326
|
+
if (data === this.BUTTON_SINGLE_CLICK || (data & 0x0080) !== 0) {
|
|
327
|
+
eventType = 'single_click';
|
|
328
|
+
} else if (data === this.BUTTON_DOUBLE_CLICK || (data & 0x0040) !== 0) {
|
|
329
|
+
eventType = 'double_click';
|
|
330
|
+
} else if (data === this.BUTTON_TRIPLE_CLICK || (data & 0x0020) !== 0) {
|
|
331
|
+
eventType = 'triple_click';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
type: 'button',
|
|
336
|
+
panelAddr: frame.panelAddr,
|
|
337
|
+
buttonNumber: buttonNumber,
|
|
338
|
+
eventType: eventType,
|
|
339
|
+
state: state,
|
|
340
|
+
data: data,
|
|
341
|
+
raw: frame
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 检查是否是长按事件(寄存器0x2050)
|
|
346
|
+
if (frame.regAddr === this.REG_LONG_PRESS) {
|
|
347
|
+
const buttonNumber = frame.dataHigh; // 按键编号在高字节
|
|
348
|
+
const pressState = frame.dataLow; // 按下状态在低字节
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
type: 'button',
|
|
352
|
+
panelAddr: frame.panelAddr,
|
|
353
|
+
buttonNumber: buttonNumber,
|
|
354
|
+
eventType: pressState === 0xFE ? 'long_press_release' : 'long_press',
|
|
355
|
+
state: pressState !== 0xFE,
|
|
356
|
+
data: frame.data,
|
|
357
|
+
raw: frame
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 检查是否是感应信号(寄存器0x1040)
|
|
362
|
+
if (frame.regAddr === this.REG_SENSOR) {
|
|
363
|
+
const hasPresence = (frame.data & 0x0080) !== 0;
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
type: 'sensor',
|
|
367
|
+
panelAddr: frame.panelAddr,
|
|
368
|
+
sensorType: 'presence',
|
|
369
|
+
state: hasPresence,
|
|
370
|
+
data: frame.data,
|
|
371
|
+
raw: frame
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return null;
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* 判断是否是Clowire协议帧
|
|
380
|
+
* @param {Buffer} buffer - 接收到的数据
|
|
381
|
+
* @returns {boolean} 是否是Clowire协议
|
|
382
|
+
*/
|
|
383
|
+
isClowireFrame: function(buffer) {
|
|
384
|
+
if (!buffer || buffer.length < 9) {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 检查帧尾是否是0xAA
|
|
389
|
+
const lastByte = buffer[buffer.length - 1];
|
|
390
|
+
if (lastByte !== this.FRAME_TAIL) {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 检查命令码是否是Clowire支持的命令
|
|
395
|
+
const cmdCode = buffer[1];
|
|
396
|
+
const validCmds = [this.CMD_READ, this.CMD_WRITE, this.CMD_BUTTON_EVENT, this.CMD_BATCH_CONTROL];
|
|
397
|
+
if (!validCmds.includes(cmdCode)) {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// 检查寄存器地址是否在Clowire范围内(0x1000-0x2FFF)
|
|
402
|
+
const regAddr = (buffer[2] << 8) | buffer[3];
|
|
403
|
+
if (regAddr < 0x1000 || regAddr > 0x2FFF) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
};
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
buttonType: {value: "switch"}, // 按钮类型:switch=开关模式,scene=场景模式,mesh=Mesh模式
|
|
16
16
|
switchId: {value: 0, validate: RED.validators.number()},
|
|
17
17
|
buttonNumber: {value: 1, validate: RED.validators.number()},
|
|
18
|
+
|
|
18
19
|
// Mesh模式配置
|
|
19
20
|
meshMacAddress: {value: ""}, // Mesh设备MAC地址
|
|
20
21
|
meshShortAddress: {value: 0}, // Mesh设备短地址
|
|
@@ -39,7 +40,8 @@
|
|
|
39
40
|
// 开关模式和场景模式显示
|
|
40
41
|
const coilDisplay = this.targetCoilNumber || 1;
|
|
41
42
|
const btnLabel = this.buttonNumber == 15 ? '背光灯' : `按钮${this.buttonNumber}`;
|
|
42
|
-
|
|
43
|
+
const brandLabel = this.switchBrand === 'clowire' ? 'Clowire' : '开关';
|
|
44
|
+
return this.name || `${brandLabel}${this.switchId}-${btnLabel} → 继电器${this.targetSlaveAddress}-${coilDisplay}路`;
|
|
43
45
|
}
|
|
44
46
|
},
|
|
45
47
|
oneditprepare: function() {
|
|
@@ -54,6 +56,17 @@
|
|
|
54
56
|
}
|
|
55
57
|
});
|
|
56
58
|
|
|
59
|
+
// 品牌切换控制
|
|
60
|
+
$("#node-input-switchBrand").on("change", function() {
|
|
61
|
+
const brand = $(this).val();
|
|
62
|
+
if (brand === 'clowire') {
|
|
63
|
+
// Clowire默认地址是2
|
|
64
|
+
if ($("#node-input-switchId").val() === '0') {
|
|
65
|
+
$("#node-input-switchId").val(2);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
57
70
|
// 按钮类型切换控制显示/隐藏
|
|
58
71
|
$("#node-input-buttonType").on("change", function() {
|
|
59
72
|
const buttonType = $(this).val();
|
|
@@ -268,12 +281,13 @@
|
|
|
268
281
|
<label for="node-input-switchBrand" style="width: 110px;"><i class="fa fa-trademark"></i> 面板品牌</label>
|
|
269
282
|
<select id="node-input-switchBrand" style="width: 200px;">
|
|
270
283
|
<option value="symi">亖米(Symi)</option>
|
|
271
|
-
<option value="
|
|
272
|
-
<option value="other2" disabled style="color: #999;">其他品牌2(待开发)</option>
|
|
284
|
+
<option value="clowire">Clowire(克伦威尔)</option>
|
|
273
285
|
</select>
|
|
274
286
|
<span style="margin-left: 10px; font-size: 11px; color: #666; font-style: italic;">支持1-8键开关</span>
|
|
275
287
|
</div>
|
|
276
288
|
|
|
289
|
+
|
|
290
|
+
|
|
277
291
|
<div class="form-row">
|
|
278
292
|
<label for="node-input-buttonType" style="width: 110px;"><i class="fa fa-cog"></i> 按钮类型</label>
|
|
279
293
|
<select id="node-input-buttonType" style="width: 200px;">
|
|
@@ -388,8 +402,8 @@
|
|
|
388
402
|
配置说明
|
|
389
403
|
</div>
|
|
390
404
|
<div style="margin-bottom: 8px; padding-left: 10px;">
|
|
391
|
-
<strong>面板品牌:</strong><span style="color: #555;"
|
|
392
|
-
<strong>开关ID:</strong><span style="color: #555;">物理面板RS-485
|
|
405
|
+
<strong>面板品牌:</strong><span style="color: #555;">亖米/Clowire协议,支持1-8键开关</span><br>
|
|
406
|
+
<strong>开关ID:</strong><span style="color: #555;">物理面板RS-485地址(亖米:0-255,Clowire:1-127,默认2)</span><br>
|
|
393
407
|
<strong>按钮编号:</strong><span style="color: #555;">面板按键序号(1-8)</span><br>
|
|
394
408
|
<strong>从站地址:</strong><span style="color: #555;">Modbus继电器地址(10-19)</span><br>
|
|
395
409
|
<strong>线圈编号:</strong><span style="color: #555;">继电器通道号(0-31)</span>
|
|
@@ -399,7 +413,8 @@
|
|
|
399
413
|
配置示例
|
|
400
414
|
</div>
|
|
401
415
|
<div style="background: white; padding: 8px; border-radius: 4px; border: 1px solid #c8e6c9; font-size: 11px; color: #555;">
|
|
402
|
-
<strong
|
|
416
|
+
<strong>亖米:</strong>开关ID=0,按钮1 → 控制继电器10的线圈0<br>
|
|
417
|
+
<strong>Clowire:</strong>开关ID=2,按钮1 → 控制继电器10的线圈0<br>
|
|
403
418
|
<strong>MQTT:</strong><code style="background: #f5f5f5; padding: 2px 6px; border-radius: 3px; color: #e91e63; font-family: monospace;">modbus/relay/10/0/set</code>
|
|
404
419
|
</div>
|
|
405
420
|
</div>
|
|
@@ -2,6 +2,7 @@ module.exports = function(RED) {
|
|
|
2
2
|
"use strict";
|
|
3
3
|
const mqtt = require("mqtt");
|
|
4
4
|
const protocol = require("./lightweight-protocol");
|
|
5
|
+
const clowireProtocol = require("./clowire-protocol");
|
|
5
6
|
const meshProtocol = require("./mesh-protocol")(RED);
|
|
6
7
|
const storage = require('node-persist');
|
|
7
8
|
const path = require('path');
|
|
@@ -373,7 +374,7 @@ module.exports = function(RED) {
|
|
|
373
374
|
mqttPassword: node.mqttServerConfig ? (node.mqttServerConfig.credentials ? node.mqttServerConfig.credentials.password : "") : "",
|
|
374
375
|
mqttBaseTopic: node.mqttServerConfig ? node.mqttServerConfig.baseTopic : "modbus/relay",
|
|
375
376
|
// 开关面板配置
|
|
376
|
-
switchBrand: config.switchBrand || "symi", //
|
|
377
|
+
switchBrand: config.switchBrand || "symi", // 面板品牌(默认亖米,支持clowire)
|
|
377
378
|
buttonType: config.buttonType || "switch", // 按钮类型:switch=开关模式,scene=场景模式,mesh=Mesh模式
|
|
378
379
|
switchId: parseInt(config.switchId) || 0, // 开关ID(0-255,物理面板地址)
|
|
379
380
|
buttonNumber: parseInt(config.buttonNumber) || 1, // 按钮编号(1-8)
|
|
@@ -782,7 +783,13 @@ module.exports = function(RED) {
|
|
|
782
783
|
return;
|
|
783
784
|
}
|
|
784
785
|
|
|
785
|
-
//
|
|
786
|
+
// 根据品牌选择协议解析
|
|
787
|
+
if (node.config.switchBrand === 'clowire') {
|
|
788
|
+
node.handleClowireData(data);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// 亖米协议:使用parseAllFrames处理粘包,解析所有帧
|
|
786
793
|
const frames = protocol.parseAllFrames(data);
|
|
787
794
|
if (!frames || frames.length === 0) {
|
|
788
795
|
return; // 静默忽略无效帧
|
|
@@ -864,6 +871,106 @@ module.exports = function(RED) {
|
|
|
864
871
|
}
|
|
865
872
|
};
|
|
866
873
|
|
|
874
|
+
// 处理Clowire协议数据
|
|
875
|
+
node.handleClowireData = function(data) {
|
|
876
|
+
try {
|
|
877
|
+
// 检查是否是Clowire协议帧
|
|
878
|
+
if (!clowireProtocol.isClowireFrame(data)) {
|
|
879
|
+
return; // 静默忽略非Clowire帧
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// 解析所有帧
|
|
883
|
+
const frames = clowireProtocol.parseAllFrames(data);
|
|
884
|
+
if (!frames || frames.length === 0) {
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// 处理每一个帧
|
|
889
|
+
for (const frame of frames) {
|
|
890
|
+
// 检测按键或感应事件
|
|
891
|
+
const event = clowireProtocol.detectButtonEvent(frame);
|
|
892
|
+
if (!event) {
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// 检查是否是我们监听的面板
|
|
897
|
+
if (event.panelAddr !== node.config.switchId) {
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// 处理感应器事件(通道0x0F,buttonNumber=15)
|
|
902
|
+
if (event.type === 'sensor' && node.config.buttonNumber === 15) {
|
|
903
|
+
// 感应器模式:有人/无人状态,复用按键背光灯配置
|
|
904
|
+
const debounceKey = `clowire-sensor-${node.config.switchId}-${node.config.targetSlaveAddress}`;
|
|
905
|
+
const now = Date.now();
|
|
906
|
+
const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
|
|
907
|
+
|
|
908
|
+
// 感应器防抖:500ms内只触发一次
|
|
909
|
+
if (now - lastTriggerTime < 500) {
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
globalDebounceCache.set(debounceKey, now);
|
|
913
|
+
|
|
914
|
+
node.debug(`[Clowire感应] 面板${node.config.switchId} 感应状态: ${event.state ? '有人' : '无人'}`);
|
|
915
|
+
|
|
916
|
+
// 更新状态并发送命令
|
|
917
|
+
node.currentState = event.state;
|
|
918
|
+
node.sendMqttCommand(event.state);
|
|
919
|
+
|
|
920
|
+
// 输出消息
|
|
921
|
+
node.send({
|
|
922
|
+
payload: event.state,
|
|
923
|
+
topic: `clowire_sensor_${node.config.switchId}`,
|
|
924
|
+
switchId: node.config.switchId,
|
|
925
|
+
sensorType: 'presence',
|
|
926
|
+
targetSlave: node.config.targetSlaveAddress,
|
|
927
|
+
targetCoil: node.config.targetCoilNumber
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// 处理按键事件
|
|
934
|
+
if (event.type === 'button' && event.buttonNumber === node.config.buttonNumber) {
|
|
935
|
+
// 全局防抖
|
|
936
|
+
const debounceKey = `clowire-${node.config.switchId}-${node.config.buttonNumber}-${node.config.targetSlaveAddress}`;
|
|
937
|
+
const now = Date.now();
|
|
938
|
+
const lastTriggerTime = globalDebounceCache.get(debounceKey) || 0;
|
|
939
|
+
|
|
940
|
+
// 统一防抖时间200ms(单击、双击、长按都当作单击处理)
|
|
941
|
+
if (now - lastTriggerTime < 200) {
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
globalDebounceCache.set(debounceKey, now);
|
|
945
|
+
|
|
946
|
+
// 设置触发源
|
|
947
|
+
if (node.serialPortConfig && typeof node.serialPortConfig.setTriggerSource === 'function') {
|
|
948
|
+
node.serialPortConfig.setTriggerSource(node.config.switchId);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
node.debug(`[Clowire按键] 面板${node.config.switchId} 按键${node.config.buttonNumber} ${event.eventType}(当作单击处理)`);
|
|
952
|
+
|
|
953
|
+
// 所有按键事件都当作单击处理:切换状态
|
|
954
|
+
node.currentState = !node.currentState;
|
|
955
|
+
node.sendMqttCommand(node.currentState);
|
|
956
|
+
|
|
957
|
+
// 输出消息
|
|
958
|
+
node.send({
|
|
959
|
+
payload: node.currentState,
|
|
960
|
+
topic: `clowire_switch_${node.config.switchId}_btn${node.config.buttonNumber}`,
|
|
961
|
+
switchId: node.config.switchId,
|
|
962
|
+
button: node.config.buttonNumber,
|
|
963
|
+
eventType: 'single_click', // 统一报告为单击
|
|
964
|
+
targetSlave: node.config.targetSlaveAddress,
|
|
965
|
+
targetCoil: node.config.targetCoilNumber
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
} catch (err) {
|
|
970
|
+
node.error(`解析Clowire数据失败: ${err.message}`);
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
|
|
867
974
|
// 处理Mesh协议数据
|
|
868
975
|
node.handleMeshData = function(data) {
|
|
869
976
|
try {
|
|
@@ -1157,6 +1264,34 @@ module.exports = function(RED) {
|
|
|
1157
1264
|
|
|
1158
1265
|
const hexStr = command.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ');
|
|
1159
1266
|
node.debug(`[Mesh LED] 控制帧已构建:[${hexStr}]`);
|
|
1267
|
+
} else if (node.config.switchBrand === 'clowire') {
|
|
1268
|
+
// Clowire品牌:使用Clowire协议发送LED反馈
|
|
1269
|
+
const buttonNumber = node.config.buttonNumber;
|
|
1270
|
+
|
|
1271
|
+
// 防止重复发送
|
|
1272
|
+
if (node.lastSentLedState.value === state && (now - node.lastSentLedState.timestamp) < 50) {
|
|
1273
|
+
node.debug(`跳过重复LED反馈(状态未变化,间隔${now - node.lastSentLedState.timestamp}ms)`);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
node.lastSentLedState.value = state;
|
|
1278
|
+
node.lastSentLedState.timestamp = now;
|
|
1279
|
+
|
|
1280
|
+
node.debug(`[Clowire LED发送] 面板${node.config.switchId} 按钮${buttonNumber} = ${state}`);
|
|
1281
|
+
|
|
1282
|
+
command = clowireProtocol.buildLedControlCommand(
|
|
1283
|
+
node.config.switchId,
|
|
1284
|
+
buttonNumber,
|
|
1285
|
+
state
|
|
1286
|
+
);
|
|
1287
|
+
|
|
1288
|
+
if (!command) {
|
|
1289
|
+
node.error(`构建Clowire LED控制帧失败`);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const hexStr = command.toString('hex').toUpperCase().match(/.{1,2}/g).join(' ');
|
|
1294
|
+
node.debug(`[Clowire LED] 控制帧已构建:[${hexStr}]`);
|
|
1160
1295
|
} else {
|
|
1161
1296
|
// RS-485模式:使用轻量级协议
|
|
1162
1297
|
const deviceAddr = node.buttonDeviceAddr;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-symi-modbus",
|
|
3
|
-
"version": "2.9.
|
|
4
|
-
"description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit
|
|
3
|
+
"version": "2.9.8",
|
|
4
|
+
"description": "Node-RED Modbus节点,支持TCP/串口通信、串口自动搜索、多设备轮询、可选MQTT集成(支持纯本地模式和MQTT模式)、Home Assistant自动发现、HomeKit网桥、可视化控制看板、自定义协议转换和物理开关面板双向同步(支持亖米/Clowire品牌),工控机长期稳定运行",
|
|
5
5
|
"main": "nodes/modbus-master.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "echo \"Error: no test specified\" && exit 1"
|