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 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 Function节点示例
1322
- │ └── knx-bridge-example.json # KNX桥接节点示例
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.3
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": true,
88
+ "listenallga": false,
89
89
  "buttonEnabled": false,
90
90
  "x": 620,
91
91
  "y": 120,
@@ -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
- this.SerialPort = require('serialport').SerialPort;
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: 8,
69
- stopBits: 1,
70
- parity: 'none'
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) => {
@@ -34,7 +34,7 @@
34
34
 
35
35
  function loadHistory() {
36
36
  if (!node.id) return;
37
- $.getJSON('/rs485-debug/history/' + node.id, function(messages) {
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('/symi-rs485-bridge/mesh-devices/' + gatewayId)
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('/symi-rs485-bridge/protocols', function(data) {
59
+ $.getJSON('symi-rs485-bridge/protocols', function(data) {
60
60
  protocolData = data || { brands: {} };
61
61
  // 延迟加载设备,确保gateway选择框已初始化
62
62
  setTimeout(function() {
@@ -4,7 +4,13 @@
4
4
  * 事件驱动架构,命令队列顺序处理
5
5
  */
6
6
 
7
- const { SerialPort } = require('serialport');
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('/symi-gateway/serial-ports', function(ports) {
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">
@@ -3,7 +3,13 @@
3
3
  * 使用与Mesh网关相同的技术栈(SerialPort/net)
4
4
  */
5
5
 
6
- const { SerialPort } = require('serialport');
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: 8,
97
- stopBits: 1,
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', () => {
@@ -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="">-- 选择设备 --</option>');
71
+ deviceSelect.append('<option value="">加载中...</option>');
71
72
 
72
- $.getJSON('/symi-gateway/devices/' + gatewayId)
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('加载设备列表:', devices.length, '个设备');
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
 
@@ -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('/symi-gateway/serial-ports', function(ports) {
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
- <input type="number" id="node-config-input-baudRate" placeholder="115200">
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
 
@@ -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', () => {
@@ -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 (node.shouldPreventSync('mesh-to-knx', loopKey)) {
297
- node.log(`[Mesh->KNX] 跳过(防死循环): ${loopKey}`);
298
- continue;
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
- if (changed.curtainAction !== undefined || changed.curtainStatus !== undefined) {
356
- const action = changed.curtainAction || state.curtainAction;
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
- if (changed.curtainPosition !== undefined) {
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
- // knxUltimate官方格式: https://supergiovane.github.io/node-red-contrib-knx-ultimate/wiki/Device
713
- // 必须包含: destination, payload, dpt, event
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
- destination: knxMsg.knx.destination,
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] 发送: destination=${sendMsg.destination}, payload=${sendMsg.payload}, dpt=${sendMsg.dpt}, event=${sendMsg.event}`);
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('/symi-gateway/devices/' + gatewayId, function(devices) {
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('/symi-mqtt-brand/devices/' + brandConfigId, function(devices) {
79
+ $.getJSON('symi-mqtt-brand/devices/' + brandConfigId, function(devices) {
80
80
  brandDevices = devices || [];
81
81
  if (callback) callback();
82
82
  }).fail(function() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.7.3",
3
+ "version": "1.7.5",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "nodes/symi-gateway.js",
6
6
  "scripts": {