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 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
- $autoSyncEnabled.prop('checked', node.autoSyncEnabled === true || node.autoSyncEnabled === 'true');
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');
@@ -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
- // 标记该设备刚刚被查询,handleMeshStateChange 会据此防止反向回环
197
- node.pendingMeshQueries.set(mac, Date.now());
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
- // 查询后 1500ms 内到达的状态变化,一律视为“查询响应”
703
- if (delta >= 0 && delta < 1500) {
704
- if (eventData.isUserControl) {
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 >= 1500) {
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
- // 如需在某些场景下完全关闭“期望状态纠错”逻辑,可在 Node-RED 运行环境中显式设置:
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 !== "0" && LWW_ENV !== "false");
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
- node.pendingMeshQueries.set(macNormalized, Date.now());
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
- node.debug(`[KNX->Mesh] 发送开关(校准): ${meshDevice.name} CH${channel} = ${finalValue ? "ON" : "OFF"}`);
2628
- // 即使是校准,也要发送给 Mesh
2629
- node.gateway.sendSwitchCommand(meshDevice, channel, finalValue)
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, Date.now());
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
  // 收集所有已映射的唯一设备地址
@@ -268,8 +268,22 @@ module.exports = function(RED) {
268
268
 
269
269
  // 清理MQTT资源
270
270
  if (node.mqttClient) {
271
- // 移除所有监听器
272
- node.mqttClient.removeAllListeners();
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.8",
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": {