node-red-contrib-symi-mesh 1.7.3 → 1.7.4
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 +25 -16
- package/lib/serial-client.js +16 -5
- package/nodes/symi-485-bridge.js +7 -1
- package/nodes/symi-485-config.html +18 -0
- package/nodes/symi-485-config.js +13 -4
- package/nodes/symi-gateway.html +36 -2
- package/nodes/symi-gateway.js +8 -1
- package/nodes/symi-knx-bridge.js +96 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1291,6 +1291,8 @@ A: 每个房间部署一个云端同步节点,配置对应的酒店ID和房间
|
|
|
1291
1291
|
| **Symi RS485 Bridge** | RS485设备双向同步 | [RS485/Modbus双向同步](#rs485modbus双向同步) |
|
|
1292
1292
|
| **Symi KNX Bridge** | KNX系统双向同步 | [KNX双向同步](#knx双向同步) |
|
|
1293
1293
|
| **Symi KNX-HA Bridge** | KNX与HA实体双向同步 | [KNX-HA双向同步](#knx-ha双向同步) |
|
|
1294
|
+
| **Symi MQTT Sync** | 第三方MQTT品牌设备同步 | v1.7.3新增 |
|
|
1295
|
+
| **Symi MQTT Brand** | 品牌MQTT配置节点 | v1.7.3新增 |
|
|
1294
1296
|
| **RS485 Debug** | RS485总线数据抓包调试 | [RS485调试节点](#rs485调试节点) |
|
|
1295
1297
|
| **Symi RS485 Sync** | 两种RS485协议双向同步 | [RS485协议同步](#rs485协议同步) |
|
|
1296
1298
|
|
|
@@ -1314,12 +1316,15 @@ node-red-contrib-symi-mesh/
|
|
|
1314
1316
|
│ ├── symi-485-bridge.js/html # RS485桥接节点
|
|
1315
1317
|
│ ├── symi-485-config.js/html # RS485配置节点
|
|
1316
1318
|
│ ├── symi-knx-bridge.js/html # KNX桥接节点
|
|
1319
|
+
│ ├── symi-knx-ha-bridge.js/html # KNX-HA桥接节点
|
|
1317
1320
|
│ ├── symi-rs485-sync.js/html # RS485协议同步节点
|
|
1321
|
+
│ ├── symi-mqtt-sync.js/html # MQTT品牌同步节点
|
|
1322
|
+
│ ├── symi-mqtt-brand.js/html # 品牌MQTT配置节点
|
|
1318
1323
|
│ └── rs485-debug.js/html # RS485调试节点
|
|
1319
1324
|
├── examples/
|
|
1320
1325
|
│ ├── basic-example.json # 基础示例
|
|
1321
|
-
│ ├── knx-sync-example.json # KNX
|
|
1322
|
-
│ └──
|
|
1326
|
+
│ ├── knx-sync-example.json # KNX桥接节点示例
|
|
1327
|
+
│ └── rs485-sync-example.json # RS485协议同步示例
|
|
1323
1328
|
├── LICENSE
|
|
1324
1329
|
├── README.md
|
|
1325
1330
|
└── package.json
|
|
@@ -1457,6 +1462,23 @@ node-red-contrib-symi-mesh/
|
|
|
1457
1462
|
|
|
1458
1463
|
## 更新日志
|
|
1459
1464
|
|
|
1465
|
+
### v1.7.4 (2025-12-24)
|
|
1466
|
+
- **串口配置完善**:完整的串口参数配置,解决HassOS环境串口锁定问题
|
|
1467
|
+
- **完整参数**:波特率、数据位(7/8)、停止位(1/2)、校验位(None/Even/Odd)
|
|
1468
|
+
- **串口解锁**:添加`lock: false`参数,避免HassOS环境下"Cannot lock port"错误
|
|
1469
|
+
- **配置持久化**:所有串口参数自动保存,重启后保持
|
|
1470
|
+
- **统一界面**:symi-gateway和symi-485-config使用相同的串口配置界面
|
|
1471
|
+
- **串口兼容性修复**:兼容serialport v9和v10+,确保HassOS环境正常使用串口
|
|
1472
|
+
- `serial-client.js`:动态检测serialport版本
|
|
1473
|
+
- `symi-485-config.js`:兼容v9/v10+ API
|
|
1474
|
+
- `symi-485-bridge.js`:兼容v9/v10+ API
|
|
1475
|
+
- **KNX Bridge窗帘/调光同步优化**:重新设计步进设备同步逻辑,解决双向控制时乱动问题
|
|
1476
|
+
- **控制锁定机制**:谁先发起控制谁锁定,3秒内忽略另一方的反馈
|
|
1477
|
+
- **窗帘设备**:Mesh控制→锁定→忽略KNX反馈;KNX控制→锁定→忽略Mesh反馈
|
|
1478
|
+
- **调光设备**:同样应用控制锁定机制,避免亮度调节过程中的反馈干扰
|
|
1479
|
+
- **停止解锁**:窗帘stopped动作会解除锁定,允许下一次控制
|
|
1480
|
+
- **位置/亮度同步**:只同步最终值,不同步过程中的步进状态
|
|
1481
|
+
|
|
1460
1482
|
### v1.7.3 (2025-12-24)
|
|
1461
1483
|
- **MQTT同步节点**:新增`symi-mqtt-sync`节点实现第三方MQTT品牌设备与Mesh设备双向同步
|
|
1462
1484
|
- **双MQTT配置节点架构**:
|
|
@@ -1580,19 +1602,6 @@ node-red-contrib-symi-mesh/
|
|
|
1580
1602
|
- 连接knxUltimate节点,无缝集成
|
|
1581
1603
|
- 适用于已有KNX系统与HA整合的场景
|
|
1582
1604
|
|
|
1583
|
-
### v1.6.8 (2025-12-15)
|
|
1584
|
-
- **KNX双向同步修复**:彻底修复米家/面板操作KNX不同步的问题
|
|
1585
|
-
- 修复0x45消息类型处理:米家/面板操作发送msgType=0x45,不再限制channels>=6
|
|
1586
|
-
- 支持1-4路开关2字节参数解析(米家/面板场景触发格式)
|
|
1587
|
-
- 场景执行通知(0x11)后自动查询设备状态
|
|
1588
|
-
- **syncLock阻塞修复**:移除handleMeshStateChange中syncLock检查
|
|
1589
|
-
- 避免队列处理期间丢失状态变化事件
|
|
1590
|
-
- 改用per-device时间戳防回环机制
|
|
1591
|
-
- **processQueue健壮性**:添加try/finally确保processing标志正确重置
|
|
1592
|
-
- **防死循环参数调整**:LOOP_PREVENTION_MS调整为800ms
|
|
1593
|
-
- **同时修复RS485桥接**:应用相同syncLock修复
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
1605
|
## 许可证
|
|
1597
1606
|
|
|
1598
1607
|
MIT License
|
|
@@ -1604,7 +1613,7 @@ Copyright (c) 2025 SYMI 亖米
|
|
|
1604
1613
|
## 关于
|
|
1605
1614
|
|
|
1606
1615
|
**作者**: SYMI 亖米
|
|
1607
|
-
**版本**: 1.7.
|
|
1616
|
+
**版本**: 1.7.4
|
|
1608
1617
|
**协议**: 蓝牙MESH网关(初级版)串口协议V1.0
|
|
1609
1618
|
**最后更新**: 2025-12-24
|
|
1610
1619
|
**仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
|
package/lib/serial-client.js
CHANGED
|
@@ -6,10 +6,13 @@ const EventEmitter = require('events');
|
|
|
6
6
|
const { ProtocolHandler } = require('./protocol');
|
|
7
7
|
|
|
8
8
|
class SerialClient extends EventEmitter {
|
|
9
|
-
constructor(portPath, baudRate = 115200, logger = console) {
|
|
9
|
+
constructor(portPath, baudRate = 115200, logger = console, options = {}) {
|
|
10
10
|
super();
|
|
11
11
|
this.portPath = portPath;
|
|
12
12
|
this.baudRate = baudRate;
|
|
13
|
+
this.dataBits = options.dataBits || 8;
|
|
14
|
+
this.stopBits = options.stopBits || 1;
|
|
15
|
+
this.parity = options.parity || 'none';
|
|
13
16
|
this.logger = logger;
|
|
14
17
|
this.port = null;
|
|
15
18
|
this.protocolHandler = new ProtocolHandler();
|
|
@@ -59,15 +62,23 @@ class SerialClient extends EventEmitter {
|
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
if (!this.SerialPort) {
|
|
62
|
-
|
|
65
|
+
// 兼容serialport v9和v10+
|
|
66
|
+
try {
|
|
67
|
+
// v10+ API
|
|
68
|
+
this.SerialPort = require('serialport').SerialPort;
|
|
69
|
+
} catch (e) {
|
|
70
|
+
// v9 API
|
|
71
|
+
this.SerialPort = require('serialport');
|
|
72
|
+
}
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
this.port = new this.SerialPort({
|
|
66
76
|
path: this.portPath,
|
|
67
77
|
baudRate: this.baudRate,
|
|
68
|
-
dataBits:
|
|
69
|
-
stopBits:
|
|
70
|
-
parity:
|
|
78
|
+
dataBits: this.dataBits,
|
|
79
|
+
stopBits: this.stopBits,
|
|
80
|
+
parity: this.parity,
|
|
81
|
+
lock: false // 不锁定串口,避免HassOS环境下的锁定问题
|
|
71
82
|
});
|
|
72
83
|
|
|
73
84
|
return new Promise((resolve, reject) => {
|
package/nodes/symi-485-bridge.js
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
* 事件驱动架构,命令队列顺序处理
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// 兼容serialport v9和v10+
|
|
8
|
+
let SerialPort;
|
|
9
|
+
try {
|
|
10
|
+
SerialPort = require('serialport').SerialPort;
|
|
11
|
+
} catch (e) {
|
|
12
|
+
SerialPort = require('serialport');
|
|
13
|
+
}
|
|
8
14
|
|
|
9
15
|
module.exports = function(RED) {
|
|
10
16
|
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
port: { value: 502 },
|
|
9
9
|
serialPort: { value: '' },
|
|
10
10
|
baudRate: { value: 9600 },
|
|
11
|
+
dataBits: { value: 8 },
|
|
12
|
+
stopBits: { value: 1 },
|
|
11
13
|
parity: { value: 'none' }
|
|
12
14
|
},
|
|
13
15
|
label: function() {
|
|
@@ -108,6 +110,22 @@
|
|
|
108
110
|
</select>
|
|
109
111
|
</div>
|
|
110
112
|
|
|
113
|
+
<div class="form-row">
|
|
114
|
+
<label for="node-config-input-dataBits"><i class="fa fa-bars"></i> 数据位</label>
|
|
115
|
+
<select id="node-config-input-dataBits">
|
|
116
|
+
<option value="7">7</option>
|
|
117
|
+
<option value="8">8</option>
|
|
118
|
+
</select>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div class="form-row">
|
|
122
|
+
<label for="node-config-input-stopBits"><i class="fa fa-stop"></i> 停止位</label>
|
|
123
|
+
<select id="node-config-input-stopBits">
|
|
124
|
+
<option value="1">1</option>
|
|
125
|
+
<option value="2">2</option>
|
|
126
|
+
</select>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
111
129
|
<div class="form-row">
|
|
112
130
|
<label for="node-config-input-parity"><i class="fa fa-check-square"></i> 校验位</label>
|
|
113
131
|
<select id="node-config-input-parity">
|
package/nodes/symi-485-config.js
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
* 使用与Mesh网关相同的技术栈(SerialPort/net)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// 兼容serialport v9和v10+
|
|
7
|
+
let SerialPort;
|
|
8
|
+
try {
|
|
9
|
+
SerialPort = require('serialport').SerialPort;
|
|
10
|
+
} catch (e) {
|
|
11
|
+
SerialPort = require('serialport');
|
|
12
|
+
}
|
|
7
13
|
const net = require('net');
|
|
8
14
|
|
|
9
15
|
// 全局禁用 Happy Eyeballs 算法,防止 AggregateError 导致 Node-RED 崩溃
|
|
@@ -50,6 +56,8 @@ module.exports = function(RED) {
|
|
|
50
56
|
node.port = parseInt(config.port) || 502;
|
|
51
57
|
node.serialPort = config.serialPort || '';
|
|
52
58
|
node.baudRate = parseInt(config.baudRate) || 9600;
|
|
59
|
+
node.dataBits = parseInt(config.dataBits) || 8;
|
|
60
|
+
node.stopBits = parseInt(config.stopBits) || 1;
|
|
53
61
|
node.parity = config.parity || 'none';
|
|
54
62
|
|
|
55
63
|
// 连接状态
|
|
@@ -93,10 +101,11 @@ module.exports = function(RED) {
|
|
|
93
101
|
node.client = new SerialPort({
|
|
94
102
|
path: node.serialPort,
|
|
95
103
|
baudRate: node.baudRate,
|
|
96
|
-
dataBits:
|
|
97
|
-
stopBits:
|
|
104
|
+
dataBits: node.dataBits,
|
|
105
|
+
stopBits: node.stopBits,
|
|
98
106
|
parity: node.parity,
|
|
99
|
-
autoOpen: false
|
|
107
|
+
autoOpen: false,
|
|
108
|
+
lock: false // 不锁定串口,避免HassOS环境下的锁定问题
|
|
100
109
|
});
|
|
101
110
|
|
|
102
111
|
node.client.on('open', () => {
|
package/nodes/symi-gateway.html
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
host: { value: '' },
|
|
8
8
|
port: { value: 4196 },
|
|
9
9
|
serialPort: { value: '' },
|
|
10
|
-
baudRate: { value: 115200 }
|
|
10
|
+
baudRate: { value: 115200 },
|
|
11
|
+
dataBits: { value: 8 },
|
|
12
|
+
stopBits: { value: 1 },
|
|
13
|
+
parity: { value: 'none' }
|
|
11
14
|
},
|
|
12
15
|
label: function() {
|
|
13
16
|
return this.name || (this.connectionType === 'tcp'
|
|
@@ -107,7 +110,38 @@
|
|
|
107
110
|
|
|
108
111
|
<div class="form-row serial-config">
|
|
109
112
|
<label for="node-config-input-baudRate"><i class="fa fa-tachometer"></i> 波特率</label>
|
|
110
|
-
<
|
|
113
|
+
<select id="node-config-input-baudRate">
|
|
114
|
+
<option value="9600">9600</option>
|
|
115
|
+
<option value="19200">19200</option>
|
|
116
|
+
<option value="38400">38400</option>
|
|
117
|
+
<option value="57600">57600</option>
|
|
118
|
+
<option value="115200">115200</option>
|
|
119
|
+
</select>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div class="form-row serial-config">
|
|
123
|
+
<label for="node-config-input-dataBits"><i class="fa fa-bars"></i> 数据位</label>
|
|
124
|
+
<select id="node-config-input-dataBits">
|
|
125
|
+
<option value="7">7</option>
|
|
126
|
+
<option value="8">8</option>
|
|
127
|
+
</select>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div class="form-row serial-config">
|
|
131
|
+
<label for="node-config-input-stopBits"><i class="fa fa-stop"></i> 停止位</label>
|
|
132
|
+
<select id="node-config-input-stopBits">
|
|
133
|
+
<option value="1">1</option>
|
|
134
|
+
<option value="2">2</option>
|
|
135
|
+
</select>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div class="form-row serial-config">
|
|
139
|
+
<label for="node-config-input-parity"><i class="fa fa-check-square"></i> 校验位</label>
|
|
140
|
+
<select id="node-config-input-parity">
|
|
141
|
+
<option value="none">无 (None)</option>
|
|
142
|
+
<option value="even">偶校验 (Even)</option>
|
|
143
|
+
<option value="odd">奇校验 (Odd)</option>
|
|
144
|
+
</select>
|
|
111
145
|
</div>
|
|
112
146
|
</script>
|
|
113
147
|
|
package/nodes/symi-gateway.js
CHANGED
|
@@ -45,6 +45,9 @@ module.exports = function(RED) {
|
|
|
45
45
|
this.port = parseInt(config.port) || 4196;
|
|
46
46
|
this.serialPort = config.serialPort;
|
|
47
47
|
this.baudRate = parseInt(config.baudRate) || 115200;
|
|
48
|
+
this.dataBits = parseInt(config.dataBits) || 8;
|
|
49
|
+
this.stopBits = parseInt(config.stopBits) || 1;
|
|
50
|
+
this.parity = config.parity || 'none';
|
|
48
51
|
|
|
49
52
|
this.client = null;
|
|
50
53
|
this.deviceManager = new DeviceManager(this.context(), this, this.id);
|
|
@@ -83,7 +86,11 @@ module.exports = function(RED) {
|
|
|
83
86
|
if (this.connectionType === 'tcp') {
|
|
84
87
|
this.client = new TCPClient(this.host, this.port, this);
|
|
85
88
|
} else {
|
|
86
|
-
this.client = new SerialClient(this.serialPort, this.baudRate, this
|
|
89
|
+
this.client = new SerialClient(this.serialPort, this.baudRate, this, {
|
|
90
|
+
dataBits: this.dataBits,
|
|
91
|
+
stopBits: this.stopBits,
|
|
92
|
+
parity: this.parity
|
|
93
|
+
});
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
this.client.on('connected', () => {
|
package/nodes/symi-knx-bridge.js
CHANGED
|
@@ -149,11 +149,17 @@ module.exports = function(RED) {
|
|
|
149
149
|
node.knxStateCache = {}; // KNX设备状态缓存
|
|
150
150
|
node.lastMeshToKnx = {}; // 记录Mesh->KNX发送时间,防止回环
|
|
151
151
|
node.lastKnxToMesh = {}; // 记录KNX->Mesh发送时间,防止回环
|
|
152
|
+
node.lastKnxAddrSent = {}; // 记录发送到KNX地址的时间,防止自己发的命令又被处理
|
|
152
153
|
|
|
153
154
|
// 防死循环参数
|
|
154
155
|
const LOOP_PREVENTION_MS = 800; // 800ms内不处理反向同步,防止回环
|
|
155
156
|
const DEBOUNCE_MS = 100; // 100ms防抖
|
|
156
157
|
const MAX_QUEUE_SIZE = 100; // 最大队列大小
|
|
158
|
+
const COVER_CONTROL_LOCK_MS = 3000; // 窗帘/调光控制锁定3秒(只忽略过程反馈,不长期锁定)
|
|
159
|
+
|
|
160
|
+
// 控制锁定状态:谁先动作,锁定期间忽略另一方的反馈
|
|
161
|
+
// { deviceKey: { controller: 'mesh'|'knx', lockUntil: timestamp } }
|
|
162
|
+
node.controlLock = {};
|
|
157
163
|
|
|
158
164
|
// 初始化标记
|
|
159
165
|
node.initializing = true;
|
|
@@ -292,10 +298,15 @@ module.exports = function(RED) {
|
|
|
292
298
|
for (const mapping of matchedMappings) {
|
|
293
299
|
const loopKey = `${mac}_${mapping.meshChannel}`;
|
|
294
300
|
|
|
295
|
-
//
|
|
296
|
-
if (
|
|
297
|
-
|
|
298
|
-
|
|
301
|
+
// 窗帘设备需要单独处理防死循环(动作和位置分开检查)
|
|
302
|
+
if (mapping.deviceType === 'cover') {
|
|
303
|
+
// 窗帘的防死循环在内部单独处理,这里不跳过
|
|
304
|
+
} else {
|
|
305
|
+
// 其他设备统一防死循环检查
|
|
306
|
+
if (node.shouldPreventSync('mesh-to-knx', loopKey)) {
|
|
307
|
+
node.log(`[Mesh->KNX] 跳过(防死循环): ${loopKey}`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
299
310
|
}
|
|
300
311
|
|
|
301
312
|
// 开关设备
|
|
@@ -312,8 +323,23 @@ module.exports = function(RED) {
|
|
|
312
323
|
});
|
|
313
324
|
}
|
|
314
325
|
}
|
|
315
|
-
// 调光灯设备(单色、双色、RGB、RGBCW
|
|
326
|
+
// 调光灯设备(单色、双色、RGB、RGBCW)- 简化逻辑:谁动谁跟,忽略过程反馈
|
|
316
327
|
else if (mapping.deviceType.startsWith('light_')) {
|
|
328
|
+
if (!eventData.isUserControl) continue;
|
|
329
|
+
|
|
330
|
+
const deviceKey = `${macNormalized}_light`;
|
|
331
|
+
const now = Date.now();
|
|
332
|
+
const lock = node.controlLock[deviceKey];
|
|
333
|
+
|
|
334
|
+
// 如果KNX正在控制中,忽略Mesh的反馈
|
|
335
|
+
if (lock && lock.controller === 'knx' && now < lock.lockUntil) {
|
|
336
|
+
node.debug(`[Mesh->KNX] 调光灯跳过(KNX控制中): ${deviceKey}`);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Mesh发起控制,锁定20秒
|
|
341
|
+
node.controlLock[deviceKey] = { controller: 'mesh', lockUntil: now + COVER_CONTROL_LOCK_MS };
|
|
342
|
+
|
|
317
343
|
// 开关状态
|
|
318
344
|
if (changed.switch !== undefined) {
|
|
319
345
|
node.log(`[Mesh->KNX] 调光灯开关: ${mapping.name || eventData.device.name} = ${changed.switch}`);
|
|
@@ -325,8 +351,8 @@ module.exports = function(RED) {
|
|
|
325
351
|
key: loopKey
|
|
326
352
|
});
|
|
327
353
|
}
|
|
328
|
-
//
|
|
329
|
-
if (changed.brightness !== undefined) {
|
|
354
|
+
// 亮度(只在没有开关变化时同步,避免重复)
|
|
355
|
+
if (changed.brightness !== undefined && changed.switch === undefined) {
|
|
330
356
|
node.log(`[Mesh->KNX] 调光灯亮度: ${mapping.name || eventData.device.name} = ${changed.brightness}%`);
|
|
331
357
|
node.queueCommand({
|
|
332
358
|
direction: 'mesh-to-knx',
|
|
@@ -348,29 +374,50 @@ module.exports = function(RED) {
|
|
|
348
374
|
});
|
|
349
375
|
}
|
|
350
376
|
}
|
|
351
|
-
// 窗帘设备
|
|
377
|
+
// 窗帘设备 - 简化逻辑:谁动谁跟,忽略过程反馈
|
|
352
378
|
else if (mapping.deviceType === 'cover') {
|
|
353
379
|
if (!eventData.isUserControl) continue;
|
|
354
380
|
|
|
355
|
-
|
|
356
|
-
|
|
381
|
+
const deviceKey = macNormalized;
|
|
382
|
+
const now = Date.now();
|
|
383
|
+
const lock = node.controlLock[deviceKey];
|
|
384
|
+
|
|
385
|
+
// 如果KNX正在控制中,忽略Mesh的反馈(避免回环)
|
|
386
|
+
if (lock && lock.controller === 'knx' && now < lock.lockUntil) {
|
|
387
|
+
node.debug(`[Mesh->KNX] 窗帘跳过(KNX控制中): ${deviceKey}`);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Mesh发起控制,锁定20秒
|
|
392
|
+
node.controlLock[deviceKey] = { controller: 'mesh', lockUntil: now + COVER_CONTROL_LOCK_MS };
|
|
393
|
+
|
|
394
|
+
// 处理动作(打开/关闭/停止)
|
|
395
|
+
if (changed.curtainAction !== undefined) {
|
|
396
|
+
const action = changed.curtainAction;
|
|
357
397
|
node.log(`[Mesh->KNX] 窗帘动作: ${mapping.name || eventData.device.name} action=${action}`);
|
|
358
398
|
node.queueCommand({
|
|
359
399
|
direction: 'mesh-to-knx',
|
|
360
400
|
mapping: mapping,
|
|
361
401
|
type: 'cover_action',
|
|
362
402
|
value: action,
|
|
363
|
-
key: loopKey
|
|
403
|
+
key: `${loopKey}_action`
|
|
364
404
|
});
|
|
405
|
+
|
|
406
|
+
// 停止时解除锁定
|
|
407
|
+
if (action === 'stopped') {
|
|
408
|
+
delete node.controlLock[deviceKey];
|
|
409
|
+
}
|
|
365
410
|
}
|
|
366
|
-
|
|
411
|
+
|
|
412
|
+
// 处理位置变化(只在没有动作变化时同步位置)
|
|
413
|
+
if (changed.curtainPosition !== undefined && changed.curtainAction === undefined) {
|
|
367
414
|
node.log(`[Mesh->KNX] 窗帘位置: ${mapping.name || eventData.device.name} pos=${changed.curtainPosition}%`);
|
|
368
415
|
node.queueCommand({
|
|
369
416
|
direction: 'mesh-to-knx',
|
|
370
417
|
mapping: mapping,
|
|
371
418
|
type: 'cover_position',
|
|
372
419
|
value: changed.curtainPosition,
|
|
373
|
-
key: loopKey
|
|
420
|
+
key: `${loopKey}_position`
|
|
374
421
|
});
|
|
375
422
|
}
|
|
376
423
|
}
|
|
@@ -709,10 +756,14 @@ module.exports = function(RED) {
|
|
|
709
756
|
}
|
|
710
757
|
|
|
711
758
|
if (knxMsg) {
|
|
759
|
+
// 记录发送到的KNX地址和时间,防止自己发的命令又被处理
|
|
760
|
+
const destAddr = knxMsg.knx.destination;
|
|
761
|
+
node.lastKnxAddrSent[destAddr] = Date.now();
|
|
762
|
+
|
|
712
763
|
// knxUltimate官方格式: https://supergiovane.github.io/node-red-contrib-knx-ultimate/wiki/Device
|
|
713
764
|
// 必须包含: destination, payload, dpt, event
|
|
714
765
|
const sendMsg = {
|
|
715
|
-
destination:
|
|
766
|
+
destination: destAddr,
|
|
716
767
|
payload: knxMsg.payload,
|
|
717
768
|
dpt: knxMsg.dpt || knxMsg.knx.dpt,
|
|
718
769
|
event: "GroupValue_Write"
|
|
@@ -897,6 +948,14 @@ module.exports = function(RED) {
|
|
|
897
948
|
return;
|
|
898
949
|
}
|
|
899
950
|
|
|
951
|
+
// 检查是否是我们自己发出去的命令(防止自己发的命令被监听后又处理)
|
|
952
|
+
const lastSentTime = node.lastKnxAddrSent[groupAddr] || 0;
|
|
953
|
+
if (Date.now() - lastSentTime < LOOP_PREVENTION_MS) {
|
|
954
|
+
node.debug(`[KNX输入] 跳过(自己发的): ${groupAddr}`);
|
|
955
|
+
done && done();
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
|
|
900
959
|
// 查找映射
|
|
901
960
|
const mapping = node.findKnxMapping(groupAddr);
|
|
902
961
|
if (!mapping) {
|
|
@@ -909,7 +968,7 @@ module.exports = function(RED) {
|
|
|
909
968
|
const addrFunc = node.getKnxAddrFunction(mapping, groupAddr);
|
|
910
969
|
const loopKey = `${mapping.meshMac}_${mapping.meshChannel}`;
|
|
911
970
|
|
|
912
|
-
//
|
|
971
|
+
// 防死循环检查(基于设备的双向同步防护)
|
|
913
972
|
if (node.shouldPreventSync('knx-to-mesh', loopKey)) {
|
|
914
973
|
node.debug(`[KNX->Mesh] 跳过同步(防死循环): ${loopKey}, addr=${groupAddr}`);
|
|
915
974
|
done && done();
|
|
@@ -933,35 +992,55 @@ module.exports = function(RED) {
|
|
|
933
992
|
});
|
|
934
993
|
}
|
|
935
994
|
}
|
|
936
|
-
// 窗帘设备
|
|
995
|
+
// 窗帘设备 - 简化逻辑:谁动谁跟,KNX可抢占控制权
|
|
937
996
|
else if (mapping.deviceType === 'cover') {
|
|
997
|
+
const deviceKey = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
|
|
998
|
+
const now = Date.now();
|
|
999
|
+
|
|
1000
|
+
// KNX主动发起控制,直接抢占锁定(用户操作优先)
|
|
1001
|
+
node.controlLock[deviceKey] = { controller: 'knx', lockUntil: now + COVER_CONTROL_LOCK_MS };
|
|
1002
|
+
|
|
938
1003
|
if (addrFunc === 'cmd') {
|
|
939
1004
|
// 上下命令: 0=上(开), 1=下(关)
|
|
940
1005
|
const action = (value === 0 || value === false) ? 'open' : 'close';
|
|
1006
|
+
node.log(`[KNX->Mesh] 窗帘动作: ${action}`);
|
|
941
1007
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'cover_action', value: action, key: loopKey, sourceAddr: groupAddr });
|
|
942
1008
|
}
|
|
943
1009
|
else if (addrFunc === 'stop') {
|
|
1010
|
+
node.log(`[KNX->Mesh] 窗帘停止`);
|
|
944
1011
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'cover_action', value: 'stop', key: loopKey, sourceAddr: groupAddr });
|
|
1012
|
+
// 停止时解除锁定
|
|
1013
|
+
delete node.controlLock[deviceKey];
|
|
945
1014
|
}
|
|
946
1015
|
else if (addrFunc === 'position' || addrFunc === 'status') {
|
|
947
1016
|
const pos = mapping.invertPosition ? (100 - (parseInt(value) || 0)) : (parseInt(value) || 0);
|
|
1017
|
+
node.log(`[KNX->Mesh] 窗帘位置: ${pos}%`);
|
|
948
1018
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'cover_position', value: pos, key: loopKey, sourceAddr: groupAddr });
|
|
949
1019
|
}
|
|
950
1020
|
}
|
|
951
|
-
// 调光灯设备
|
|
1021
|
+
// 调光灯设备 - 简化逻辑:谁动谁跟,KNX可抢占控制权
|
|
952
1022
|
else if (mapping.deviceType.startsWith('light_')) {
|
|
1023
|
+
const deviceKey = `${(mapping.meshMac || '').toLowerCase().replace(/:/g, '')}_light`;
|
|
1024
|
+
const now = Date.now();
|
|
1025
|
+
|
|
1026
|
+
// KNX主动发起控制,直接抢占锁定(用户操作优先)
|
|
1027
|
+
node.controlLock[deviceKey] = { controller: 'knx', lockUntil: now + COVER_CONTROL_LOCK_MS };
|
|
1028
|
+
|
|
953
1029
|
if (addrFunc === 'cmd' || addrFunc === 'status') {
|
|
954
1030
|
// 开关
|
|
955
1031
|
const sw = (value === 1 || value === true);
|
|
1032
|
+
node.log(`[KNX->Mesh] 调光灯开关: ${sw ? 'ON' : 'OFF'}`);
|
|
956
1033
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_switch', value: sw, key: loopKey, sourceAddr: groupAddr });
|
|
957
1034
|
}
|
|
958
1035
|
else if (addrFunc === 'brightness') {
|
|
959
1036
|
// 亮度 (KNX 0-255 -> Mesh 0-100)
|
|
960
1037
|
const brightness = Math.round((parseInt(value) || 0) * 100 / 255);
|
|
1038
|
+
node.log(`[KNX->Mesh] 调光灯亮度: ${brightness}%`);
|
|
961
1039
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_brightness', value: brightness, key: loopKey, sourceAddr: groupAddr });
|
|
962
1040
|
}
|
|
963
1041
|
else if (addrFunc === 'colorTemp') {
|
|
964
1042
|
// 色温
|
|
1043
|
+
node.log(`[KNX->Mesh] 调光灯色温: ${value}`);
|
|
965
1044
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_color_temp', value: parseInt(value) || 50, key: loopKey, sourceAddr: groupAddr });
|
|
966
1045
|
}
|
|
967
1046
|
}
|