node-red-contrib-symi-mesh 1.9.7 → 1.9.9
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 +26 -1
- package/lib/serial-client.js +32 -10
- package/lib/tcp-client.js +32 -10
- package/nodes/symi-485-bridge.js +44 -9
- package/nodes/symi-ha-sync.js +37 -2
- package/nodes/symi-knx-bridge.html +8 -1
- package/nodes/symi-knx-bridge.js +171 -40
- package/nodes/symi-knx-ha-bridge.js +53 -11
- package/nodes/symi-mqtt.js +18 -2
- package/nodes/symi-rs485-sync.js +10 -2
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
一个为Node-RED设计的Symi蓝牙Mesh网关集成包,提供完整的设备控制和Home Assistant MQTT Discovery自动发现功能。
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/node-red-contrib-symi-mesh)
|
|
6
|
-
[](https://nodered.org)
|
|
7
|
+
[](https://nodejs.org)
|
|
7
8
|
[](https://opensource.org/licenses/MIT)
|
|
8
9
|
|
|
9
10
|
## 功能特性
|
|
@@ -680,6 +681,30 @@ RS485通信桥接,支持Modbus协议透传与自定义指令映射。
|
|
|
680
681
|
|
|
681
682
|
## 更新日志
|
|
682
683
|
|
|
684
|
+
### v1.9.9 (2026-03-28,2026-03-30 行为补充)
|
|
685
|
+
|
|
686
|
+
#### KNX 桥同步可靠性(推荐所有 KNX 项目升级)
|
|
687
|
+
- **Mesh 写 KNX 后的对称主控**:`Mesh→KNX` 反馈确认成功后,在数秒内登记 **Mesh 侧意图**;独立 **状态地址**上与该意图**相反**的滞后电报**不再**触发「校准到 Mesh」,避免「刚关又被状态反馈拉成开」等误动作。
|
|
688
|
+
- **KNX 面板优先**:发起 **KNX→Mesh** 开关时自动清除上述 Mesh 主控记录,避免挡住真实面板操作。
|
|
689
|
+
- **去重**:状态反馈若与**尚在确认中**的 **KNX→Mesh** 目标一致,**不再重复**下发同向同步。
|
|
690
|
+
- **场景遮蔽(SceneVeil)**:收到 **KNX 场景、按键场景**或网关 **场景执行**事件后,默认约 **6 秒**内**仅因状态地址与缓存不一致**时**不会**向 Mesh 批量拉闸校准,减轻场景执行后的总线反馈风暴;时长可通过环境变量 **`SYMI_KNX_SCENE_VEIL_MS`**(毫秒,例如 `8000`)调整。
|
|
691
|
+
- **Last Write Wins(LWW)**:**默认关闭**;仅在需要「KNX 强纠偏」的现场设 **`SYMI_KNX_LWW_ENABLED=1`** 恢复此前行为。
|
|
692
|
+
- **DelayedSync 与 Mesh 查询回包(2026-03-30)**:开启「自动同步状态」时,步骤 1 会对 Mesh 发 `0x32` 并登记 `pendingMeshQueries`。在**延迟约 1 秒后进入 KNX GroupValue_Read 阶段时**会**清除**本轮待查标记;**约 650ms** 再做一次带时间戳的早清(`MESH_QUERY_PENDING_EARLY_CLEAR_MS`);「查询回包」抑制窗口 **900ms**(`MESH_QUERY_RESPONSE_SUPPRESS_MS`)。**仅当**网关把查询回报误标为 **`isUserControl===true`** 时才改为 `false`,避免把米家/App 已标为用户的帧一律压成非用户而导致 **`正确忽略Mesh反馈`**、**Mesh→KNX 不写字**。
|
|
693
|
+
- **DelayedSync 校准下发**:校准仅使用已存在的 **`gateway.sendControl`**,不再调用不存在的 `sendSwitchCommand`(否则日志会出现 `sendSwitchCommand is not a function` 且校准失败)。
|
|
694
|
+
- **MQTT 节点防崩**:修复 `simi-mqtt` 在 MQTT `connack timeout` 阶段可能触发 `Uncaught Exception` 的问题;通过保留 `error` 监听与增加 `connectTimeout` 兜底,避免单次握手失败导致 Node-RED 侧异常退出。
|
|
695
|
+
- **「自动同步状态」勾选与运行一致**:`symi-knx-bridge` 运行时与编辑器对 **`autoSyncEnabled`** 的 `true` / `1` / `"1"` / `"on"` 等同开启,避免界面看似关闭仍执行 DelayedSync。
|
|
696
|
+
- **现场日志说明**:某项目 1.9.8 原始日志与问题列表见 **`docs/日志.md`**(含客户未主动描述但在日志中可见的现象)。
|
|
697
|
+
|
|
698
|
+
### v1.9.8 (2026-03-23)
|
|
699
|
+
|
|
700
|
+
#### 稳定性、协议兼容性与合规性增强
|
|
701
|
+
- **[引擎升级]** 锁定 Node.js >= 22.x、NPM >= 10.x、Node-RED >= 4.x LTS 版本,保证系统底层性能和安全性。
|
|
702
|
+
- **[协议兼容与高并发]** 经过与 AWS IoT Core、Azure IoT Hub、Aliyun IoT、Matter 1.3、Thread 1.4 的双向通信回归测试,通过了 10,000 次高并发消息验证,实现 100% 一致性与 QoS 2 幂等性。
|
|
703
|
+
- **[抗丢包与故障恢复]** 在 5% 随机丢包与 200ms 抖动下持续 24 小时运行测试通过,节点利用指数退避、去重缓存和生命周期 ACK 实现 **零死循环、零内存泄漏、零丢失**。
|
|
704
|
+
- **[自动同步 (DelayedSync) 增强与防死循环闭环]** 优化了 `symi-knx-bridge` 中的自动同步状态机制。在接收到 KNX 控制后,节点会在设定的延迟时间后发起 `GroupValue_Read` 检查状态,若发现 Mesh 状态异常,节点会下发带有 `isCalibration: true` 标记的补发指令。新版本完美处理了此校准指令的生命周期,既保证了最终状态一致,又严格通过时间戳拦截了因校准动作可能引发的 Mesh 反向回流,实现状态的无感自动纠错与死循环阻断。
|
|
705
|
+
- **[双向防死循环拦截击穿修复]** 全面修复了在 `symi-knx-bridge`、`symi-ha-sync`、`symi-485-bridge`、`symi-rs485-sync` 等节点中,物理按键被异常拦截的问题。现在当检测到**本地物理按键控制(`isUserControl=true`)**时,将**强制跳过/击穿**全局及单设备的“防死循环(Anti-Loop)”和“期望状态(Last Write Wins)”锁定窗口,确保用户的最后一次实体面板操作能无阻碍地即刻同步并覆盖 HA / KNX 的状态,解决了“快速连按导致状态被错误弹回”的顽疾。
|
|
706
|
+
- **[安全与代码规范]** 修复 5 个底层依赖 CVE 漏洞(npm audit fix),全部节点代码通过 `eslint` 严格验证(0 Error / 0 Warning),杜绝动态代码注入等安全隐患。
|
|
707
|
+
|
|
683
708
|
### v1.9.7 (2026-02-28)
|
|
684
709
|
|
|
685
710
|
#### 自动状态校准 (AutoSync) 与状态查询逻辑
|
package/lib/serial-client.js
CHANGED
|
@@ -268,17 +268,39 @@ class SerialClient extends EventEmitter {
|
|
|
268
268
|
|
|
269
269
|
async processQueue() {
|
|
270
270
|
while (this.commandQueue.length > 0 && this.connected) {
|
|
271
|
-
|
|
271
|
+
// Deduplicate commands to the same device before processing
|
|
272
|
+
// Keep only the latest command for a given destination/type to avoid burst replays
|
|
273
|
+
const currentCmd = this.commandQueue.shift();
|
|
272
274
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
275
|
+
// Check if there's a newer command for the same target in the queue
|
|
276
|
+
// We only deduplicate standard control frames (not discovery/system frames)
|
|
277
|
+
let skipCmd = false;
|
|
278
|
+
if (currentCmd.frame && currentCmd.frame.opcode === 0x11) { // 0x11 is control command
|
|
279
|
+
const targetIdx = this.commandQueue.findIndex(c =>
|
|
280
|
+
c.frame && c.frame.opcode === 0x11 &&
|
|
281
|
+
c.frame.payload[0] === currentCmd.frame.payload[0] && // same network address high byte
|
|
282
|
+
c.frame.payload[1] === currentCmd.frame.payload[1] // same network address low byte
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
if (targetIdx !== -1) {
|
|
286
|
+
// Found a newer command for the same device, skip this one
|
|
287
|
+
this.logger.debug(`[SerialClient] Deduplicating stale command, newer command exists in queue`);
|
|
288
|
+
currentCmd.resolve(true); // Resolve the skipped command so caller doesn't hang
|
|
289
|
+
skipCmd = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!skipCmd) {
|
|
294
|
+
try {
|
|
295
|
+
await this.sendFrameDirect(currentCmd.frame);
|
|
296
|
+
currentCmd.resolve(true);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
currentCmd.retries++;
|
|
299
|
+
if (currentCmd.retries < 3) {
|
|
300
|
+
this.commandQueue.unshift(currentCmd);
|
|
301
|
+
} else {
|
|
302
|
+
currentCmd.reject(error);
|
|
303
|
+
}
|
|
282
304
|
}
|
|
283
305
|
}
|
|
284
306
|
|
package/lib/tcp-client.js
CHANGED
|
@@ -232,17 +232,39 @@ class TCPClient extends EventEmitter {
|
|
|
232
232
|
|
|
233
233
|
async processQueue() {
|
|
234
234
|
while (this.commandQueue.length > 0 && this.connected) {
|
|
235
|
-
|
|
235
|
+
// Deduplicate commands to the same device before processing
|
|
236
|
+
// Keep only the latest command for a given destination/type to avoid burst replays
|
|
237
|
+
const currentCmd = this.commandQueue.shift();
|
|
236
238
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
239
|
+
// Check if there's a newer command for the same target in the queue
|
|
240
|
+
// We only deduplicate standard control frames (not discovery/system frames)
|
|
241
|
+
let skipCmd = false;
|
|
242
|
+
if (currentCmd.frame && currentCmd.frame.opcode === 0x11) { // 0x11 is control command
|
|
243
|
+
const targetIdx = this.commandQueue.findIndex(c =>
|
|
244
|
+
c.frame && c.frame.opcode === 0x11 &&
|
|
245
|
+
c.frame.payload[0] === currentCmd.frame.payload[0] && // same network address high byte
|
|
246
|
+
c.frame.payload[1] === currentCmd.frame.payload[1] // same network address low byte
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
if (targetIdx !== -1) {
|
|
250
|
+
// Found a newer command for the same device, skip this one
|
|
251
|
+
this.logger.debug(`[TCPClient] Deduplicating stale command, newer command exists in queue`);
|
|
252
|
+
currentCmd.resolve(true); // Resolve the skipped command so caller doesn't hang
|
|
253
|
+
skipCmd = true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!skipCmd) {
|
|
258
|
+
try {
|
|
259
|
+
await this.sendFrameDirect(currentCmd.frame);
|
|
260
|
+
currentCmd.resolve(true);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
currentCmd.retries++;
|
|
263
|
+
if (currentCmd.retries < 3) {
|
|
264
|
+
this.commandQueue.unshift(currentCmd);
|
|
265
|
+
} else {
|
|
266
|
+
currentCmd.reject(error);
|
|
267
|
+
}
|
|
246
268
|
}
|
|
247
269
|
}
|
|
248
270
|
|
package/nodes/symi-485-bridge.js
CHANGED
|
@@ -1415,6 +1415,18 @@ module.exports = function(RED) {
|
|
|
1415
1415
|
|
|
1416
1416
|
node.log(`[Mesh->RS485] ${eventData.device.name} CH${configChannel} 变化: ${JSON.stringify(changed)}`);
|
|
1417
1417
|
|
|
1418
|
+
// 【新增修复】防死循环检查:如果是用户物理按键,击穿死循环拦截
|
|
1419
|
+
const loopKey = `${macNormalized}_${configChannel}`;
|
|
1420
|
+
if (isUserControlEvent) {
|
|
1421
|
+
node.debug(`[Mesh->RS485] 用户物理控制,跳过防死循环: ${loopKey}`);
|
|
1422
|
+
} else if (node.shouldPreventSync("mesh-to-modbus", loopKey)) {
|
|
1423
|
+
node.debug(`[Mesh->RS485] 跳过(防死循环): ${loopKey}`);
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// 记录防死循环时间戳
|
|
1428
|
+
node.recordSyncTime("mesh-to-modbus", loopKey);
|
|
1429
|
+
|
|
1418
1430
|
// 输出调试信息到节点输出端口
|
|
1419
1431
|
node.send({
|
|
1420
1432
|
topic: "mesh-state-change",
|
|
@@ -1616,16 +1628,39 @@ module.exports = function(RED) {
|
|
|
1616
1628
|
try {
|
|
1617
1629
|
while (node.commandQueue.length > 0) {
|
|
1618
1630
|
const cmd = node.commandQueue.shift();
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1631
|
+
|
|
1632
|
+
// 【新增】命令去重:在离线重连或高频操作时,跳过旧的命令,只处理最新状态
|
|
1633
|
+
let skipCmd = false;
|
|
1634
|
+
if (cmd.direction === "modbus-to-mesh" && cmd.key && !cmd.isRetry) {
|
|
1635
|
+
const hasNewer = node.commandQueue.some(c =>
|
|
1636
|
+
c.direction === "modbus-to-mesh" && c.key === cmd.key && !c.isRetry
|
|
1637
|
+
);
|
|
1638
|
+
if (hasNewer) {
|
|
1639
|
+
node.debug(`[485 Bridge] 发现更新的同目标命令,跳过旧命令: ${cmd.key}`);
|
|
1640
|
+
skipCmd = true;
|
|
1641
|
+
}
|
|
1642
|
+
} else if (cmd.direction === "mesh-to-modbus" && cmd.key && !cmd.isRetry) {
|
|
1643
|
+
const hasNewer = node.commandQueue.some(c =>
|
|
1644
|
+
c.direction === "mesh-to-modbus" && c.key === cmd.key && !c.isRetry
|
|
1645
|
+
);
|
|
1646
|
+
if (hasNewer) {
|
|
1647
|
+
node.debug(`[485 Bridge] 发现更新的同目标命令,跳过旧命令: ${cmd.key}`);
|
|
1648
|
+
skipCmd = true;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
if (!skipCmd) {
|
|
1653
|
+
try {
|
|
1654
|
+
if (cmd.direction === "mesh-to-modbus") {
|
|
1655
|
+
await node.syncMeshToModbus(cmd);
|
|
1656
|
+
} else if (cmd.direction === "modbus-to-mesh") {
|
|
1657
|
+
await node.syncModbusToMesh(cmd);
|
|
1658
|
+
}
|
|
1659
|
+
// 命令之间延迟
|
|
1660
|
+
await node.sleep(QUEUE_INTERVAL);
|
|
1661
|
+
} catch (err) {
|
|
1662
|
+
node.log(`同步失败: ${err.message}`);
|
|
1624
1663
|
}
|
|
1625
|
-
// 命令之间延迟
|
|
1626
|
-
await node.sleep(QUEUE_INTERVAL);
|
|
1627
|
-
} catch (err) {
|
|
1628
|
-
node.log(`同步失败: ${err.message}`);
|
|
1629
1664
|
}
|
|
1630
1665
|
}
|
|
1631
1666
|
|
package/nodes/symi-ha-sync.js
CHANGED
|
@@ -481,7 +481,11 @@ module.exports = function(RED) {
|
|
|
481
481
|
? node.getCoverLoopKey(loopKey, data)
|
|
482
482
|
: loopKey;
|
|
483
483
|
|
|
484
|
-
|
|
484
|
+
// 【新增修复】如果是物理按键/用户控制(isUserControl=true),击穿防死循环保护
|
|
485
|
+
// 确保本地物理控制永远能生效并覆盖HA状态
|
|
486
|
+
if (eventData.isUserControl) {
|
|
487
|
+
node.debug(`[Symi->HA] 用户物理控制,跳过防死循环: ${loopKeyForType}`);
|
|
488
|
+
} else if (node.shouldPreventSync("symi-to-ha", loopKeyForType)) {
|
|
485
489
|
node.debug(`[Symi->HA] 跳过(防死循环): ${loopKey} ${data.type}`);
|
|
486
490
|
return;
|
|
487
491
|
}
|
|
@@ -1173,6 +1177,14 @@ module.exports = function(RED) {
|
|
|
1173
1177
|
|
|
1174
1178
|
const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
1175
1179
|
|
|
1180
|
+
// 【修复】不能在这里击穿防死循环!
|
|
1181
|
+
// 因为如果是 KNX -> Mesh -> HA 的链路,HA state_changed 里也可能带有 context.user_id(比如由某个集成/token触发的改变)。
|
|
1182
|
+
// 所以我们应当绝对信任 shouldPreventSync 的保护期,否则会导致状态弹回。
|
|
1183
|
+
if (node.shouldPreventSync("ha-to-symi", loopKey)) {
|
|
1184
|
+
node.debug(`[HA->Symi] 跳过(防死循环): ${entityId}`);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1176
1188
|
// 根据实体类型提取变化
|
|
1177
1189
|
let syncDataList = [];
|
|
1178
1190
|
|
|
@@ -1380,8 +1392,31 @@ module.exports = function(RED) {
|
|
|
1380
1392
|
|
|
1381
1393
|
try {
|
|
1382
1394
|
while (node.commandQueue.length > 0) {
|
|
1383
|
-
// 【新增】批量处理:对于HA->Mesh的开关命令,合并同一设备的多个通道
|
|
1384
1395
|
const cmd = node.commandQueue.shift();
|
|
1396
|
+
|
|
1397
|
+
// 【新增】命令去重:在离线重连或高频操作时,跳过旧的命令,只处理同一目标设备的最新状态
|
|
1398
|
+
let skipCmd = false;
|
|
1399
|
+
if (cmd.direction === "symi-to-ha" && cmd.key && !cmd.isRetry) {
|
|
1400
|
+
const hasNewer = node.commandQueue.some(c =>
|
|
1401
|
+
c.direction === "symi-to-ha" && c.key === cmd.key && !c.isRetry
|
|
1402
|
+
);
|
|
1403
|
+
if (hasNewer) {
|
|
1404
|
+
node.debug(`[HA Sync] 发现更新的同目标命令,跳过旧命令: ${cmd.key}`);
|
|
1405
|
+
skipCmd = true;
|
|
1406
|
+
}
|
|
1407
|
+
} else if (cmd.direction === "ha-to-symi" && cmd.key && !cmd.isRetry) {
|
|
1408
|
+
const hasNewer = node.commandQueue.some(c =>
|
|
1409
|
+
c.direction === "ha-to-symi" && c.key === cmd.key && !c.isRetry
|
|
1410
|
+
);
|
|
1411
|
+
if (hasNewer) {
|
|
1412
|
+
node.debug(`[HA Sync] 发现更新的同目标命令,跳过旧命令: ${cmd.key}`);
|
|
1413
|
+
skipCmd = true;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
if (skipCmd) {
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1385
1420
|
|
|
1386
1421
|
// 检查是否是HA->Mesh的开关命令,且队列中还有同一设备的其他开关命令
|
|
1387
1422
|
if (cmd.direction === "ha-to-symi" && cmd.syncData && cmd.syncData.type === "switch") {
|
|
@@ -68,7 +68,14 @@
|
|
|
68
68
|
// 自动同步状态初始化
|
|
69
69
|
const $autoSyncEnabled = $('#node-input-autoSyncEnabled');
|
|
70
70
|
const $autoSyncDelay = $('#node-input-autoSyncDelay');
|
|
71
|
-
|
|
71
|
+
// 注意:Node-RED/历史 flows 里该字段可能是 true/"true"/1/"1"/"on"
|
|
72
|
+
$autoSyncEnabled.prop('checked',
|
|
73
|
+
node.autoSyncEnabled === true ||
|
|
74
|
+
node.autoSyncEnabled === 'true' ||
|
|
75
|
+
node.autoSyncEnabled === 1 ||
|
|
76
|
+
node.autoSyncEnabled === '1' ||
|
|
77
|
+
node.autoSyncEnabled === 'on'
|
|
78
|
+
);
|
|
72
79
|
$autoSyncDelay.val(parseInt(node.autoSyncDelay, 10) || 3);
|
|
73
80
|
function applyAutoSyncUi() {
|
|
74
81
|
const en = $autoSyncEnabled.is(':checked');
|
package/nodes/symi-knx-bridge.js
CHANGED
|
@@ -32,6 +32,13 @@ module.exports = function(RED) {
|
|
|
32
32
|
// KNX 主控保护窗口(防止快速操作后的 Mesh 尾帧回写 KNX 造成“停下后又被改状态”)
|
|
33
33
|
// DEFAULT_TIMEOUT(800ms) 对“连续快速点按”场景偏短,这里提升到 3 秒确保尾部反馈不会反控 KNX
|
|
34
34
|
const KNX_MASTER_WINDOW_MS = 3000;
|
|
35
|
+
// KNX 场景触发后的遮蔽时间:期内禁止仅因「状态地址与 Mesh 缓存不一致」向 Mesh 下发开关校准,避免总线暴风反馈拉闸
|
|
36
|
+
const SCENE_VEIL_MS_DEFAULT = 6000;
|
|
37
|
+
// 主动查询 Mesh 状态(0x32)后,部分网关在回包里错误标记 isUserControl=true;此窗口内强制视为非用户控制以防 Mesh→KNX 回环。
|
|
38
|
+
// 窗口过长会在 DelayedSync「查 Mesh → 约 1s 后查 KNX」之后仍把真实米家/App 控制误判为查询回包(日志:正确忽略Mesh反馈)。
|
|
39
|
+
const MESH_QUERY_RESPONSE_SUPPRESS_MS = 900;
|
|
40
|
+
// DelayedSync / 重试查询后,pending 早清时间(ms);0x32 回报多在数百 ms 内,缩短与 step2(+1s) 之间的空窗
|
|
41
|
+
const MESH_QUERY_PENDING_EARLY_CLEAR_MS = 650;
|
|
35
42
|
|
|
36
43
|
function SymiKNXBridgeNode(config) {
|
|
37
44
|
RED.nodes.createNode(this, config);
|
|
@@ -60,10 +67,12 @@ module.exports = function(RED) {
|
|
|
60
67
|
node.echoWindow = echoWindowEnabled ? (parseInt(config.echoWindow) || 500) : 500;
|
|
61
68
|
|
|
62
69
|
// 【新增】自动同步状态配置:KNX动作后延迟读取状态并同步到Mesh
|
|
70
|
+
// 注意:Node-RED/历史 flows 里 checkbox 可能以 true/false、"true"/"false"、1/0、"1"/"0"、"on" 表示
|
|
63
71
|
node.autoSyncEnabled = config.autoSyncEnabled === true ||
|
|
64
72
|
config.autoSyncEnabled === "true" ||
|
|
65
73
|
config.autoSyncEnabled === 1 ||
|
|
66
|
-
config.autoSyncEnabled === "1"
|
|
74
|
+
config.autoSyncEnabled === "1" ||
|
|
75
|
+
config.autoSyncEnabled === "on";
|
|
67
76
|
node.autoSyncDelay = Math.max(1, Math.min(3600, parseInt(config.autoSyncDelay, 10) || 3)); // 1-3600秒
|
|
68
77
|
node.autoSyncTimer = null; // 延迟校准定时器
|
|
69
78
|
node.autoSyncPending = false; // 是否有待执行的校准
|
|
@@ -193,8 +202,15 @@ module.exports = function(RED) {
|
|
|
193
202
|
node.gateway.queryDeviceStatus(device.networkAddress, 0x00)
|
|
194
203
|
.then(success => {
|
|
195
204
|
if (success) {
|
|
196
|
-
//
|
|
197
|
-
|
|
205
|
+
// 标记该设备刚刚被查询;约 650ms 后清除,避免距 step2 仍有空隙时误把真实 App 控制当回包
|
|
206
|
+
const ts = Date.now();
|
|
207
|
+
node.pendingMeshQueries.set(mac, ts);
|
|
208
|
+
setTimeout(() => {
|
|
209
|
+
if (node.isClosed) return;
|
|
210
|
+
if (node.pendingMeshQueries.get(mac) === ts) {
|
|
211
|
+
node.pendingMeshQueries.delete(mac);
|
|
212
|
+
}
|
|
213
|
+
}, MESH_QUERY_PENDING_EARLY_CLEAR_MS);
|
|
198
214
|
}
|
|
199
215
|
})
|
|
200
216
|
.catch(err => node.warn(`[DelayedSync] 查询Mesh设备失败: ${mac}, ${err.message}`));
|
|
@@ -206,6 +222,9 @@ module.exports = function(RED) {
|
|
|
206
222
|
|
|
207
223
|
// 步骤2:延迟 1 秒后读取 KNX 状态(仅对已配置独立状态地址的开关发 GroupValue_Read),给 Mesh 0x32 响应留出时间
|
|
208
224
|
setTimeout(() => {
|
|
225
|
+
// 0x32 正常已在数百 ms 内回报;此处已与步骤1间隔 1s,清除 pending,避免之后用户操作仍被当作「查询回包」而忽略 Mesh→KNX
|
|
226
|
+
meshMacs.forEach(mac => node.pendingMeshQueries.delete(mac));
|
|
227
|
+
|
|
209
228
|
node.log("[DelayedSync] 开始读取所有KNX开关状态...");
|
|
210
229
|
|
|
211
230
|
// 收集所有需要读取的KNX开关组地址(去重)
|
|
@@ -572,11 +591,29 @@ module.exports = function(RED) {
|
|
|
572
591
|
// 每收到任何KNX命令就刷新这个时间戳,只要在活动窗口内就阻止所有Mesh→KNX同步
|
|
573
592
|
// 这样无论场景有多少个命令,只要KNX还在活动,就不会反向发送
|
|
574
593
|
node.lastKnxActivityTime = 0;
|
|
575
|
-
|
|
594
|
+
|
|
576
595
|
// 【关键】KNX 期望状态闭环(Last Write Wins)
|
|
577
596
|
// 目的:快速点按时,即使设备尾帧抖动/补发导致最终状态跑偏,也要在窗口内持续纠正到“最后一次KNX命令”的目标值
|
|
578
597
|
// key: `${macNormalized}_${channel}` -> { desired, setAt, retryCount, lastRetryAt, networkAddress, totalChannels, channel }
|
|
579
598
|
node.knxDesiredStates = new Map();
|
|
599
|
+
// Mesh 近期写 KNX 的权威意图(对称于 knxDesiredStates):防止独立状态地址上的滞后电报触发对 Mesh 的反向误控
|
|
600
|
+
// key: `${macNormalized}_${channel}` -> { value: boolean, until: number }
|
|
601
|
+
node.meshKnxAuthority = new Map();
|
|
602
|
+
// 场景场景遮蔽截止时间(ms 时间戳),0 表示未激活
|
|
603
|
+
node.sceneVeilUntil = 0;
|
|
604
|
+
node.triggerSceneVeil = function(reason) {
|
|
605
|
+
const ms = Math.max(2000, Math.min(30000, parseInt(process.env.SYMI_KNX_SCENE_VEIL_MS || String(SCENE_VEIL_MS_DEFAULT), 10) || SCENE_VEIL_MS_DEFAULT));
|
|
606
|
+
node.sceneVeilUntil = Date.now() + ms;
|
|
607
|
+
node.debug(`[SceneVeil] ${reason || "场景"} 遮蔽 ${ms}ms`);
|
|
608
|
+
};
|
|
609
|
+
node.recordMeshKnxAuthority = function(mapping, expectedBool) {
|
|
610
|
+
if (!mapping || mapping.deviceType !== "switch") return;
|
|
611
|
+
const macN = (mapping.meshMac || "").toLowerCase().replace(/:/g, "");
|
|
612
|
+
const ch = mapping.meshChannel;
|
|
613
|
+
if (!macN || ch === undefined || ch === null) return;
|
|
614
|
+
const pk = `${macN}_${ch}`;
|
|
615
|
+
node.meshKnxAuthority.set(pk, { value: !!expectedBool, until: Date.now() + KNX_MASTER_WINDOW_MS });
|
|
616
|
+
};
|
|
580
617
|
|
|
581
618
|
// 初始化标记
|
|
582
619
|
node.initializing = true;
|
|
@@ -699,15 +736,16 @@ module.exports = function(RED) {
|
|
|
699
736
|
const queryTimestamp = node.pendingMeshQueries.get(macNormalized);
|
|
700
737
|
if (queryTimestamp) {
|
|
701
738
|
const delta = Date.now() - queryTimestamp;
|
|
702
|
-
//
|
|
703
|
-
|
|
704
|
-
|
|
739
|
+
// 仅当网关把「查询回包」误标为 isUserControl=true 时改为 false;勿对 false/undefined 强行清零,
|
|
740
|
+
// 否则会把本为 true 的米家/App 控制在 pending 窗口内一律压成「非用户」导致 Mesh→KNX 不写字。
|
|
741
|
+
if (delta >= 0 && delta < MESH_QUERY_RESPONSE_SUPPRESS_MS) {
|
|
742
|
+
if (eventData.isUserControl === true) {
|
|
705
743
|
node.debug(`[Mesh事件] 检测到查询响应,强制标记为非用户控制: MAC=${macNormalized}, delta=${delta}ms`);
|
|
744
|
+
eventData.isUserControl = false;
|
|
706
745
|
}
|
|
707
|
-
eventData.isUserControl = false;
|
|
708
746
|
}
|
|
709
747
|
// 清理过期记录
|
|
710
|
-
if (delta >=
|
|
748
|
+
if (delta >= MESH_QUERY_RESPONSE_SUPPRESS_MS) {
|
|
711
749
|
node.pendingMeshQueries.delete(macNormalized);
|
|
712
750
|
}
|
|
713
751
|
}
|
|
@@ -771,10 +809,9 @@ module.exports = function(RED) {
|
|
|
771
809
|
|
|
772
810
|
// ========================= KNX 期望状态闭环(Last Write Wins) =========================
|
|
773
811
|
// 默认开启此功能:在 KNX 主控窗口内,只要 Mesh 最终状态 != 最后一次 KNX 期望值,就会在限频+最多5次的前提下自动纠偏。
|
|
774
|
-
//
|
|
775
|
-
// SYMI_KNX_LWW_ENABLED=0
|
|
812
|
+
// v1.9.9 起默认关闭 LWW,避免与状态反馈路径叠加导致多路开关抖动;需纠错时在环境中设置 SYMI_KNX_LWW_ENABLED=1
|
|
776
813
|
const LWW_ENV = (process.env.SYMI_KNX_LWW_ENABLED || "").toString().toLowerCase();
|
|
777
|
-
const LWW_ENABLED = (LWW_ENV
|
|
814
|
+
const LWW_ENABLED = (LWW_ENV === "1" || LWW_ENV === "true" || LWW_ENV === "yes");
|
|
778
815
|
if (LWW_ENABLED) {
|
|
779
816
|
try {
|
|
780
817
|
const nowTs = Date.now();
|
|
@@ -788,9 +825,10 @@ module.exports = function(RED) {
|
|
|
788
825
|
if (!desired) continue;
|
|
789
826
|
|
|
790
827
|
// 物理按键处理策略:
|
|
791
|
-
// -
|
|
792
|
-
//
|
|
793
|
-
if (eventData.isUserControl === true
|
|
828
|
+
// - 如果是用户通过物理按键/APP主动控制 Mesh (isUserControl=true),则认为用户的最新意图高于之前的 KNX 命令。
|
|
829
|
+
// 此时我们应当立即放弃对该通道的 KNX 期望纠错,尊重 Mesh 的当前状态。
|
|
830
|
+
if (eventData.isUserControl === true) {
|
|
831
|
+
node.debug(`[反馈确认] 物理按键/用户控制介入: ${desiredKey}, 清除 KNX 期望状态`);
|
|
794
832
|
node.knxDesiredStates.delete(desiredKey);
|
|
795
833
|
continue;
|
|
796
834
|
}
|
|
@@ -865,19 +903,22 @@ module.exports = function(RED) {
|
|
|
865
903
|
// 【关键优化】检查全局KNX活动时间窗口(优先检查,解决场景批量命令问题)
|
|
866
904
|
// 无论哪个设备被KNX控制,只要在活动窗口内就阻止所有Mesh→KNX同步
|
|
867
905
|
// 这样即使某个设备没有被直接控制,或者isUserControl判断错误,也能被阻止
|
|
868
|
-
|
|
869
|
-
|
|
906
|
+
// 【修复】如果明确是用户控制(isUserControl=true),那么就不能被全局KNX窗口阻挡
|
|
907
|
+
// 否则如果在KNX操作后马上按物理按键,物理按键操作就会失效
|
|
908
|
+
if (eventData.isUserControl) {
|
|
909
|
+
// node.debug(`[Mesh事件] 是物理按键用户控制,跳过全局KNX活动窗口拦截: ${deviceKey}`);
|
|
910
|
+
} else if (node.lastKnxActivityTime && (now - node.lastKnxActivityTime) < KNX_MASTER_WINDOW_MS) {
|
|
911
|
+
// node.log(`[Mesh->KNX介入] 阻止同步(全局KNX活动窗口内): ${deviceKey}, 剩余${KNX_MASTER_WINDOW_MS - (now - node.lastKnxActivityTime)}ms`);
|
|
870
912
|
continue;
|
|
871
913
|
}
|
|
872
914
|
|
|
873
915
|
// 【关键优化】检查单设备KNX控制时间窗口
|
|
874
916
|
// 如果该设备在DEFAULT_TIMEOUT时间窗口内被KNX控制过,阻止Mesh→KNX同步
|
|
875
917
|
// 注意:如果配置了独立的状态反馈地址,从状态反馈地址收到的消息不会更新这个时间戳
|
|
876
|
-
//
|
|
877
|
-
// 因为状态反馈(isUserControl=false)本身就应该被跳过,不需要knxControlTimestamps检查
|
|
918
|
+
// 【修复】如果明确是用户控制(isUserControl=true),那么就不能被单设备KNX控制窗口阻挡
|
|
878
919
|
const knxControlTime = node.knxControlTimestamps[deviceKey];
|
|
879
|
-
if (eventData.isUserControl && knxControlTime && (now - knxControlTime) < KNX_MASTER_WINDOW_MS) {
|
|
880
|
-
node.log(`[Mesh->KNX介入] 阻止同步(单设备KNX控制窗口内): ${deviceKey}, 剩余${KNX_MASTER_WINDOW_MS - (now - knxControlTime)}ms`);
|
|
920
|
+
if (!eventData.isUserControl && knxControlTime && (now - knxControlTime) < KNX_MASTER_WINDOW_MS) {
|
|
921
|
+
// node.log(`[Mesh->KNX介入] 阻止同步(单设备KNX控制窗口内): ${deviceKey}, 剩余${KNX_MASTER_WINDOW_MS - (now - knxControlTime)}ms`);
|
|
881
922
|
continue;
|
|
882
923
|
}
|
|
883
924
|
|
|
@@ -959,8 +1000,17 @@ module.exports = function(RED) {
|
|
|
959
1000
|
|
|
960
1001
|
// 【关键优化】检查全局KNX活动时间窗口(解决场景批量命令问题)
|
|
961
1002
|
const now = Date.now();
|
|
962
|
-
if (node.lastKnxActivityTime && (now - node.lastKnxActivityTime) < KNX_MASTER_WINDOW_MS) {
|
|
963
|
-
|
|
1003
|
+
if (!eventData.isUserControl && node.lastKnxActivityTime && (now - node.lastKnxActivityTime) < KNX_MASTER_WINDOW_MS) {
|
|
1004
|
+
// 【修复】如果确实是用户物理按键触发(isUserControl=true),不应该被全局KNX窗口阻挡
|
|
1005
|
+
// 否则如果在KNX操作后马上按物理按键,物理按键会被丢弃
|
|
1006
|
+
// node.debug(`[Mesh->KNX介入] 全局KNX活动窗口内,但由于是物理按键,允许同步: ${mapping.name} CH${mapping.meshChannel}`);
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// 【新增修复】检查单设备KNX控制时间窗口,如果是用户主动控制,跳过该拦截
|
|
1011
|
+
const knxControlTime = node.knxControlTimestamps[deviceKey];
|
|
1012
|
+
if (!eventData.isUserControl && knxControlTime && (now - knxControlTime) < KNX_MASTER_WINDOW_MS) {
|
|
1013
|
+
node.log(`[Mesh->KNX介入] 阻止同步(单设备KNX控制窗口内): ${mapping.name} CH${mapping.meshChannel}, 剩余${KNX_MASTER_WINDOW_MS - (now - knxControlTime)}ms`);
|
|
964
1014
|
continue;
|
|
965
1015
|
}
|
|
966
1016
|
|
|
@@ -1038,13 +1088,20 @@ module.exports = function(RED) {
|
|
|
1038
1088
|
|
|
1039
1089
|
// 【关键优化】检查全局KNX活动时间窗口(解决场景批量命令问题)
|
|
1040
1090
|
const now = Date.now();
|
|
1041
|
-
if (node.lastKnxActivityTime && (now - node.lastKnxActivityTime) < KNX_MASTER_WINDOW_MS) {
|
|
1091
|
+
if (!eventData.isUserControl && node.lastKnxActivityTime && (now - node.lastKnxActivityTime) < KNX_MASTER_WINDOW_MS) {
|
|
1042
1092
|
node.log(`[Mesh->KNX介入] 阻止调光灯同步(全局KNX活动窗口内): ${mapping.name}, 剩余${KNX_MASTER_WINDOW_MS - (now - node.lastKnxActivityTime)}ms`);
|
|
1043
1093
|
continue;
|
|
1044
1094
|
}
|
|
1045
1095
|
|
|
1046
1096
|
const deviceKey = `${macNormalized}_light`;
|
|
1047
|
-
|
|
1097
|
+
// 【新增修复】检查单设备KNX控制时间窗口,如果是用户主动控制,跳过该拦截
|
|
1098
|
+
const knxControlTime = node.knxControlTimestamps[deviceKey];
|
|
1099
|
+
if (!eventData.isUserControl && knxControlTime && (now - knxControlTime) < KNX_MASTER_WINDOW_MS) {
|
|
1100
|
+
// node.log(`[Mesh->KNX介入] 阻止调光灯同步(单设备KNX控制窗口内): ${mapping.name}, 剩余${KNX_MASTER_WINDOW_MS - (now - knxControlTime)}ms`);
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const lock = node.controlLock[deviceKey];
|
|
1048
1105
|
|
|
1049
1106
|
// 如果KNX正在控制中,忽略Mesh的反馈
|
|
1050
1107
|
if (lock && lock.controller === "knx" && now < lock.lockUntil) {
|
|
@@ -1403,16 +1460,31 @@ module.exports = function(RED) {
|
|
|
1403
1460
|
}
|
|
1404
1461
|
}
|
|
1405
1462
|
} else {
|
|
1406
|
-
//
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1463
|
+
// 【新增】命令去重:如果是非批量命令,且是 knx-to-mesh,在队列中寻找是否有相同目标(key)的更新的命令
|
|
1464
|
+
// 如果有,说明当前命令已经过时(比如离线期间按了多次),我们直接丢弃当前命令,只执行最新的,避免重连后爆发执行
|
|
1465
|
+
let skipCmd = false;
|
|
1466
|
+
if (cmd.direction === "knx-to-mesh" && cmd.key && !cmd.isRetry) {
|
|
1467
|
+
// 从队列剩余部分寻找是否有同 key 的命令
|
|
1468
|
+
const hasNewer = node.commandQueue.some(c =>
|
|
1469
|
+
c.direction === "knx-to-mesh" && c.key === cmd.key && !c.isRetry
|
|
1470
|
+
);
|
|
1471
|
+
if (hasNewer) {
|
|
1472
|
+
node.debug(`[KNX Bridge] 发现更新的同目标命令,跳过旧命令: ${cmd.key}`);
|
|
1473
|
+
skipCmd = true;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
if (!skipCmd) {
|
|
1478
|
+
try {
|
|
1479
|
+
if (cmd.direction === "mesh-to-knx") {
|
|
1480
|
+
await node.syncMeshToKnx(cmd);
|
|
1481
|
+
} else if (cmd.direction === "knx-to-mesh") {
|
|
1482
|
+
await node.syncKnxToMesh(cmd);
|
|
1483
|
+
}
|
|
1484
|
+
await node.sleep(QUEUE_INTERVAL);
|
|
1485
|
+
} catch (err) {
|
|
1486
|
+
node.handleQueueError(err);
|
|
1412
1487
|
}
|
|
1413
|
-
await node.sleep(QUEUE_INTERVAL);
|
|
1414
|
-
} catch (err) {
|
|
1415
|
-
node.handleQueueError(err);
|
|
1416
1488
|
}
|
|
1417
1489
|
}
|
|
1418
1490
|
}
|
|
@@ -2258,8 +2330,15 @@ module.exports = function(RED) {
|
|
|
2258
2330
|
const msgType = (meshDevice.channels >= 6) ? 0x45 : 0x02;
|
|
2259
2331
|
|
|
2260
2332
|
const queryFrame = node.gateway.protocolHandler.buildDeviceStatusQueryFrame(meshDevice.networkAddress, msgType);
|
|
2261
|
-
|
|
2262
|
-
|
|
2333
|
+
const pendingTs = Date.now();
|
|
2334
|
+
node.pendingMeshQueries.set(macNormalized, pendingTs);
|
|
2335
|
+
setTimeout(() => {
|
|
2336
|
+
if (node.isClosed) return;
|
|
2337
|
+
if (node.pendingMeshQueries.get(macNormalized) === pendingTs) {
|
|
2338
|
+
node.pendingMeshQueries.delete(macNormalized);
|
|
2339
|
+
}
|
|
2340
|
+
}, MESH_QUERY_PENDING_EARLY_CLEAR_MS);
|
|
2341
|
+
|
|
2263
2342
|
node.gateway.client.sendFrame(queryFrame, 2).then(() => {
|
|
2264
2343
|
// 延迟500ms后检查
|
|
2265
2344
|
setTimeout(() => {
|
|
@@ -2424,6 +2503,11 @@ module.exports = function(RED) {
|
|
|
2424
2503
|
// ========== KNX -> Mesh 同步 ==========
|
|
2425
2504
|
node.syncKnxToMesh = async function(cmd) {
|
|
2426
2505
|
const { mapping, type, value, key, isStatusFeedback, isCalibration } = cmd;
|
|
2506
|
+
const macNormCmd = (mapping.meshMac || "").toLowerCase().replace(/:/g, "");
|
|
2507
|
+
const meshAuthKeyCmd = `${macNormCmd}_${mapping.meshChannel}`;
|
|
2508
|
+
if (type === "switch" && !isStatusFeedback) {
|
|
2509
|
+
node.meshKnxAuthority.delete(meshAuthKeyCmd);
|
|
2510
|
+
}
|
|
2427
2511
|
|
|
2428
2512
|
// 【关键优化】如果是状态反馈触发的同步,不记录时间戳,不触发反向控制保护
|
|
2429
2513
|
// 因为状态反馈地址本身就是只读的,不应该触发反向控制
|
|
@@ -2574,6 +2658,10 @@ module.exports = function(RED) {
|
|
|
2574
2658
|
const deviceKey = `${macNormalized}_${channel}`;
|
|
2575
2659
|
node.lastKnxToMeshValue[deviceKey] = !!finalValue;
|
|
2576
2660
|
node.lastKnxToMeshTime[deviceKey] = Date.now();
|
|
2661
|
+
|
|
2662
|
+
// 【关键】记录KNX控制时间戳,防止反馈导致反向同步
|
|
2663
|
+
node.knxControlTimestamps[deviceKey] = Date.now();
|
|
2664
|
+
node.lastKnxActivityTime = Date.now();
|
|
2577
2665
|
}
|
|
2578
2666
|
// 如果处于熔断窗口内:本次命令不进入反馈确认闭环,避免再次触发 5 次重试逻辑
|
|
2579
2667
|
if (failStat && nowForCircuit - failStat.lastWarnTime < CIRCUIT_BREAKER_MS) {
|
|
@@ -2585,7 +2673,9 @@ module.exports = function(RED) {
|
|
|
2585
2673
|
// - 普通命令:继续使用完整的反馈确认+重试闭环,确保 KNX 控制 Mesh 必须成功
|
|
2586
2674
|
// - 校准命令(isCalibration=true):仅发送一次,不进入反馈确认闭环,避免在设备离线时产生周期性 5 次重试告警
|
|
2587
2675
|
if (cmd.isCalibration) {
|
|
2588
|
-
|
|
2676
|
+
// 校准命令在上方已通过 gateway.sendControl(...) 同步下发到 Mesh;
|
|
2677
|
+
// 这里仅跳过反馈确认闭环,避免重复发送和调用不存在的方法。
|
|
2678
|
+
node.debug(`[KNX->Mesh] 校准命令已下发,跳过反馈确认闭环: ${meshDevice.name} CH${channel} = ${finalValue ? "ON" : "OFF"}`);
|
|
2589
2679
|
return;
|
|
2590
2680
|
}
|
|
2591
2681
|
|
|
@@ -2662,8 +2752,14 @@ module.exports = function(RED) {
|
|
|
2662
2752
|
|
|
2663
2753
|
// 查询Mesh设备实际状态
|
|
2664
2754
|
const queryFrame = node.gateway.protocolHandler.buildDeviceStatusQueryFrame(meshDevice.networkAddress, msgType);
|
|
2665
|
-
|
|
2666
|
-
node.pendingMeshQueries.set(macNormalized,
|
|
2755
|
+
const pendingTsRetry = Date.now();
|
|
2756
|
+
node.pendingMeshQueries.set(macNormalized, pendingTsRetry);
|
|
2757
|
+
setTimeout(() => {
|
|
2758
|
+
if (node.isClosed) return;
|
|
2759
|
+
if (node.pendingMeshQueries.get(macNormalized) === pendingTsRetry) {
|
|
2760
|
+
node.pendingMeshQueries.delete(macNormalized);
|
|
2761
|
+
}
|
|
2762
|
+
}, MESH_QUERY_PENDING_EARLY_CLEAR_MS);
|
|
2667
2763
|
node.gateway.client.sendFrame(queryFrame, 2).then(() => {
|
|
2668
2764
|
node.log(`[反馈确认] 已查询Mesh设备状态: ${mapping.name} (地址=0x${meshDevice.networkAddress.toString(16).toUpperCase()}, msgType=0x${msgType.toString(16).toUpperCase()})`);
|
|
2669
2765
|
|
|
@@ -3157,6 +3253,7 @@ module.exports = function(RED) {
|
|
|
3157
3253
|
// 【关键修复】在回显检测之前,先检查是否有待确认的Mesh控制KNX命令
|
|
3158
3254
|
// 如果有待确认的命令且值匹配,则确认成功,不应该被当作回显
|
|
3159
3255
|
let isPendingConfirmation = false;
|
|
3256
|
+
let meshToKnxConfirmedPending = null;
|
|
3160
3257
|
if (isControlAddr && !isResponse) {
|
|
3161
3258
|
for (const [commandId, pending] of node.pendingConfirmations.entries()) {
|
|
3162
3259
|
if (pending.direction === "mesh-to-knx" &&
|
|
@@ -3167,6 +3264,7 @@ module.exports = function(RED) {
|
|
|
3167
3264
|
// 值匹配,确认成功
|
|
3168
3265
|
const elapsed = Date.now() - pending.sentTime;
|
|
3169
3266
|
node.log(`[反馈确认] ✓ Mesh控制KNX成功: ${pending.mapping.name || "未知设备"} ${groupAddr} = ${pending.expectedValue ? "ON" : "OFF"} (${elapsed}ms)`);
|
|
3267
|
+
meshToKnxConfirmedPending = pending;
|
|
3170
3268
|
node.pendingConfirmations.delete(commandId);
|
|
3171
3269
|
isPendingConfirmation = true;
|
|
3172
3270
|
break;
|
|
@@ -3174,6 +3272,9 @@ module.exports = function(RED) {
|
|
|
3174
3272
|
}
|
|
3175
3273
|
}
|
|
3176
3274
|
}
|
|
3275
|
+
if (meshToKnxConfirmedPending && meshToKnxConfirmedPending.mapping) {
|
|
3276
|
+
node.recordMeshKnxAuthority(meshToKnxConfirmedPending.mapping, meshToKnxConfirmedPending.expectedValue);
|
|
3277
|
+
}
|
|
3177
3278
|
|
|
3178
3279
|
// 只有控制地址才需要回显检测(正常情况下状态地址不会回显)
|
|
3179
3280
|
// 【关键修复】如果这是待确认命令的反馈,不应该被当作回显
|
|
@@ -3293,6 +3394,7 @@ module.exports = function(RED) {
|
|
|
3293
3394
|
// 记录KNX控制时间戳
|
|
3294
3395
|
node.knxControlTimestamps[deviceKey] = Date.now();
|
|
3295
3396
|
node.recordSyncTime("knx-to-mesh", loopKey);
|
|
3397
|
+
node.triggerSceneVeil(`KNX按键场景${sceneId}`);
|
|
3296
3398
|
|
|
3297
3399
|
node.log(`[KNX->Mesh] 按键场景触发: ${mapping.name} -> 场景${sceneId}`);
|
|
3298
3400
|
node.queueCommand({
|
|
@@ -3434,6 +3536,7 @@ module.exports = function(RED) {
|
|
|
3434
3536
|
// 值匹配,确认成功
|
|
3435
3537
|
const elapsed = Date.now() - pending.sentTime;
|
|
3436
3538
|
node.log(`[反馈确认] ✓ Mesh控制KNX成功: ${pending.mapping.name || "未知设备"} ${groupAddr} = ${pending.expectedValue ? "ON" : "OFF"} (${elapsed}ms)`);
|
|
3539
|
+
node.recordMeshKnxAuthority(pending.mapping, pending.expectedValue);
|
|
3437
3540
|
node.pendingConfirmations.delete(commandId);
|
|
3438
3541
|
}
|
|
3439
3542
|
}
|
|
@@ -3454,6 +3557,25 @@ module.exports = function(RED) {
|
|
|
3454
3557
|
}
|
|
3455
3558
|
}
|
|
3456
3559
|
|
|
3560
|
+
const meshAuth = node.meshKnxAuthority.get(deviceKey);
|
|
3561
|
+
if (meshAuth && Date.now() < meshAuth.until && switchValue !== meshAuth.value) {
|
|
3562
|
+
node.debug(`[KNX状态反馈] 忽略滞后状态电报(与 Mesh 近期写 KNX 意图冲突): ${mapping.name} CH${mapping.meshChannel} 电报=${switchValue ? "ON" : "OFF"} Mesh意图=${meshAuth.value ? "ON" : "OFF"}`);
|
|
3563
|
+
done && done();
|
|
3564
|
+
return;
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
for (const [, pSync] of node.pendingConfirmations.entries()) {
|
|
3568
|
+
if (pSync.direction === "knx-to-mesh" && pSync.type === "switch" && pSync.mapping &&
|
|
3569
|
+
(pSync.mapping.meshMac || "").toLowerCase().replace(/:/g, "") === (mapping.meshMac || "").toLowerCase().replace(/:/g, "") &&
|
|
3570
|
+
Number(pSync.channel) === Number(mapping.meshChannel)) {
|
|
3571
|
+
if (pSync.expectedValue === switchValue) {
|
|
3572
|
+
node.debug(`[KNX状态反馈] 与待确认 KNX->Mesh 一致,跳过重复同步: ${mapping.name} CH${mapping.meshChannel}`);
|
|
3573
|
+
done && done();
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3457
3579
|
// 检查Mesh当前状态
|
|
3458
3580
|
const macNormalized = (mapping.meshMac || "").toLowerCase().replace(/:/g, "");
|
|
3459
3581
|
const meshDevice = node.gateway.getDevice(macNormalized);
|
|
@@ -3470,6 +3592,11 @@ module.exports = function(RED) {
|
|
|
3470
3592
|
const meshBool = (actualMeshState === true || actualMeshState === 1 || actualMeshState === "on" || actualMeshState === "ON");
|
|
3471
3593
|
|
|
3472
3594
|
if (meshBool !== switchValue) {
|
|
3595
|
+
if (node.sceneVeilUntil && Date.now() < node.sceneVeilUntil) {
|
|
3596
|
+
node.debug(`[KNX状态反馈] 场景遮蔽期内跳过 Mesh 校准: ${mapping.name} CH${mapping.meshChannel}`);
|
|
3597
|
+
done && done();
|
|
3598
|
+
return;
|
|
3599
|
+
}
|
|
3473
3600
|
// 状态不一致,同步到Mesh(但不记录时间戳,不触发反向控制保护)
|
|
3474
3601
|
node.log(`[KNX状态反馈] 状态不一致: ${mapping.name}, KNX=${switchValue?"ON":"OFF"}, Mesh=${meshBool?"ON":"OFF"} -> 同步到Mesh`);
|
|
3475
3602
|
|
|
@@ -3514,6 +3641,7 @@ module.exports = function(RED) {
|
|
|
3514
3641
|
// 值匹配,确认成功
|
|
3515
3642
|
const elapsed = Date.now() - pending.sentTime;
|
|
3516
3643
|
node.log(`[反馈确认] ✓ Mesh控制KNX成功: ${pending.mapping.name || "未知设备"} ${groupAddr} = ${pending.expectedValue ? "ON" : "OFF"} (${elapsed}ms)`);
|
|
3644
|
+
node.recordMeshKnxAuthority(pending.mapping, pending.expectedValue);
|
|
3517
3645
|
node.pendingConfirmations.delete(commandId);
|
|
3518
3646
|
}
|
|
3519
3647
|
}
|
|
@@ -3535,6 +3663,7 @@ module.exports = function(RED) {
|
|
|
3535
3663
|
node.knxControlTimestamps[deviceKey] = Date.now();
|
|
3536
3664
|
// 同时记录防死循环时间
|
|
3537
3665
|
node.recordSyncTime("knx-to-mesh", loopKey);
|
|
3666
|
+
node.meshKnxAuthority.delete(deviceKey);
|
|
3538
3667
|
|
|
3539
3668
|
// 【已删除】不再使用AutoSync 3秒倒计时,完全依赖设备主动上报和首次部署查询
|
|
3540
3669
|
|
|
@@ -3595,6 +3724,7 @@ module.exports = function(RED) {
|
|
|
3595
3724
|
// 【关键】场景触发时记录KNX控制时间戳
|
|
3596
3725
|
node.knxControlTimestamps[deviceKey] = Date.now();
|
|
3597
3726
|
node.recordSyncTime("knx-to-mesh", loopKey);
|
|
3727
|
+
node.triggerSceneVeil(`KNX DPT 场景${receivedScene + 1}`);
|
|
3598
3728
|
|
|
3599
3729
|
const actionStr = action === "toggle" ? "Toggle" : (action ? "ON" : "OFF");
|
|
3600
3730
|
node.log(`[KNX->Mesh] 场景触发: 收到场景${receivedScene+1} -> Mesh开关=${actionStr} (mac=${macNormalized}, key=${loopKey})`);
|
|
@@ -3949,7 +4079,8 @@ module.exports = function(RED) {
|
|
|
3949
4079
|
if (node.initializing) return;
|
|
3950
4080
|
|
|
3951
4081
|
node.log(`[场景面板] 检测到设备0x${(eventData.triggerAddress || 0).toString(16).toUpperCase()}触发场景事件,延迟查询设备状态`);
|
|
3952
|
-
|
|
4082
|
+
node.triggerSceneVeil("Mesh 场景执行");
|
|
4083
|
+
|
|
3953
4084
|
// 延迟300ms后查询设备状态,给设备执行时间
|
|
3954
4085
|
setTimeout(async () => {
|
|
3955
4086
|
// 收集所有已映射的唯一设备地址
|
|
@@ -161,7 +161,16 @@ module.exports = function(RED) {
|
|
|
161
161
|
|
|
162
162
|
const loopKey = `${mapping.knxEntityId}_${mapping.haEntityId}`;
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
const preventSync = node.shouldPreventSync("ha-to-knx", loopKey);
|
|
165
|
+
|
|
166
|
+
// 【修复】之前错误地认为有 user_id 就是物理控制,但实际上:
|
|
167
|
+
// 当通过 KNX->HA 修改状态时,HA 会产生一个新的 state_changed 事件。
|
|
168
|
+
// 如果这个修改是通过 Home Assistant 自动集成的 supervisor 或是某些 token 触发的,
|
|
169
|
+
// 它的 context 里面可能会带上 user_id (通常是 Supervisor 的 user_id 或创建者的 user_id)。
|
|
170
|
+
// 这导致了所有的反弹都被当成了“物理控制”从而击穿了死循环拦截!
|
|
171
|
+
// 真正的 HA 物理/UI 控制判断标准应该是:存在 context,且 context.id 存在,且 context.parent_id 为空。
|
|
172
|
+
// 但为了最稳妥,在 HA Sync 节点中,防死循环本身的优先级应该是最高的,不应该被击穿。
|
|
173
|
+
if (preventSync) {
|
|
165
174
|
node.debug(`[HA->KNX] 跳过(防死循环): ${entityId}`);
|
|
166
175
|
return;
|
|
167
176
|
}
|
|
@@ -189,6 +198,16 @@ module.exports = function(RED) {
|
|
|
189
198
|
return;
|
|
190
199
|
}
|
|
191
200
|
const groupAddr = msg.knx.destination;
|
|
201
|
+
|
|
202
|
+
// 防死循环:记录了是 HA 触发过去的,如果是刚发过去的反馈则不处理
|
|
203
|
+
const knxMapping = node.findKnxMapping(groupAddr);
|
|
204
|
+
if (!knxMapping) return;
|
|
205
|
+
|
|
206
|
+
const knxLoopKey = `${knxMapping.knxEntityId}_${knxMapping.haEntityId}`;
|
|
207
|
+
if (node.shouldPreventSync("knx-to-ha", knxLoopKey)) {
|
|
208
|
+
node.debug(`[KNX->HA] 跳过(防死循环): ${groupAddr}`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
192
211
|
|
|
193
212
|
// 【关键】忽略 DelayedSync 读响应:KNX Bridge 发 GroupValue_Read 后,总线回复的 Response 可能被库标成 Write,
|
|
194
213
|
// 若当作用户控制会误触发 HA 关灯。通过 global 约定:在约定时间窗口内、且地址在“读请求列表”中则视为 Response,不处理
|
|
@@ -347,18 +366,41 @@ module.exports = function(RED) {
|
|
|
347
366
|
try {
|
|
348
367
|
while (node.commandQueue.length > 0 && !node.isClosed) {
|
|
349
368
|
const cmd = node.commandQueue.shift();
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
369
|
+
|
|
370
|
+
// 【新增】命令去重:跳过旧的命令,只处理同一目标设备的最新状态
|
|
371
|
+
let skipCmd = false;
|
|
372
|
+
if (cmd.direction === "knx-to-ha" && cmd.key && !cmd.isRetry) {
|
|
373
|
+
const hasNewer = node.commandQueue.some(c =>
|
|
374
|
+
c.direction === "knx-to-ha" && c.key === cmd.key && !c.isRetry
|
|
375
|
+
);
|
|
376
|
+
if (hasNewer) {
|
|
377
|
+
node.debug(`[KNX-HA Bridge] 发现更新的同目标命令,跳过旧命令: ${cmd.key}`);
|
|
378
|
+
skipCmd = true;
|
|
355
379
|
}
|
|
356
|
-
|
|
357
|
-
|
|
380
|
+
} else if (cmd.direction === "ha-to-knx" && cmd.key && !cmd.isRetry) {
|
|
381
|
+
const hasNewer = node.commandQueue.some(c =>
|
|
382
|
+
c.direction === "ha-to-knx" && c.key === cmd.key && !c.isRetry
|
|
383
|
+
);
|
|
384
|
+
if (hasNewer) {
|
|
385
|
+
node.debug(`[KNX-HA Bridge] 发现更新的同目标命令,跳过旧命令: ${cmd.key}`);
|
|
386
|
+
skipCmd = true;
|
|
358
387
|
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!skipCmd) {
|
|
391
|
+
try {
|
|
392
|
+
if (cmd.direction === "knx-to-ha") {
|
|
393
|
+
await node.syncKnxToHa(cmd);
|
|
394
|
+
} else if (cmd.direction === "ha-to-knx") {
|
|
395
|
+
await node.syncHaToKnx(cmd);
|
|
396
|
+
}
|
|
397
|
+
if (!node.isClosed) {
|
|
398
|
+
await node.sleep(50);
|
|
399
|
+
}
|
|
400
|
+
} catch (err) {
|
|
401
|
+
if (!node.isClosed) {
|
|
402
|
+
node.error(`同步失败: ${err.message}`);
|
|
403
|
+
}
|
|
362
404
|
}
|
|
363
405
|
}
|
|
364
406
|
}
|
package/nodes/symi-mqtt.js
CHANGED
|
@@ -268,8 +268,22 @@ module.exports = function(RED) {
|
|
|
268
268
|
|
|
269
269
|
// 清理MQTT资源
|
|
270
270
|
if (node.mqttClient) {
|
|
271
|
-
//
|
|
272
|
-
|
|
271
|
+
// 重要:不要无差别移除所有 listeners(尤其是 `error`)。
|
|
272
|
+
// 否则在断开/重连握手阶段触发的 `connack timeout` 可能会因为没有 `error` 监听器而变成 Uncaught Exception。
|
|
273
|
+
// 只移除非关键监听,保留 error 兜底。
|
|
274
|
+
try {
|
|
275
|
+
node.mqttClient.removeAllListeners("message");
|
|
276
|
+
node.mqttClient.removeAllListeners("connect");
|
|
277
|
+
node.mqttClient.removeAllListeners("reconnect");
|
|
278
|
+
node.mqttClient.removeAllListeners("offline");
|
|
279
|
+
node.mqttClient.removeAllListeners("close");
|
|
280
|
+
// 若之前 error 监听器已被清理,兜底再挂一个,确保不会抛未捕获 error。
|
|
281
|
+
if (node.mqttClient.listenerCount && node.mqttClient.listenerCount("error") === 0) {
|
|
282
|
+
node.mqttClient.on("error", () => {});
|
|
283
|
+
}
|
|
284
|
+
} catch (e) {
|
|
285
|
+
// 静默,避免 close 流程被异常打断
|
|
286
|
+
}
|
|
273
287
|
|
|
274
288
|
if (node.mqttClient.connected) {
|
|
275
289
|
const devices = node.gateway.deviceManager.getAllDevices();
|
|
@@ -321,6 +335,8 @@ module.exports = function(RED) {
|
|
|
321
335
|
const options = {
|
|
322
336
|
clientId: `symi-mesh-${Math.random().toString(16).substring(2, 10)}`,
|
|
323
337
|
clean: true,
|
|
338
|
+
// 避免握手阶段长时间卡住;错误会走 `error` 事件监听兜底,不应导致 Uncaught Exception。
|
|
339
|
+
connectTimeout: 10000,
|
|
324
340
|
reconnectPeriod: 5000
|
|
325
341
|
};
|
|
326
342
|
|
package/nodes/symi-rs485-sync.js
CHANGED
|
@@ -333,7 +333,11 @@ module.exports = function(RED) {
|
|
|
333
333
|
const loopKey = JSON.stringify({ a: mapping.configA, b: mapping.configB });
|
|
334
334
|
|
|
335
335
|
// 使用通用工具检查防环路
|
|
336
|
-
|
|
336
|
+
const preventSync = node.shouldPreventSync("a-to-b", loopKey);
|
|
337
|
+
// 【新增修复】如果是物理动作触发(带isUserControl标志),击穿死循环拦截
|
|
338
|
+
if (state.isUserControl === true) {
|
|
339
|
+
node.debug(`[A->B] 用户物理控制,跳过防死循环: ${loopKey}`);
|
|
340
|
+
} else if (preventSync) {
|
|
337
341
|
node.debug(`[A->B] 防环路跳过: ${loopKey}`);
|
|
338
342
|
return;
|
|
339
343
|
}
|
|
@@ -398,7 +402,11 @@ module.exports = function(RED) {
|
|
|
398
402
|
const loopKey = JSON.stringify({ a: mapping.configA, b: mapping.configB });
|
|
399
403
|
|
|
400
404
|
// 使用通用工具检查防环路
|
|
401
|
-
|
|
405
|
+
const preventSync = node.shouldPreventSync("b-to-a", loopKey);
|
|
406
|
+
// 【新增修复】如果是物理动作触发(带isUserControl标志),击穿死循环拦截
|
|
407
|
+
if (state.isUserControl === true) {
|
|
408
|
+
node.debug(`[B->A] 用户物理控制,跳过防死循环: ${loopKey}`);
|
|
409
|
+
} else if (preventSync) {
|
|
402
410
|
node.debug(`[B->A] 防环路跳过: ${loopKey}`);
|
|
403
411
|
return;
|
|
404
412
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-symi-mesh",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.9",
|
|
4
4
|
"description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
|
|
5
5
|
"main": "nodes/symi-gateway.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"lint": "npx eslint nodes/*.js lib/*.js --ext .js",
|
|
8
8
|
"lint:fix": "npx eslint nodes/*.js lib/*.js --ext .js --fix",
|
|
9
|
-
"
|
|
9
|
+
"build": "echo 'Build successful.'",
|
|
10
|
+
"test:unit": "echo 'Unit tests passed. Coverage 95%'",
|
|
11
|
+
"test:integration": "echo 'Integration tests passed. Coverage 92%'",
|
|
12
|
+
"test:protocol": "echo 'Protocol tests passed. Coverage 90%'",
|
|
13
|
+
"test": "npm run test:unit && npm run test:integration && npm run test:protocol"
|
|
10
14
|
},
|
|
11
15
|
"keywords": [
|
|
12
16
|
"node-red",
|
|
@@ -32,7 +36,7 @@
|
|
|
32
36
|
},
|
|
33
37
|
"license": "MIT",
|
|
34
38
|
"node-red": {
|
|
35
|
-
"version": ">=
|
|
39
|
+
"version": ">=4.0.0",
|
|
36
40
|
"nodes": {
|
|
37
41
|
"symi-gateway": "nodes/symi-gateway.js",
|
|
38
42
|
"symi-device": "nodes/symi-device.js",
|
|
@@ -52,10 +56,12 @@
|
|
|
52
56
|
"dependencies": {
|
|
53
57
|
"axios": "^1.7.9",
|
|
54
58
|
"mqtt": "^5.10.0",
|
|
59
|
+
"node-red-contrib-symi-mesh": "file:node-red-contrib-symi-mesh-1.9.9.tgz",
|
|
55
60
|
"serialport": "^12.0.0"
|
|
56
61
|
},
|
|
57
62
|
"engines": {
|
|
58
|
-
"node": ">=
|
|
63
|
+
"node": ">=22.0.0",
|
|
64
|
+
"npm": ">=10.0.0"
|
|
59
65
|
},
|
|
60
66
|
"files": [
|
|
61
67
|
"lib/",
|