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 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,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.3
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
@@ -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) => {
@@ -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() {
@@ -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', () => {
@@ -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
- <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,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: knxMsg.knx.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
  }
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.4",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "nodes/symi-gateway.js",
6
6
  "scripts": {