node-red-contrib-symi-mesh 1.8.9 → 1.8.11
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 +30 -0
- package/lib/tcp-client.js +2 -1
- package/nodes/symi-gateway.js +10 -0
- package/nodes/symi-knx-bridge.html +9 -5
- package/nodes/symi-knx-bridge.js +94 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -704,6 +704,36 @@ node-red-contrib-symi-mesh/
|
|
|
704
704
|
|
|
705
705
|
## 更新日志
|
|
706
706
|
|
|
707
|
+
### v1.8.11 (2026-01-16)
|
|
708
|
+
|
|
709
|
+
**核心稳定性修复**:
|
|
710
|
+
- **僵尸节点彻底清除**:修复了在删除或修改 KNX 场景映射并重新部署后,旧的配置逻辑仍在后台运行的问题。
|
|
711
|
+
- **原因分析**:旧节点实例销毁时,未能完全解绑网关事件监听器,导致“僵尸节点”继续响应事件。
|
|
712
|
+
- **解决方案**:引入 `node.isClosed` 标志位,强制拦截销毁后的所有逻辑执行;同时修复了网关连接状态监听器的内存泄漏问题。
|
|
713
|
+
- **内存泄漏修复**:将所有匿名事件监听器改为具名函数,确保在节点关闭时能被正确移除,防止多次部署后的内存累积。
|
|
714
|
+
- **空指针异常防护**:在 `symi-gateway` 中增加了多处连接状态检查,防止网关断开后后台任务访问已销毁的客户端对象,彻底消除 `Cannot read properties of null (reading 'sendFrame')` 报错刷屏。
|
|
715
|
+
- **UI 显示优化**:修复了 KNX 开关类型在配置列表中错误显示“扩展”列数据的问题,现在开关类型的扩展列将正确显示为“-”。
|
|
716
|
+
|
|
717
|
+
### v1.8.10 (2026-01-16)
|
|
718
|
+
|
|
719
|
+
**新增功能**:
|
|
720
|
+
- **KNX 场景联动支持**:新增“场景”设备类型,支持 Mesh 开关按键与 KNX 场景的双向联动。
|
|
721
|
+
- **双向同步**:
|
|
722
|
+
- KNX 触发场景 -> 自动控制 Mesh 开关 (ON/OFF)
|
|
723
|
+
- Mesh 按键操作 -> 自动触发 KNX 场景 (发送场景号)
|
|
724
|
+
- **防死循环**:针对场景触发的单向特性,特别优化了防环路机制,确保 Mesh 状态变化后不会再次触发场景发送。
|
|
725
|
+
- **配置方式**:在 KNX 桥接节点中添加 KNX 实体时选择“场景”类型,需要配置以下三个参数:
|
|
726
|
+
1. **KNX组地址**:场景控制的组地址(如 1/1/1)。
|
|
727
|
+
2. **场景号(1-64)**:KNX 标准场景编号(对应 DPT 17.001 0-63 值)。
|
|
728
|
+
3. **绑定Mesh开关状态**:**必填项**。设置当触发该 KNX 场景时,关联的 Mesh 开关应变为“开”还是“关”。
|
|
729
|
+
- **为什么必须指定状态?** 场景通常是确定的状态(如“离家”=全关),而不是翻转(Toggle)。如果使用翻转,当灯已经是关闭状态时,再次触发“离家”会导致灯打开,这违背了场景的初衷。
|
|
730
|
+
- **逻辑说明**:
|
|
731
|
+
- **KNX -> Mesh**:收到 KNX 场景号 -> Mesh 开关执行指定状态(如设为 0,则执行关)。
|
|
732
|
+
- **Mesh -> KNX**:Mesh 开关变为指定状态(如变为关) -> 发送 KNX 场景号。
|
|
733
|
+
- **日志优化**:
|
|
734
|
+
- **错误限流**:当 Mesh 网关离线时,KNX 同步节点会自动抑制重复的连接错误日志(1分钟内只显示一次),避免日志刷屏。
|
|
735
|
+
- **网络噪音过滤**:TCP 客户端自动过滤常见的网络连接错误(如 EHOSTUNREACH),减少生产环境的日志干扰。
|
|
736
|
+
|
|
707
737
|
### v1.8.9 (2026-01-14)
|
|
708
738
|
|
|
709
739
|
**KNX 协议回显消除算法升级**:
|
package/lib/tcp-client.js
CHANGED
|
@@ -110,7 +110,8 @@ class TCPClient extends EventEmitter {
|
|
|
110
110
|
}
|
|
111
111
|
// 只在首次连接或重要错误时记录,避免大量重复日志
|
|
112
112
|
// ECONNRESET通常是网络波动,不记录错误
|
|
113
|
-
|
|
113
|
+
// EHOSTUNREACH/ETIMEDOUT 也是常见网络错误,不记录
|
|
114
|
+
else if (!this.connected && !['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ETIMEDOUT'].includes(error.code)) {
|
|
114
115
|
this.logger.log('TCP client error: ' + error.message);
|
|
115
116
|
}
|
|
116
117
|
|
package/nodes/symi-gateway.js
CHANGED
|
@@ -495,6 +495,10 @@ module.exports = function(RED) {
|
|
|
495
495
|
}
|
|
496
496
|
|
|
497
497
|
for (const attr of queryAttrs) {
|
|
498
|
+
if (!this.client) {
|
|
499
|
+
this.error(`查询设备${device.name}失败: 网关未连接 (Client is null)`);
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
498
502
|
const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
|
|
499
503
|
await this.client.sendFrame(frame, 2);
|
|
500
504
|
await this.sleep(150); // 增加延迟确保设备有时间响应
|
|
@@ -522,6 +526,7 @@ module.exports = function(RED) {
|
|
|
522
526
|
this.log(`[三合一检测] 查询 ${device.name} 完整状态...`);
|
|
523
527
|
const threeInOneAttrs = [0x02, 0x16, 0x1B, 0x1C, 0x1D, 0x6A, 0x6C];
|
|
524
528
|
for (const attr of threeInOneAttrs) {
|
|
529
|
+
if (!this.client) break;
|
|
525
530
|
const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
|
|
526
531
|
await this.client.sendFrame(frame, 2);
|
|
527
532
|
await this.sleep(150);
|
|
@@ -545,6 +550,7 @@ module.exports = function(RED) {
|
|
|
545
550
|
// 查询温控器状态
|
|
546
551
|
const tempCtrlAttrs = [0x02, 0x16, 0x1B, 0x1C, 0x1D];
|
|
547
552
|
for (const attr of tempCtrlAttrs) {
|
|
553
|
+
if (!this.client) break;
|
|
548
554
|
const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
|
|
549
555
|
await this.client.sendFrame(frame, 2);
|
|
550
556
|
await this.sleep(150);
|
|
@@ -567,6 +573,10 @@ module.exports = function(RED) {
|
|
|
567
573
|
this.log('启用设备状态上报功能...');
|
|
568
574
|
for (const device of devices) {
|
|
569
575
|
try {
|
|
576
|
+
if (!this.client) {
|
|
577
|
+
this.error('网关连接断开,停止启用状态上报');
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
570
580
|
// 启用状态上报:msg_type=0x10, param=0x01
|
|
571
581
|
const frame = this.protocolHandler.buildDeviceControlFrame(device.networkAddress, 0x10, Buffer.from([0x01]));
|
|
572
582
|
await this.client.sendFrame(frame, 2);
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
let mappings = [], devices = [], knxEntities = [];
|
|
29
|
-
const typeLabels = {switch:'开关',light_mono:'单调',light_cct:'双调',light_rgb:'RGB',light_rgbcw:'RGBCW',cover:'窗帘',climate:'空调',fresh_air:'新风',floor_heating:'地暖'};
|
|
29
|
+
const typeLabels = {switch:'开关',light_mono:'单调',light_cct:'双调',light_rgb:'RGB',light_rgbcw:'RGBCW',cover:'窗帘',climate:'空调',fresh_air:'新风',floor_heating:'地暖',scene:'场景'};
|
|
30
30
|
|
|
31
31
|
try { mappings = JSON.parse(node.mappings || '[]'); } catch(e) { mappings = []; }
|
|
32
32
|
try { knxEntities = JSON.parse(node.knxEntities || '[]'); } catch(e) { knxEntities = []; }
|
|
@@ -64,7 +64,10 @@
|
|
|
64
64
|
# 新风 (开关, 风速)
|
|
65
65
|
全屋新风 fresh_air 4/1/1 4/2/1
|
|
66
66
|
# 地暖 (开关, 温度, 当前温度)
|
|
67
|
-
客厅地暖 floor_heating 5/1/1 5/2/1 5/3/1
|
|
67
|
+
客厅地暖 floor_heating 5/1/1 5/2/1 5/3/1
|
|
68
|
+
# 场景 (地址, 编号, 动作1开0关)
|
|
69
|
+
回家模式 scene 0/0/1 1 1
|
|
70
|
+
离家模式 scene 0/0/1 2 0`;
|
|
68
71
|
const blob = new Blob([tpl], {type:'text/plain;charset=utf-8'});
|
|
69
72
|
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
|
70
73
|
a.download = 'knx-template.txt'; a.click();
|
|
@@ -76,7 +79,7 @@
|
|
|
76
79
|
if (!knxEntities.length) { c.html('<div class="tips">点击"导入"或"添加"管理KNX实体</div>'); return; }
|
|
77
80
|
let h = '<table class="tbl"><tr><th>名称</th><th>类型</th><th>命令</th><th>状态</th><th>扩展</th><th style="width:60px">操作</th></tr>';
|
|
78
81
|
knxEntities.forEach((e,i) => {
|
|
79
|
-
const ext = [e.ext1,e.ext2,e.ext3].filter(x=>x).join(',');
|
|
82
|
+
const ext = (e.type === 'switch') ? '-' : [e.ext1,e.ext2,e.ext3].filter(x=>x).join(',');
|
|
80
83
|
const inv = e.type==='cover' ? '<input type="checkbox" class="e-inv" title="位置反转"'+(e.invert?' checked':'')+'>' : '';
|
|
81
84
|
h += '<tr data-ei="'+i+'"><td>'+e.name+'</td><td>'+(typeLabels[e.type]||e.type)+inv+'</td><td>'+e.cmdAddr+'</td><td>'+(e.statusAddr||'-')+'</td><td>'+(ext||'-')+'</td><td><button class="red-ui-button red-ui-button-small e-edit" title="编辑"><i class="fa fa-pencil"></i></button> <button class="red-ui-button red-ui-button-small e-del" title="删除"><i class="fa fa-times"></i></button></td></tr>';
|
|
82
85
|
});
|
|
@@ -108,7 +111,8 @@
|
|
|
108
111
|
'cover': ['上下地址*','位置地址','停止地址'],
|
|
109
112
|
'climate': ['开关地址*','温度地址','模式地址','风速地址','当前温度'],
|
|
110
113
|
'fresh_air': ['开关地址*','风速地址'],
|
|
111
|
-
'floor_heating': ['开关地址*','温度地址','当前温度']
|
|
114
|
+
'floor_heating': ['开关地址*','温度地址','当前温度'],
|
|
115
|
+
'scene': ['KNX组地址*', '场景号(1-64)*', '绑定Mesh开关状态(1=开/0=关)*']
|
|
112
116
|
};
|
|
113
117
|
|
|
114
118
|
// 统一的添加/编辑KNX实体面板
|
|
@@ -291,7 +295,7 @@
|
|
|
291
295
|
.tbl th { background:#f0f0f0; }
|
|
292
296
|
.tbl select { width:100%; font-size:11px; padding:2px; }
|
|
293
297
|
.tbl input[type="checkbox"] { margin:0; }
|
|
294
|
-
#knx-list, #map-list { max-height:
|
|
298
|
+
#knx-list, #map-list { max-height:450px; overflow-y:auto; border:1px solid #ccc; margin:5px 0; padding:3px; }
|
|
295
299
|
.tips { color:#666; padding:8px; text-align:center; font-size:12px; }
|
|
296
300
|
.sec { display:flex; justify-content:space-between; align-items:center; margin:10px 0 4px; padding-bottom:4px; border-bottom:1px solid #ddd; }
|
|
297
301
|
.sec b { font-size:12px; }
|
package/nodes/symi-knx-bridge.js
CHANGED
|
@@ -23,7 +23,8 @@ module.exports = function(RED) {
|
|
|
23
23
|
'cover': { dpt: '1.008', name: '窗帘', hasChannel: false },
|
|
24
24
|
'climate': { dpt: '9.001', name: '空调', hasChannel: false },
|
|
25
25
|
'fresh_air': { dpt: '1.001', name: '新风', hasChannel: false },
|
|
26
|
-
'floor_heating': { dpt: '9.001', name: '地暖', hasChannel: false }
|
|
26
|
+
'floor_heating': { dpt: '9.001', name: '地暖', hasChannel: false },
|
|
27
|
+
'scene': { dpt: '17.001', name: '场景', hasChannel: false }
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
function SymiKNXBridgeNode(config) {
|
|
@@ -155,6 +156,10 @@ module.exports = function(RED) {
|
|
|
155
156
|
node.lastKnxAddrSent = {}; // 记录发送到KNX地址的时间
|
|
156
157
|
node.lastKnxValueSent = {}; // 记录发送到KNX地址的值
|
|
157
158
|
|
|
159
|
+
// 错误日志限流
|
|
160
|
+
node.lastErrorTime = 0;
|
|
161
|
+
node.ERROR_THROTTLE_MS = 60000; // 1分钟
|
|
162
|
+
|
|
158
163
|
// 初始化通用同步工具类
|
|
159
164
|
node.syncUtils = new SyncUtils({
|
|
160
165
|
defaultTimeout: DEFAULT_TIMEOUT,
|
|
@@ -173,6 +178,7 @@ module.exports = function(RED) {
|
|
|
173
178
|
|
|
174
179
|
// 初始化标记
|
|
175
180
|
node.initializing = true;
|
|
181
|
+
node.isClosed = false; // 防止僵尸节点执行逻辑
|
|
176
182
|
node.initTimer = setTimeout(() => {
|
|
177
183
|
node.initializing = false;
|
|
178
184
|
node.log('[KNX Bridge] 初始化完成,开始同步');
|
|
@@ -256,6 +262,9 @@ module.exports = function(RED) {
|
|
|
256
262
|
|
|
257
263
|
// ========== Mesh设备状态变化处理 ==========
|
|
258
264
|
const handleMeshStateChange = (eventData) => {
|
|
265
|
+
// 防止僵尸节点执行
|
|
266
|
+
if (node.isClosed) return;
|
|
267
|
+
|
|
259
268
|
// 只检查initializing,不检查syncLock
|
|
260
269
|
// syncLock会导致队列处理期间丢失事件,改用per-device时间戳防回环
|
|
261
270
|
if (node.initializing) return;
|
|
@@ -339,6 +348,63 @@ module.exports = function(RED) {
|
|
|
339
348
|
});
|
|
340
349
|
}
|
|
341
350
|
}
|
|
351
|
+
// 场景设备
|
|
352
|
+
else if (mapping.deviceType === 'scene') {
|
|
353
|
+
const switchKey = `switch_${mapping.meshChannel}`;
|
|
354
|
+
if (changed[switchKey] !== undefined) {
|
|
355
|
+
const val = changed[switchKey];
|
|
356
|
+
const switchValue = (val === 1 || val === true || val === 'on' || val === 'ON');
|
|
357
|
+
|
|
358
|
+
// 检查是否匹配配置的触发动作
|
|
359
|
+
if (switchValue === mapping.sceneAction) {
|
|
360
|
+
// 构造防死循环key
|
|
361
|
+
// 注意:这里使用的是 knx-to-mesh 方向的记录来防止 mesh-to-knx 的发送
|
|
362
|
+
// 即:如果最近收到了 KNX 场景命令导致 Mesh 变化,这里应该拦截
|
|
363
|
+
const sceneLoopKey = `${loopKey}_scene_${mapping.sceneNumber}`;
|
|
364
|
+
|
|
365
|
+
if (node.shouldPreventSync('mesh-to-knx', sceneLoopKey)) {
|
|
366
|
+
node.log(`[Mesh->KNX] 跳过场景触发(防死循环): ${sceneLoopKey}`);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 场景命令 DPT 17.001 (1字节无符号整数)
|
|
371
|
+
// payload = 场景号 - 1 (KNX wire format: 0-63)
|
|
372
|
+
const scenePayload = mapping.sceneNumber - 1;
|
|
373
|
+
|
|
374
|
+
const knxMsg = {
|
|
375
|
+
topic: mapping.knxAddrCmd,
|
|
376
|
+
payload: scenePayload,
|
|
377
|
+
dpt: '17.001',
|
|
378
|
+
knx: {
|
|
379
|
+
destination: mapping.knxAddrCmd,
|
|
380
|
+
dpt: '17.001',
|
|
381
|
+
action: 'write'
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
node.log(`[Mesh->KNX] 场景触发: Mesh开关=${switchValue?'ON':'OFF'} -> 发送场景 ${mapping.sceneNumber} 到 ${mapping.knxAddrCmd}`);
|
|
386
|
+
|
|
387
|
+
// 直接发送,不经过通用队列(因为是单向触发)
|
|
388
|
+
node.send([knxMsg, {
|
|
389
|
+
topic: 'mesh-to-knx',
|
|
390
|
+
payload: {
|
|
391
|
+
direction: 'Mesh→KNX',
|
|
392
|
+
type: 'scene',
|
|
393
|
+
scene: mapping.sceneNumber,
|
|
394
|
+
trigger: switchValue ? 'ON' : 'OFF'
|
|
395
|
+
},
|
|
396
|
+
timestamp: new Date().toISOString()
|
|
397
|
+
}]);
|
|
398
|
+
|
|
399
|
+
// 记录发送时间
|
|
400
|
+
node.lastKnxAddrSent[mapping.knxAddrCmd] = Date.now();
|
|
401
|
+
node.lastKnxValueSent[mapping.knxAddrCmd] = scenePayload;
|
|
402
|
+
|
|
403
|
+
// 记录同步时间,防止反向循环
|
|
404
|
+
node.recordSyncTime('mesh-to-knx', sceneLoopKey);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
342
408
|
// 调光灯设备(单色、双色、RGB、RGBCW)- 简化逻辑:谁动谁跟,忽略过程反馈
|
|
343
409
|
else if (mapping.deviceType.startsWith('light_')) {
|
|
344
410
|
if (!eventData.isUserControl) continue;
|
|
@@ -566,7 +632,17 @@ module.exports = function(RED) {
|
|
|
566
632
|
}
|
|
567
633
|
await node.sleep(50); // 命令间隔50ms
|
|
568
634
|
} catch (err) {
|
|
569
|
-
|
|
635
|
+
const now = Date.now();
|
|
636
|
+
// 如果是连接相关错误,进行限流
|
|
637
|
+
if (err.message && (err.message.includes('Not connected') || err.message.includes('Connection'))) {
|
|
638
|
+
if (now - node.lastErrorTime > node.ERROR_THROTTLE_MS) {
|
|
639
|
+
node.lastErrorTime = now;
|
|
640
|
+
node.error(`同步失败(网关离线): ${err.message} (后续类似错误将被抑制1分钟)`);
|
|
641
|
+
}
|
|
642
|
+
} else {
|
|
643
|
+
// 其他错误正常记录
|
|
644
|
+
node.error(`同步失败: ${err.message}`);
|
|
645
|
+
}
|
|
570
646
|
}
|
|
571
647
|
}
|
|
572
648
|
} finally {
|
|
@@ -1126,13 +1202,17 @@ module.exports = function(RED) {
|
|
|
1126
1202
|
// ========== 事件监听 ==========
|
|
1127
1203
|
|
|
1128
1204
|
// 监听网关连接状态
|
|
1129
|
-
|
|
1205
|
+
const handleGatewayConnected = () => {
|
|
1206
|
+
if (node.isClosed) return;
|
|
1130
1207
|
node.status({ fill: 'green', shape: 'ring', text: `网关已连接 ${node.mappings.length}个映射` });
|
|
1131
|
-
}
|
|
1208
|
+
};
|
|
1209
|
+
node.gateway.on('gateway-connected', handleGatewayConnected);
|
|
1132
1210
|
|
|
1133
|
-
|
|
1211
|
+
const handleGatewayDisconnected = () => {
|
|
1212
|
+
if (node.isClosed) return;
|
|
1134
1213
|
node.status({ fill: 'red', shape: 'ring', text: '网关断开' });
|
|
1135
|
-
}
|
|
1214
|
+
};
|
|
1215
|
+
node.gateway.on('gateway-disconnected', handleGatewayDisconnected);
|
|
1136
1216
|
|
|
1137
1217
|
// 监听Mesh设备状态变化
|
|
1138
1218
|
node.gateway.on('device-state-changed', handleMeshStateChange);
|
|
@@ -1140,6 +1220,9 @@ module.exports = function(RED) {
|
|
|
1140
1220
|
// ========== 场景执行事件处理 ==========
|
|
1141
1221
|
// 当收到场景执行通知时,查询所有已映射设备的状态
|
|
1142
1222
|
const handleSceneExecuted = (eventData) => {
|
|
1223
|
+
// 防止僵尸节点执行
|
|
1224
|
+
if (node.isClosed) return;
|
|
1225
|
+
|
|
1143
1226
|
if (node.initializing) return;
|
|
1144
1227
|
|
|
1145
1228
|
node.log(`[场景执行] 检测到场景${eventData.sceneId}执行,延迟查询设备状态`);
|
|
@@ -1181,6 +1264,9 @@ module.exports = function(RED) {
|
|
|
1181
1264
|
|
|
1182
1265
|
// ========== 清理 ==========
|
|
1183
1266
|
node.on('close', function(done) {
|
|
1267
|
+
// 标记为已关闭,阻止任何后续事件处理
|
|
1268
|
+
node.isClosed = true;
|
|
1269
|
+
|
|
1184
1270
|
// 清除初始化定时器
|
|
1185
1271
|
if (node.initTimer) {
|
|
1186
1272
|
clearTimeout(node.initTimer);
|
|
@@ -1190,6 +1276,8 @@ module.exports = function(RED) {
|
|
|
1190
1276
|
if (node.gateway) {
|
|
1191
1277
|
node.gateway.removeListener('device-state-changed', handleMeshStateChange);
|
|
1192
1278
|
node.gateway.removeListener('scene-executed', handleSceneExecuted);
|
|
1279
|
+
node.gateway.removeListener('gateway-connected', handleGatewayConnected);
|
|
1280
|
+
node.gateway.removeListener('gateway-disconnected', handleGatewayDisconnected);
|
|
1193
1281
|
}
|
|
1194
1282
|
|
|
1195
1283
|
// 销毁 SyncUtils 实例,清理资源
|