node-red-contrib-symi-mesh 1.9.8 → 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 +14 -0
- package/nodes/symi-knx-bridge.html +8 -1
- package/nodes/symi-knx-bridge.js +109 -21
- package/nodes/symi-mqtt.js +18 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -681,6 +681,20 @@ RS485通信桥接,支持Modbus协议透传与自定义指令映射。
|
|
|
681
681
|
|
|
682
682
|
## 更新日志
|
|
683
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
|
+
|
|
684
698
|
### v1.9.8 (2026-03-23)
|
|
685
699
|
|
|
686
700
|
#### 稳定性、协议兼容性与合规性增强
|
|
@@ -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开关组地址(去重)
|
|
@@ -577,6 +596,24 @@ module.exports = function(RED) {
|
|
|
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();
|
|
@@ -2293,8 +2330,15 @@ module.exports = function(RED) {
|
|
|
2293
2330
|
const msgType = (meshDevice.channels >= 6) ? 0x45 : 0x02;
|
|
2294
2331
|
|
|
2295
2332
|
const queryFrame = node.gateway.protocolHandler.buildDeviceStatusQueryFrame(meshDevice.networkAddress, msgType);
|
|
2296
|
-
|
|
2297
|
-
|
|
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
|
+
|
|
2298
2342
|
node.gateway.client.sendFrame(queryFrame, 2).then(() => {
|
|
2299
2343
|
// 延迟500ms后检查
|
|
2300
2344
|
setTimeout(() => {
|
|
@@ -2459,6 +2503,11 @@ module.exports = function(RED) {
|
|
|
2459
2503
|
// ========== KNX -> Mesh 同步 ==========
|
|
2460
2504
|
node.syncKnxToMesh = async function(cmd) {
|
|
2461
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
|
+
}
|
|
2462
2511
|
|
|
2463
2512
|
// 【关键优化】如果是状态反馈触发的同步,不记录时间戳,不触发反向控制保护
|
|
2464
2513
|
// 因为状态反馈地址本身就是只读的,不应该触发反向控制
|
|
@@ -2624,11 +2673,9 @@ module.exports = function(RED) {
|
|
|
2624
2673
|
// - 普通命令:继续使用完整的反馈确认+重试闭环,确保 KNX 控制 Mesh 必须成功
|
|
2625
2674
|
// - 校准命令(isCalibration=true):仅发送一次,不进入反馈确认闭环,避免在设备离线时产生周期性 5 次重试告警
|
|
2626
2675
|
if (cmd.isCalibration) {
|
|
2627
|
-
|
|
2628
|
-
//
|
|
2629
|
-
node.
|
|
2630
|
-
.then(() => node.debug(`[KNX->Mesh] ✓ 校准命令已发送: ${meshDevice.name} CH${channel}`))
|
|
2631
|
-
.catch(err => node.warn(`[KNX->Mesh] 校准命令发送失败: ${err.message}`));
|
|
2676
|
+
// 校准命令在上方已通过 gateway.sendControl(...) 同步下发到 Mesh;
|
|
2677
|
+
// 这里仅跳过反馈确认闭环,避免重复发送和调用不存在的方法。
|
|
2678
|
+
node.debug(`[KNX->Mesh] 校准命令已下发,跳过反馈确认闭环: ${meshDevice.name} CH${channel} = ${finalValue ? "ON" : "OFF"}`);
|
|
2632
2679
|
return;
|
|
2633
2680
|
}
|
|
2634
2681
|
|
|
@@ -2705,8 +2752,14 @@ module.exports = function(RED) {
|
|
|
2705
2752
|
|
|
2706
2753
|
// 查询Mesh设备实际状态
|
|
2707
2754
|
const queryFrame = node.gateway.protocolHandler.buildDeviceStatusQueryFrame(meshDevice.networkAddress, msgType);
|
|
2708
|
-
|
|
2709
|
-
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);
|
|
2710
2763
|
node.gateway.client.sendFrame(queryFrame, 2).then(() => {
|
|
2711
2764
|
node.log(`[反馈确认] 已查询Mesh设备状态: ${mapping.name} (地址=0x${meshDevice.networkAddress.toString(16).toUpperCase()}, msgType=0x${msgType.toString(16).toUpperCase()})`);
|
|
2712
2765
|
|
|
@@ -3200,6 +3253,7 @@ module.exports = function(RED) {
|
|
|
3200
3253
|
// 【关键修复】在回显检测之前,先检查是否有待确认的Mesh控制KNX命令
|
|
3201
3254
|
// 如果有待确认的命令且值匹配,则确认成功,不应该被当作回显
|
|
3202
3255
|
let isPendingConfirmation = false;
|
|
3256
|
+
let meshToKnxConfirmedPending = null;
|
|
3203
3257
|
if (isControlAddr && !isResponse) {
|
|
3204
3258
|
for (const [commandId, pending] of node.pendingConfirmations.entries()) {
|
|
3205
3259
|
if (pending.direction === "mesh-to-knx" &&
|
|
@@ -3210,6 +3264,7 @@ module.exports = function(RED) {
|
|
|
3210
3264
|
// 值匹配,确认成功
|
|
3211
3265
|
const elapsed = Date.now() - pending.sentTime;
|
|
3212
3266
|
node.log(`[反馈确认] ✓ Mesh控制KNX成功: ${pending.mapping.name || "未知设备"} ${groupAddr} = ${pending.expectedValue ? "ON" : "OFF"} (${elapsed}ms)`);
|
|
3267
|
+
meshToKnxConfirmedPending = pending;
|
|
3213
3268
|
node.pendingConfirmations.delete(commandId);
|
|
3214
3269
|
isPendingConfirmation = true;
|
|
3215
3270
|
break;
|
|
@@ -3217,6 +3272,9 @@ module.exports = function(RED) {
|
|
|
3217
3272
|
}
|
|
3218
3273
|
}
|
|
3219
3274
|
}
|
|
3275
|
+
if (meshToKnxConfirmedPending && meshToKnxConfirmedPending.mapping) {
|
|
3276
|
+
node.recordMeshKnxAuthority(meshToKnxConfirmedPending.mapping, meshToKnxConfirmedPending.expectedValue);
|
|
3277
|
+
}
|
|
3220
3278
|
|
|
3221
3279
|
// 只有控制地址才需要回显检测(正常情况下状态地址不会回显)
|
|
3222
3280
|
// 【关键修复】如果这是待确认命令的反馈,不应该被当作回显
|
|
@@ -3336,6 +3394,7 @@ module.exports = function(RED) {
|
|
|
3336
3394
|
// 记录KNX控制时间戳
|
|
3337
3395
|
node.knxControlTimestamps[deviceKey] = Date.now();
|
|
3338
3396
|
node.recordSyncTime("knx-to-mesh", loopKey);
|
|
3397
|
+
node.triggerSceneVeil(`KNX按键场景${sceneId}`);
|
|
3339
3398
|
|
|
3340
3399
|
node.log(`[KNX->Mesh] 按键场景触发: ${mapping.name} -> 场景${sceneId}`);
|
|
3341
3400
|
node.queueCommand({
|
|
@@ -3477,6 +3536,7 @@ module.exports = function(RED) {
|
|
|
3477
3536
|
// 值匹配,确认成功
|
|
3478
3537
|
const elapsed = Date.now() - pending.sentTime;
|
|
3479
3538
|
node.log(`[反馈确认] ✓ Mesh控制KNX成功: ${pending.mapping.name || "未知设备"} ${groupAddr} = ${pending.expectedValue ? "ON" : "OFF"} (${elapsed}ms)`);
|
|
3539
|
+
node.recordMeshKnxAuthority(pending.mapping, pending.expectedValue);
|
|
3480
3540
|
node.pendingConfirmations.delete(commandId);
|
|
3481
3541
|
}
|
|
3482
3542
|
}
|
|
@@ -3497,6 +3557,25 @@ module.exports = function(RED) {
|
|
|
3497
3557
|
}
|
|
3498
3558
|
}
|
|
3499
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
|
+
|
|
3500
3579
|
// 检查Mesh当前状态
|
|
3501
3580
|
const macNormalized = (mapping.meshMac || "").toLowerCase().replace(/:/g, "");
|
|
3502
3581
|
const meshDevice = node.gateway.getDevice(macNormalized);
|
|
@@ -3513,6 +3592,11 @@ module.exports = function(RED) {
|
|
|
3513
3592
|
const meshBool = (actualMeshState === true || actualMeshState === 1 || actualMeshState === "on" || actualMeshState === "ON");
|
|
3514
3593
|
|
|
3515
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
|
+
}
|
|
3516
3600
|
// 状态不一致,同步到Mesh(但不记录时间戳,不触发反向控制保护)
|
|
3517
3601
|
node.log(`[KNX状态反馈] 状态不一致: ${mapping.name}, KNX=${switchValue?"ON":"OFF"}, Mesh=${meshBool?"ON":"OFF"} -> 同步到Mesh`);
|
|
3518
3602
|
|
|
@@ -3557,6 +3641,7 @@ module.exports = function(RED) {
|
|
|
3557
3641
|
// 值匹配,确认成功
|
|
3558
3642
|
const elapsed = Date.now() - pending.sentTime;
|
|
3559
3643
|
node.log(`[反馈确认] ✓ Mesh控制KNX成功: ${pending.mapping.name || "未知设备"} ${groupAddr} = ${pending.expectedValue ? "ON" : "OFF"} (${elapsed}ms)`);
|
|
3644
|
+
node.recordMeshKnxAuthority(pending.mapping, pending.expectedValue);
|
|
3560
3645
|
node.pendingConfirmations.delete(commandId);
|
|
3561
3646
|
}
|
|
3562
3647
|
}
|
|
@@ -3578,6 +3663,7 @@ module.exports = function(RED) {
|
|
|
3578
3663
|
node.knxControlTimestamps[deviceKey] = Date.now();
|
|
3579
3664
|
// 同时记录防死循环时间
|
|
3580
3665
|
node.recordSyncTime("knx-to-mesh", loopKey);
|
|
3666
|
+
node.meshKnxAuthority.delete(deviceKey);
|
|
3581
3667
|
|
|
3582
3668
|
// 【已删除】不再使用AutoSync 3秒倒计时,完全依赖设备主动上报和首次部署查询
|
|
3583
3669
|
|
|
@@ -3638,6 +3724,7 @@ module.exports = function(RED) {
|
|
|
3638
3724
|
// 【关键】场景触发时记录KNX控制时间戳
|
|
3639
3725
|
node.knxControlTimestamps[deviceKey] = Date.now();
|
|
3640
3726
|
node.recordSyncTime("knx-to-mesh", loopKey);
|
|
3727
|
+
node.triggerSceneVeil(`KNX DPT 场景${receivedScene + 1}`);
|
|
3641
3728
|
|
|
3642
3729
|
const actionStr = action === "toggle" ? "Toggle" : (action ? "ON" : "OFF");
|
|
3643
3730
|
node.log(`[KNX->Mesh] 场景触发: 收到场景${receivedScene+1} -> Mesh开关=${actionStr} (mac=${macNormalized}, key=${loopKey})`);
|
|
@@ -3992,7 +4079,8 @@ module.exports = function(RED) {
|
|
|
3992
4079
|
if (node.initializing) return;
|
|
3993
4080
|
|
|
3994
4081
|
node.log(`[场景面板] 检测到设备0x${(eventData.triggerAddress || 0).toString(16).toUpperCase()}触发场景事件,延迟查询设备状态`);
|
|
3995
|
-
|
|
4082
|
+
node.triggerSceneVeil("Mesh 场景执行");
|
|
4083
|
+
|
|
3996
4084
|
// 延迟300ms后查询设备状态,给设备执行时间
|
|
3997
4085
|
setTimeout(async () => {
|
|
3998
4086
|
// 收集所有已映射的唯一设备地址
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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": {
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"axios": "^1.7.9",
|
|
58
58
|
"mqtt": "^5.10.0",
|
|
59
|
+
"node-red-contrib-symi-mesh": "file:node-red-contrib-symi-mesh-1.9.9.tgz",
|
|
59
60
|
"serialport": "^12.0.0"
|
|
60
61
|
},
|
|
61
62
|
"engines": {
|