node-red-contrib-symi-mesh 1.7.3 → 1.7.5
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 +39 -33
- package/examples/knx-sync-example.json +2 -2
- package/lib/serial-client.js +16 -5
- package/nodes/rs485-debug.html +1 -1
- package/nodes/symi-485-bridge.html +2 -2
- package/nodes/symi-485-bridge.js +7 -1
- package/nodes/symi-485-config.html +19 -1
- package/nodes/symi-485-config.js +13 -4
- package/nodes/symi-device.html +13 -5
- package/nodes/symi-gateway.html +37 -3
- package/nodes/symi-gateway.js +8 -1
- package/nodes/symi-knx-bridge.js +102 -20
- package/nodes/symi-mqtt-sync.html +2 -2
- 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,37 @@ node-red-contrib-symi-mesh/
|
|
|
1457
1462
|
|
|
1458
1463
|
## 更新日志
|
|
1459
1464
|
|
|
1465
|
+
### v1.7.5 (2025-12-24)
|
|
1466
|
+
- **API路径兼容性修复**:修复HassOS环境下节点配置界面无法加载设备列表的问题
|
|
1467
|
+
- **根本原因**:HassOS Node-RED使用nginx反向代理,绝对路径`/api`会被拦截,需使用相对路径`api`
|
|
1468
|
+
- **修复范围**:所有节点HTML中的`$.getJSON()`调用统一使用相对路径
|
|
1469
|
+
- **开发规范**:后续开发节点时,所有前端API调用必须使用相对路径,不带前导斜杠
|
|
1470
|
+
- **KNX Bridge双向同步修复**:修复Mesh控制KNX开关不工作的问题
|
|
1471
|
+
- **根本原因**:knxUltimate节点当`setTopicType: "str"`时使用`msg.topic`作为目标地址,而非`msg.destination`
|
|
1472
|
+
- **修复方案**:发送消息同时包含`topic`和`destination`字段,确保兼容所有knxUltimate配置
|
|
1473
|
+
- **消息格式**:`{ topic, destination, payload, dpt, event }`
|
|
1474
|
+
- **防死循环**:不包含`knx`对象,避免触发knxUltimate的循环引用保护机制
|
|
1475
|
+
- **用户体验优化**:
|
|
1476
|
+
- 设备列表加载时显示"加载中..."提示
|
|
1477
|
+
- API调用失败时显示"加载失败,请重试"提示
|
|
1478
|
+
|
|
1479
|
+
### v1.7.4 (2025-12-24)
|
|
1480
|
+
- **串口配置完善**:完整的串口参数配置,解决HassOS环境串口锁定问题
|
|
1481
|
+
- **完整参数**:波特率、数据位(7/8)、停止位(1/2)、校验位(None/Even/Odd)
|
|
1482
|
+
- **串口解锁**:添加`lock: false`参数,避免HassOS环境下"Cannot lock port"错误
|
|
1483
|
+
- **配置持久化**:所有串口参数自动保存,重启后保持
|
|
1484
|
+
- **统一界面**:symi-gateway和symi-485-config使用相同的串口配置界面
|
|
1485
|
+
- **串口兼容性修复**:兼容serialport v9和v10+,确保HassOS环境正常使用串口
|
|
1486
|
+
- `serial-client.js`:动态检测serialport版本
|
|
1487
|
+
- `symi-485-config.js`:兼容v9/v10+ API
|
|
1488
|
+
- `symi-485-bridge.js`:兼容v9/v10+ API
|
|
1489
|
+
- **KNX Bridge窗帘/调光同步优化**:重新设计步进设备同步逻辑,解决双向控制时乱动问题
|
|
1490
|
+
- **控制锁定机制**:谁先发起控制谁锁定,3秒内忽略另一方的反馈
|
|
1491
|
+
- **窗帘设备**:Mesh控制→锁定→忽略KNX反馈;KNX控制→锁定→忽略Mesh反馈
|
|
1492
|
+
- **调光设备**:同样应用控制锁定机制,避免亮度调节过程中的反馈干扰
|
|
1493
|
+
- **停止解锁**:窗帘stopped动作会解除锁定,允许下一次控制
|
|
1494
|
+
- **位置/亮度同步**:只同步最终值,不同步过程中的步进状态
|
|
1495
|
+
|
|
1460
1496
|
### v1.7.3 (2025-12-24)
|
|
1461
1497
|
- **MQTT同步节点**:新增`symi-mqtt-sync`节点实现第三方MQTT品牌设备与Mesh设备双向同步
|
|
1462
1498
|
- **双MQTT配置节点架构**:
|
|
@@ -1563,36 +1599,6 @@ node-red-contrib-symi-mesh/
|
|
|
1563
1599
|
- 防死循环机制优化
|
|
1564
1600
|
- 内存优化,防止日志溢出
|
|
1565
1601
|
|
|
1566
|
-
### v1.6.9 (2025-12-20)
|
|
1567
|
-
- **KNX-HA双向同步**:新增`symi-knx-ha-bridge`节点
|
|
1568
|
-
- 直接连接KNX与HA实体,实现双向同步
|
|
1569
|
-
- 支持Tab分隔格式导入KNX组地址配置(与KNX桥接节点100%一致)
|
|
1570
|
-
- 使用共享HA服务器节点,通过REST API自动加载HA实体列表
|
|
1571
|
-
- 支持开关、灯光、窗帘、空调、风扇等设备类型
|
|
1572
|
-
- 完整双向同步:KNX↔HA实时状态同步
|
|
1573
|
-
- 事件驱动架构,通过HA events-state节点实时接收事件
|
|
1574
|
-
- 智能防抖:调光300ms、窗帘500ms,只同步最终值
|
|
1575
|
-
- 内置防死循环机制(800ms防抖)
|
|
1576
|
-
- 快速输入框:支持输入实体ID或名称搜索,自动提示806+实体
|
|
1577
|
-
- 手动刷新按钮,随时重新加载HA实体
|
|
1578
|
-
- 宽屏界面:对话框最小1000px宽度
|
|
1579
|
-
- 持久化配置保存,内存优化
|
|
1580
|
-
- 连接knxUltimate节点,无缝集成
|
|
1581
|
-
- 适用于已有KNX系统与HA整合的场景
|
|
1582
|
-
|
|
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
1602
|
## 许可证
|
|
1597
1603
|
|
|
1598
1604
|
MIT License
|
|
@@ -1604,7 +1610,7 @@ Copyright (c) 2025 SYMI 亖米
|
|
|
1604
1610
|
## 关于
|
|
1605
1611
|
|
|
1606
1612
|
**作者**: SYMI 亖米
|
|
1607
|
-
**版本**: 1.7.
|
|
1613
|
+
**版本**: 1.7.5
|
|
1608
1614
|
**协议**: 蓝牙MESH网关(初级版)串口协议V1.0
|
|
1609
1615
|
**最后更新**: 2025-12-24
|
|
1610
1616
|
**仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
|
|
@@ -80,12 +80,12 @@
|
|
|
80
80
|
"notifyreadrequest": false,
|
|
81
81
|
"notifyresponse": false,
|
|
82
82
|
"notifywrite": true,
|
|
83
|
-
"name": "Universal KNX",
|
|
83
|
+
"name": "Universal KNX Output",
|
|
84
84
|
"outputtype": "write",
|
|
85
85
|
"outputRBE": "false",
|
|
86
86
|
"inputRBE": "false",
|
|
87
87
|
"passthrough": "no",
|
|
88
|
-
"listenallga":
|
|
88
|
+
"listenallga": false,
|
|
89
89
|
"buttonEnabled": false,
|
|
90
90
|
"x": 620,
|
|
91
91
|
"y": 120,
|
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/rs485-debug.html
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
|
|
35
35
|
function loadHistory() {
|
|
36
36
|
if (!node.id) return;
|
|
37
|
-
$.getJSON('
|
|
37
|
+
$.getJSON('rs485-debug/history/' + node.id, function(messages) {
|
|
38
38
|
var container = $('#debug-history');
|
|
39
39
|
|
|
40
40
|
if (messages.length === 0) {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
if (callback) callback();
|
|
44
44
|
return;
|
|
45
45
|
}
|
|
46
|
-
$.getJSON('
|
|
46
|
+
$.getJSON('symi-rs485-bridge/mesh-devices/' + gatewayId)
|
|
47
47
|
.done(function(devices) {
|
|
48
48
|
meshDevices = devices || [];
|
|
49
49
|
console.log('[RS485 Bridge] 加载Mesh设备:', meshDevices.length);
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// 加载协议模板
|
|
59
|
-
$.getJSON('
|
|
59
|
+
$.getJSON('symi-rs485-bridge/protocols', function(data) {
|
|
60
60
|
protocolData = data || { brands: {} };
|
|
61
61
|
// 延迟加载设备,确保gateway选择框已初始化
|
|
62
62
|
setTimeout(function() {
|
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() {
|
|
@@ -36,7 +38,7 @@
|
|
|
36
38
|
$btn.prop('disabled', true);
|
|
37
39
|
$select.empty().append('<option value="">搜索中...</option>').show();
|
|
38
40
|
|
|
39
|
-
$.getJSON('
|
|
41
|
+
$.getJSON('symi-gateway/serial-ports', function(ports) {
|
|
40
42
|
$select.empty();
|
|
41
43
|
if (ports && ports.length > 0) {
|
|
42
44
|
$select.append('<option value="">-- 选择串口 --</option>');
|
|
@@ -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-device.html
CHANGED
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
|
|
61
61
|
var loadDevices = function(gatewayId) {
|
|
62
62
|
if (!gatewayId) {
|
|
63
|
+
console.log('[symi-device] 网关ID为空,跳过加载');
|
|
63
64
|
return;
|
|
64
65
|
}
|
|
65
66
|
|
|
@@ -67,11 +68,16 @@
|
|
|
67
68
|
var currentValue = deviceSelect.val();
|
|
68
69
|
|
|
69
70
|
deviceSelect.empty();
|
|
70
|
-
deviceSelect.append('<option value=""
|
|
71
|
+
deviceSelect.append('<option value="">加载中...</option>');
|
|
71
72
|
|
|
72
|
-
|
|
73
|
+
var apiUrl = 'symi-gateway/devices/' + gatewayId;
|
|
74
|
+
console.log('[symi-device] 加载设备列表, API:', apiUrl);
|
|
75
|
+
|
|
76
|
+
$.getJSON(apiUrl)
|
|
73
77
|
.done(function(devices) {
|
|
74
|
-
console.log('
|
|
78
|
+
console.log('[symi-device] 加载成功:', devices ? devices.length : 0, '个设备');
|
|
79
|
+
deviceSelect.empty();
|
|
80
|
+
deviceSelect.append('<option value="">-- 选择设备 --</option>');
|
|
75
81
|
if (devices && devices.length > 0) {
|
|
76
82
|
devices.forEach(function(device) {
|
|
77
83
|
var label = device.name + ' (' + device.mac + ')';
|
|
@@ -99,8 +105,10 @@
|
|
|
99
105
|
|
|
100
106
|
deviceSelect.trigger('change');
|
|
101
107
|
})
|
|
102
|
-
.fail(function(err) {
|
|
103
|
-
console.log('加载设备失败:', err);
|
|
108
|
+
.fail(function(xhr, status, err) {
|
|
109
|
+
console.log('[symi-device] 加载设备失败:', status, err, xhr.responseText);
|
|
110
|
+
deviceSelect.empty();
|
|
111
|
+
deviceSelect.append('<option value="">加载失败,请重试</option>');
|
|
104
112
|
});
|
|
105
113
|
};
|
|
106
114
|
|
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'
|
|
@@ -34,7 +37,7 @@
|
|
|
34
37
|
$btn.prop('disabled', true).text('搜索中...');
|
|
35
38
|
$select.empty().append('<option value="">搜索中...</option>');
|
|
36
39
|
|
|
37
|
-
$.getJSON('
|
|
40
|
+
$.getJSON('symi-gateway/serial-ports', function(ports) {
|
|
38
41
|
$select.empty();
|
|
39
42
|
if (ports && ports.length > 0) {
|
|
40
43
|
$select.append('<option value="">-- 选择串口 --</option>');
|
|
@@ -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,17 @@ module.exports = function(RED) {
|
|
|
709
756
|
}
|
|
710
757
|
|
|
711
758
|
if (knxMsg) {
|
|
712
|
-
//
|
|
713
|
-
|
|
759
|
+
// 记录发送到的KNX地址和时间,防止自己发的命令又被处理
|
|
760
|
+
const destAddr = knxMsg.knx.destination;
|
|
761
|
+
node.lastKnxAddrSent[destAddr] = Date.now();
|
|
762
|
+
|
|
763
|
+
// knxUltimate输入格式:topic + destination + payload + dpt + event
|
|
764
|
+
// topic: 当setTopicType=str时,knxUltimate使用msg.topic作为目标地址
|
|
765
|
+
// destination: 官方备用字段
|
|
766
|
+
// 注意:不能包含knx对象,否则会触发knxUltimate的循环引用保护
|
|
714
767
|
const sendMsg = {
|
|
715
|
-
|
|
768
|
+
topic: destAddr,
|
|
769
|
+
destination: destAddr,
|
|
716
770
|
payload: knxMsg.payload,
|
|
717
771
|
dpt: knxMsg.dpt || knxMsg.knx.dpt,
|
|
718
772
|
event: "GroupValue_Write"
|
|
@@ -733,7 +787,7 @@ module.exports = function(RED) {
|
|
|
733
787
|
timestamp: new Date().toISOString()
|
|
734
788
|
};
|
|
735
789
|
|
|
736
|
-
node.log(`[Mesh->KNX] 发送:
|
|
790
|
+
node.log(`[Mesh->KNX] 发送: topic=${sendMsg.topic}, payload=${sendMsg.payload}, dpt=${sendMsg.dpt}`);
|
|
737
791
|
|
|
738
792
|
// 同时发送到两个输出端口(一次send调用)
|
|
739
793
|
node.send([sendMsg, debugMsg]);
|
|
@@ -897,6 +951,14 @@ module.exports = function(RED) {
|
|
|
897
951
|
return;
|
|
898
952
|
}
|
|
899
953
|
|
|
954
|
+
// 检查是否是我们自己发出去的命令(防止自己发的命令被监听后又处理)
|
|
955
|
+
const lastSentTime = node.lastKnxAddrSent[groupAddr] || 0;
|
|
956
|
+
if (Date.now() - lastSentTime < LOOP_PREVENTION_MS) {
|
|
957
|
+
node.debug(`[KNX输入] 跳过(自己发的): ${groupAddr}`);
|
|
958
|
+
done && done();
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
|
|
900
962
|
// 查找映射
|
|
901
963
|
const mapping = node.findKnxMapping(groupAddr);
|
|
902
964
|
if (!mapping) {
|
|
@@ -909,7 +971,7 @@ module.exports = function(RED) {
|
|
|
909
971
|
const addrFunc = node.getKnxAddrFunction(mapping, groupAddr);
|
|
910
972
|
const loopKey = `${mapping.meshMac}_${mapping.meshChannel}`;
|
|
911
973
|
|
|
912
|
-
//
|
|
974
|
+
// 防死循环检查(基于设备的双向同步防护)
|
|
913
975
|
if (node.shouldPreventSync('knx-to-mesh', loopKey)) {
|
|
914
976
|
node.debug(`[KNX->Mesh] 跳过同步(防死循环): ${loopKey}, addr=${groupAddr}`);
|
|
915
977
|
done && done();
|
|
@@ -933,35 +995,55 @@ module.exports = function(RED) {
|
|
|
933
995
|
});
|
|
934
996
|
}
|
|
935
997
|
}
|
|
936
|
-
// 窗帘设备
|
|
998
|
+
// 窗帘设备 - 简化逻辑:谁动谁跟,KNX可抢占控制权
|
|
937
999
|
else if (mapping.deviceType === 'cover') {
|
|
1000
|
+
const deviceKey = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
|
|
1001
|
+
const now = Date.now();
|
|
1002
|
+
|
|
1003
|
+
// KNX主动发起控制,直接抢占锁定(用户操作优先)
|
|
1004
|
+
node.controlLock[deviceKey] = { controller: 'knx', lockUntil: now + COVER_CONTROL_LOCK_MS };
|
|
1005
|
+
|
|
938
1006
|
if (addrFunc === 'cmd') {
|
|
939
1007
|
// 上下命令: 0=上(开), 1=下(关)
|
|
940
1008
|
const action = (value === 0 || value === false) ? 'open' : 'close';
|
|
1009
|
+
node.log(`[KNX->Mesh] 窗帘动作: ${action}`);
|
|
941
1010
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'cover_action', value: action, key: loopKey, sourceAddr: groupAddr });
|
|
942
1011
|
}
|
|
943
1012
|
else if (addrFunc === 'stop') {
|
|
1013
|
+
node.log(`[KNX->Mesh] 窗帘停止`);
|
|
944
1014
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'cover_action', value: 'stop', key: loopKey, sourceAddr: groupAddr });
|
|
1015
|
+
// 停止时解除锁定
|
|
1016
|
+
delete node.controlLock[deviceKey];
|
|
945
1017
|
}
|
|
946
1018
|
else if (addrFunc === 'position' || addrFunc === 'status') {
|
|
947
1019
|
const pos = mapping.invertPosition ? (100 - (parseInt(value) || 0)) : (parseInt(value) || 0);
|
|
1020
|
+
node.log(`[KNX->Mesh] 窗帘位置: ${pos}%`);
|
|
948
1021
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'cover_position', value: pos, key: loopKey, sourceAddr: groupAddr });
|
|
949
1022
|
}
|
|
950
1023
|
}
|
|
951
|
-
// 调光灯设备
|
|
1024
|
+
// 调光灯设备 - 简化逻辑:谁动谁跟,KNX可抢占控制权
|
|
952
1025
|
else if (mapping.deviceType.startsWith('light_')) {
|
|
1026
|
+
const deviceKey = `${(mapping.meshMac || '').toLowerCase().replace(/:/g, '')}_light`;
|
|
1027
|
+
const now = Date.now();
|
|
1028
|
+
|
|
1029
|
+
// KNX主动发起控制,直接抢占锁定(用户操作优先)
|
|
1030
|
+
node.controlLock[deviceKey] = { controller: 'knx', lockUntil: now + COVER_CONTROL_LOCK_MS };
|
|
1031
|
+
|
|
953
1032
|
if (addrFunc === 'cmd' || addrFunc === 'status') {
|
|
954
1033
|
// 开关
|
|
955
1034
|
const sw = (value === 1 || value === true);
|
|
1035
|
+
node.log(`[KNX->Mesh] 调光灯开关: ${sw ? 'ON' : 'OFF'}`);
|
|
956
1036
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_switch', value: sw, key: loopKey, sourceAddr: groupAddr });
|
|
957
1037
|
}
|
|
958
1038
|
else if (addrFunc === 'brightness') {
|
|
959
1039
|
// 亮度 (KNX 0-255 -> Mesh 0-100)
|
|
960
1040
|
const brightness = Math.round((parseInt(value) || 0) * 100 / 255);
|
|
1041
|
+
node.log(`[KNX->Mesh] 调光灯亮度: ${brightness}%`);
|
|
961
1042
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_brightness', value: brightness, key: loopKey, sourceAddr: groupAddr });
|
|
962
1043
|
}
|
|
963
1044
|
else if (addrFunc === 'colorTemp') {
|
|
964
1045
|
// 色温
|
|
1046
|
+
node.log(`[KNX->Mesh] 调光灯色温: ${value}`);
|
|
965
1047
|
node.queueCommand({ direction: 'knx-to-mesh', mapping, type: 'light_color_temp', value: parseInt(value) || 50, key: loopKey, sourceAddr: groupAddr });
|
|
966
1048
|
}
|
|
967
1049
|
}
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
if (callback) callback();
|
|
60
60
|
return;
|
|
61
61
|
}
|
|
62
|
-
$.getJSON('
|
|
62
|
+
$.getJSON('symi-gateway/devices/' + gatewayId, function(devices) {
|
|
63
63
|
meshDevices = devices || [];
|
|
64
64
|
if (callback) callback();
|
|
65
65
|
}).fail(function() {
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
if (callback) callback();
|
|
77
77
|
return;
|
|
78
78
|
}
|
|
79
|
-
$.getJSON('
|
|
79
|
+
$.getJSON('symi-mqtt-brand/devices/' + brandConfigId, function(devices) {
|
|
80
80
|
brandDevices = devices || [];
|
|
81
81
|
if (callback) callback();
|
|
82
82
|
}).fail(function() {
|