node-red-contrib-symi-mesh 1.8.5 → 1.8.7

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
@@ -558,6 +558,8 @@ npm install node-red-contrib-home-assistant-websocket
558
558
 
559
559
  | 节点 | 用途 |
560
560
  |-----|------|
561
+ | **Symi RS485 Sync** | RS485多机批量同步 |
562
+ | **RS485调试** | 原始485字节流抓取显示 |
561
563
  | **Symi Gateway** | 网关连接(TCP/串口) |
562
564
  | **Symi MQTT** | MQTT桥接,设备发布到HA |
563
565
  | **Symi Device** | Flow中单设备控制/监听 |
@@ -702,6 +704,37 @@ node-red-contrib-symi-mesh/
702
704
 
703
705
  ## 更新日志
704
706
 
707
+ ### v1.8.7 (2026-01-08)
708
+
709
+ **生产环境日志优化与稳定性增强**:
710
+ - **错误日志节流 (Throttling)**:在网关连接和 RS485 配置中引入 60 秒节流机制,同类网络错误(如 `ECONNREFUSED`)每分钟仅记录一次,彻底解决离线时的日志刷屏问题。
711
+ - **日志级别降级**:将所有节点的 `node.error` 和 `node.warn` 统一降级为 `node.log` (Info 级别),保持 Node-RED 控制台整洁,仅在调试模式下显示详细信息。
712
+ - **TCP 客户端优化**:在 `tcp-client` 库级别拦截常见的网络波动报错,提升系统在高频重连场景下的静默稳定性。
713
+ - **全量节点适配**:完成 MQTT、HA同步、云端同步、RS485、KNX 等所有功能节点的日志规范化清理。
714
+
715
+ ### v1.8.6 (2026-01-07)
716
+
717
+ **核心修复与同步增强**:
718
+ - **网关初始化修复**:修复 `symi-gateway` 节点在初始化查询设备状态时的语法错误 (SyntaxError),确保节点能正常加载。
719
+ - **三合一属性路由优化**:优化 `symi-ha-sync` 路由逻辑,支持将 0x02 属性(开关)正确重定向至三合一子实体处理器,解决三合一面板状态同步失效问题。
720
+ - **HA Climate 协议匹配**:修复 HA `climate` 实体协议不匹配问题,将空调/地暖开关操作映射为 `hvac_mode` (heat/cool/off),解决同步时的 500 错误。
721
+ - **风速同步双向修复**:
722
+ - **中英文映射支持**:在 `HA_TO_FAN_MODE` 中增加中文风速(高风/中风/低风/自动)映射,解决 HA 界面操作时的“未知风速值”警告。
723
+ - **Symi -> HA 值转换**:同步至 HA 时自动将英文风速转换为中文,匹配中文版 HA 实体要求,彻底解决同步时的 500 错误。
724
+ - **详细日志追踪**:增加 `[Symi->HA] 发送 HA 请求` 详细日志,包含完整的服务调用路径和 Payload。
725
+
726
+ **三合一设备持久化优化**:
727
+ - **文件持久化存储**:三合一设备类型信息保存到 `~/.node-red/symi-mesh-data/` 目录,实现永久记忆。
728
+ - **检测逻辑优化**:延长检测等待时间至 10 秒(分 20 次检查),解决因网络延迟导致的识别失败。
729
+ - **时序修复**:提前保存 `needsThreeInOneCheck` 状态,确保在响应到达时能正确触发持久化流程。
730
+
731
+ **Mesh -> HA 同步增强**:
732
+ - **状态缓存细粒度化**:采用 `StateCache` 实现 `{mac}_{subEntity}_{property}` 级别的精确对比,只同步真正变化的属性。
733
+ - **调试日志增强**:三合一相关事件统一使用 `log` 级别输出,便于快速排查同步链路问题。
734
+
735
+ **设备管理器功能增强**:
736
+ - 新增 `markAsThreeInOne` / `markAsThermostat` 等持久化管理方法,提升系统重启后的设备恢复速度。
737
+
705
738
  ### v1.8.5 (2026-01-06)
706
739
 
707
740
  **通用同步工具类重构**:
@@ -803,8 +836,8 @@ Copyright (c) 2025 SYMI 亖米
803
836
  ---
804
837
 
805
838
  **作者**: SYMI 亖米
806
- **版本**: 1.8.5
839
+ **版本**: 1.8.6
807
840
  **协议**: 蓝牙MESH网关(初级版)串口协议V1.0
808
- **最后更新**: 2026-01-06
841
+ **最后更新**: 2026-01-07
809
842
  **仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
810
843
  **npm包**: https://www.npmjs.com/package/node-red-contrib-symi-mesh
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  const EventEmitter = require('events');
7
+ const fs = require('fs');
8
+ const path = require('path');
7
9
 
8
10
  const DEVICE_TYPE_NAMES = {
9
11
  0: '未知设备', 1: '零火开关', 2: '单火开关', 3: '智能插座',
@@ -266,9 +268,17 @@ class DeviceInfo {
266
268
  // 收到0x68响应说明这是三合一面板(温控器不会响应0x68)
267
269
  if (parameters.length > 0) {
268
270
  // 首次收到0x68有效响应,标记为三合一
269
- if (!this.isThreeInOne && this.deviceType === 10) {
271
+ const wasThreeInOne = this.isThreeInOne;
272
+ if (!wasThreeInOne && this.deviceType === 10) {
270
273
  this.isThreeInOne = true;
271
274
  this.needsThreeInOneCheck = false;
275
+ // 通知DeviceManager持久化三合一标记
276
+ if (this.manager) {
277
+ this.manager.logger.log(`[DeviceInfo] 0x68响应触发markAsThreeInOne: ${this.macAddress}, wasThreeInOne=${wasThreeInOne}`);
278
+ this.manager.markAsThreeInOne(this.macAddress);
279
+ } else {
280
+ console.error(`[DeviceInfo] manager引用为空,无法持久化三合一标记: ${this.macAddress}`);
281
+ }
272
282
  }
273
283
  this.state.freshAirSwitch = parameters[0] === 0x02;
274
284
  } else if (this.isThreeInOne) {
@@ -285,9 +295,17 @@ class DeviceInfo {
285
295
  // 新风风速 (1=高, 2=中, 3=低, 4=自动, 空参数=查询无结果)
286
296
  // 收到0x6A响应也可以确认是三合一
287
297
  if (parameters.length > 0) {
288
- if (!this.isThreeInOne && this.deviceType === 10) {
298
+ const wasThreeInOne = this.isThreeInOne;
299
+ if (!wasThreeInOne && this.deviceType === 10) {
289
300
  this.isThreeInOne = true;
290
301
  this.needsThreeInOneCheck = false;
302
+ // 通知DeviceManager持久化三合一标记
303
+ if (this.manager) {
304
+ this.manager.logger.log(`[DeviceInfo] 0x6A响应触发markAsThreeInOne: ${this.macAddress}, wasThreeInOne=${wasThreeInOne}`);
305
+ this.manager.markAsThreeInOne(this.macAddress);
306
+ } else {
307
+ console.error(`[DeviceInfo] 0x6A: manager引用为空,无法持久化三合一标记: ${this.macAddress}`);
308
+ }
291
309
  }
292
310
  this.state.freshAirSpeed = parameters[0];
293
311
  } else if (this.isThreeInOne) {
@@ -298,9 +316,17 @@ class DeviceInfo {
298
316
  // 地暖开关 (0x02=开, 0x01=关, 空参数=查询无结果)
299
317
  // 收到0x6B响应也可以确认是三合一
300
318
  if (parameters.length > 0) {
301
- if (!this.isThreeInOne && this.deviceType === 10) {
319
+ const wasThreeInOne = this.isThreeInOne;
320
+ if (!wasThreeInOne && this.deviceType === 10) {
302
321
  this.isThreeInOne = true;
303
322
  this.needsThreeInOneCheck = false;
323
+ // 通知DeviceManager持久化三合一标记
324
+ if (this.manager) {
325
+ this.manager.logger.log(`[DeviceInfo] 0x6B响应触发markAsThreeInOne: ${this.macAddress}, wasThreeInOne=${wasThreeInOne}`);
326
+ this.manager.markAsThreeInOne(this.macAddress);
327
+ } else {
328
+ console.error(`[DeviceInfo] 0x6B: manager引用为空,无法持久化三合一标记: ${this.macAddress}`);
329
+ }
304
330
  }
305
331
  this.state.floorHeatingSwitch = parameters[0] === 0x02;
306
332
  } else if (this.isThreeInOne) {
@@ -318,9 +344,18 @@ class DeviceInfo {
318
344
  // 三合一设备状态(0x94是消息类型,不是设备类型)
319
345
  // 收到0x94消息(带有效参数)说明这是三合一面板
320
346
  if (parameters.length >= 8) {
321
- if (!this.isThreeInOne && this.deviceType === 10) {
347
+ const wasThreeInOne = this.isThreeInOne;
348
+ if (!wasThreeInOne && this.deviceType === 10) {
322
349
  this.isThreeInOne = true;
323
350
  this.needsThreeInOneCheck = false;
351
+ // 通知DeviceManager持久化三合一标记
352
+ if (this.manager) {
353
+ this.manager.logger.log(`[DeviceInfo] 0x94响应触发markAsThreeInOne: ${this.macAddress}, wasThreeInOne=${wasThreeInOne}`);
354
+ this.manager.markAsThreeInOne(this.macAddress);
355
+ } else {
356
+ // manager为空时使用console
357
+ console.error(`[DeviceInfo] manager引用为空,无法持久化三合一标记: ${this.macAddress}`);
358
+ }
324
359
  }
325
360
  // 参数格式: [byte0, byte1, byte2(子类型标识), byte3, byte4, byte5(子类型标识2), ...]
326
361
  // 新风: byte5=0x01
@@ -619,8 +654,121 @@ class DeviceManager extends EventEmitter {
619
654
  this.devices = new Map();
620
655
  this.macToAddress = new Map();
621
656
  this.addressToMac = new Map();
657
+
658
+ // 三合一设备类型持久化存储文件路径
659
+ // 使用 Node-RED 用户目录下的文件存储
660
+ this.threeInOneStoragePath = this.getThreeInOneStoragePath();
661
+ this.threeInOneDevices = this.loadThreeInOneDevices();
662
+
622
663
  this.loadDevices();
623
664
  }
665
+
666
+ // 获取三合一设备存储文件路径
667
+ getThreeInOneStoragePath() {
668
+ try {
669
+ // 尝试使用 Node-RED 用户目录
670
+ const userDir = process.env.NODE_RED_HOME ||
671
+ path.join(require('os').homedir(), '.node-red');
672
+ const storageDir = path.join(userDir, 'symi-mesh-data');
673
+
674
+ this.logger.log(`[DeviceManager] 用户目录: ${userDir}`);
675
+ this.logger.log(`[DeviceManager] 存储目录: ${storageDir}`);
676
+
677
+ // 确保目录存在
678
+ if (!fs.existsSync(storageDir)) {
679
+ this.logger.log(`[DeviceManager] 创建存储目录: ${storageDir}`);
680
+ fs.mkdirSync(storageDir, { recursive: true });
681
+ }
682
+
683
+ const filePath = path.join(storageDir, `three-in-one-devices-${this.gatewayId}.json`);
684
+ this.logger.log(`[DeviceManager] 三合一设备存储文件路径: ${filePath}`);
685
+ return filePath;
686
+ } catch (e) {
687
+ this.logger.error(`[DeviceManager] 获取存储路径失败: ${e.message}`);
688
+ return null;
689
+ }
690
+ }
691
+
692
+ // 从文件加载三合一设备列表
693
+ loadThreeInOneDevices() {
694
+ try {
695
+ this.logger.log(`[DeviceManager] 尝试加载三合一设备文件: ${this.threeInOneStoragePath}`);
696
+ if (this.threeInOneStoragePath && fs.existsSync(this.threeInOneStoragePath)) {
697
+ const data = fs.readFileSync(this.threeInOneStoragePath, 'utf8');
698
+ const parsed = JSON.parse(data);
699
+ this.logger.log(`[DeviceManager] 从文件加载了三合一设备: ${JSON.stringify(parsed)}`);
700
+ return parsed;
701
+ } else {
702
+ this.logger.log(`[DeviceManager] 三合一设备文件不存在,返回空对象`);
703
+ }
704
+ } catch (e) {
705
+ this.logger.error(`[DeviceManager] 加载三合一设备文件失败: ${e.message}`);
706
+ }
707
+ return {};
708
+ }
709
+
710
+ // 保存三合一设备列表到文件
711
+ saveThreeInOneDevices() {
712
+ try {
713
+ if (this.threeInOneStoragePath) {
714
+ const content = JSON.stringify(this.threeInOneDevices, null, 2);
715
+ this.logger.log(`[DeviceManager] 正在保存三合一设备到: ${this.threeInOneStoragePath}`);
716
+ this.logger.log(`[DeviceManager] 保存内容: ${content}`);
717
+ fs.writeFileSync(this.threeInOneStoragePath, content);
718
+ this.logger.log(`[DeviceManager] 已保存 ${Object.keys(this.threeInOneDevices).length} 个三合一设备记录到文件`);
719
+ } else {
720
+ this.logger.error(`[DeviceManager] 存储路径为空,无法保存`);
721
+ }
722
+ } catch (e) {
723
+ this.logger.error(`[DeviceManager] 保存三合一设备文件失败: ${e.message}`);
724
+ }
725
+ }
726
+
727
+ // 标记设备为三合一
728
+ markAsThreeInOne(mac) {
729
+ this.logger.log(`[DeviceManager] markAsThreeInOne被调用: mac=${mac}, 存储路径=${this.threeInOneStoragePath}`);
730
+ // 无论之前是什么状态,都更新为三合一
731
+ const existing = this.threeInOneDevices[mac];
732
+ this.logger.log(`[DeviceManager] 现有记录: ${JSON.stringify(existing)}`);
733
+ if (!existing || !existing.isThreeInOne) {
734
+ this.threeInOneDevices[mac] = {
735
+ confirmedAt: Date.now(),
736
+ isThreeInOne: true
737
+ };
738
+ this.saveThreeInOneDevices();
739
+ this.logger.log(`[DeviceManager] 设备 ${mac} 已永久标记为三合一面板`);
740
+ } else {
741
+ this.logger.log(`[DeviceManager] 设备 ${mac} 已经是三合一,跳过保存`);
742
+ }
743
+ }
744
+
745
+ // 标记设备为普通温控器
746
+ markAsThermostat(mac) {
747
+ // 只有在没有记录时才标记为温控器(三合一优先级更高)
748
+ if (!this.threeInOneDevices[mac]) {
749
+ this.threeInOneDevices[mac] = {
750
+ confirmedAt: Date.now(),
751
+ isThreeInOne: false,
752
+ thermostatConfirmed: true
753
+ };
754
+ this.saveThreeInOneDevices();
755
+ this.logger.log(`[DeviceManager] 设备 ${mac} 已永久标记为普通温控器`);
756
+ }
757
+ }
758
+
759
+ // 检查设备是否已确认为三合一
760
+ isConfirmedThreeInOne(mac) {
761
+ const result = this.threeInOneDevices[mac]?.isThreeInOne === true;
762
+ this.logger.log(`[DeviceManager] isConfirmedThreeInOne(${mac}): ${result}, 记录=${JSON.stringify(this.threeInOneDevices[mac])}`);
763
+ return result;
764
+ }
765
+
766
+ // 检查设备是否已确认为普通温控器
767
+ isConfirmedThermostat(mac) {
768
+ const result = this.threeInOneDevices[mac]?.thermostatConfirmed === true;
769
+ this.logger.log(`[DeviceManager] isConfirmedThermostat(${mac}): ${result}`);
770
+ return result;
771
+ }
624
772
 
625
773
  loadDevices() {
626
774
  try {
@@ -630,14 +778,26 @@ class DeviceManager extends EventEmitter {
630
778
 
631
779
  Object.entries(saved).forEach(([mac, data]) => {
632
780
  const device = new DeviceInfo(data, this); // 传递manager引用
633
- // 恢复三合一标记
634
- if (data.isThreeInOne) {
781
+
782
+ // 优先从文件存储恢复三合一标记(持久化)
783
+ if (this.isConfirmedThreeInOne(mac)) {
635
784
  device.isThreeInOne = true;
636
- }
637
- // 恢复温控器确认标记
638
- if (data.thermostatConfirmed) {
785
+ device.needsThreeInOneCheck = false;
786
+ this.logger.log(`[DeviceManager] 设备 ${mac} 从文件恢复为三合一面板`);
787
+ } else if (this.isConfirmedThermostat(mac)) {
639
788
  device.thermostatConfirmed = true;
789
+ device.needsThreeInOneCheck = false;
790
+ this.logger.log(`[DeviceManager] 设备 ${mac} 从文件恢复为普通温控器`);
791
+ } else if (data.isThreeInOne) {
792
+ // 兼容旧的context存储
793
+ device.isThreeInOne = true;
794
+ // 同时保存到文件存储
795
+ this.markAsThreeInOne(mac);
796
+ } else if (data.thermostatConfirmed) {
797
+ device.thermostatConfirmed = true;
798
+ this.markAsThermostat(mac);
640
799
  }
800
+
641
801
  // 恢复按键名称和配置
642
802
  if (data.subDeviceNames) {
643
803
  device.subDeviceNames = data.subDeviceNames;
@@ -698,6 +858,18 @@ class DeviceManager extends EventEmitter {
698
858
 
699
859
  if (!device) {
700
860
  device = new DeviceInfo(deviceData, this);
861
+
862
+ // 新设备:检查文件存储中是否已有三合一标记
863
+ if (this.isConfirmedThreeInOne(mac)) {
864
+ device.isThreeInOne = true;
865
+ device.needsThreeInOneCheck = false;
866
+ this.logger.log(`[DeviceManager] 新设备 ${mac} 从文件恢复为三合一面板`);
867
+ } else if (this.isConfirmedThermostat(mac)) {
868
+ device.thermostatConfirmed = true;
869
+ device.needsThreeInOneCheck = false;
870
+ this.logger.log(`[DeviceManager] 新设备 ${mac} 从文件恢复为普通温控器`);
871
+ }
872
+
701
873
  this.devices.set(mac, device);
702
874
  isNew = true;
703
875
  } else {
@@ -709,6 +881,16 @@ class DeviceManager extends EventEmitter {
709
881
  device.online = deviceData.online !== undefined ? deviceData.online : true;
710
882
  device.lastSeen = Date.now();
711
883
 
884
+ // 已存在的设备:确保三合一标记从文件存储恢复
885
+ if (!device.isThreeInOne && this.isConfirmedThreeInOne(mac)) {
886
+ device.isThreeInOne = true;
887
+ device.needsThreeInOneCheck = false;
888
+ this.logger.log(`[DeviceManager] 设备 ${mac} 从文件恢复为三合一面板`);
889
+ } else if (!device.thermostatConfirmed && this.isConfirmedThermostat(mac)) {
890
+ device.thermostatConfirmed = true;
891
+ device.needsThreeInOneCheck = false;
892
+ }
893
+
712
894
  // 检查名称是否变化(如被标记为三合一)
713
895
  if (device.name !== oldName) {
714
896
  nameChanged = true;
package/lib/tcp-client.js CHANGED
@@ -111,7 +111,7 @@ class TCPClient extends EventEmitter {
111
111
  // 只在首次连接或重要错误时记录,避免大量重复日志
112
112
  // ECONNRESET通常是网络波动,不记录错误
113
113
  else if (!this.connected && error.code !== 'ECONNREFUSED' && error.code !== 'ECONNRESET') {
114
- this.logger.error('TCP client error:', error.message);
114
+ this.logger.log('TCP client error: ' + error.message);
115
115
  }
116
116
 
117
117
  // 确保错误不会导致uncaught exception
@@ -145,12 +145,13 @@ module.exports = function(RED) {
145
145
  });
146
146
 
147
147
  node.rs485Config.on('disconnected', function() {
148
- node.warn('RS485连接已断开');
148
+ node.log('RS485连接已断开');
149
149
  updateStatus();
150
150
  });
151
151
 
152
152
  node.rs485Config.on('error', function(err) {
153
- node.error('RS485错误: ' + err.message);
153
+ // 降级为 log,避免控制台报错
154
+ node.log('RS485错误: ' + err.message);
154
155
  node.status({ fill: 'red', shape: 'ring', text: '错误: ' + err.message });
155
156
  });
156
157
 
@@ -66,6 +66,17 @@ module.exports = function(RED) {
66
66
  node.receiveBuffer = Buffer.alloc(0);
67
67
  node.users = [];
68
68
 
69
+ // 限流错误日志
70
+ node._lastErrorLog = 0;
71
+ node._ERROR_LOG_INTERVAL = 60000;
72
+ node.logErrorThrottled = function(msg) {
73
+ const now = Date.now();
74
+ if (now - node._lastErrorLog > node._ERROR_LOG_INTERVAL) {
75
+ node._lastErrorLog = now;
76
+ node.log(msg);
77
+ }
78
+ };
79
+
69
80
  // 注册使用者
70
81
  node.register = function(userNode) {
71
82
  if (!node.users.includes(userNode)) {
@@ -122,7 +133,7 @@ module.exports = function(RED) {
122
133
  });
123
134
 
124
135
  node.client.on('error', (err) => {
125
- node.error(`RS485串口错误: ${err.message}`);
136
+ node.logErrorThrottled(`RS485串口错误: ${err.message}`);
126
137
  node.emit('error', err);
127
138
  });
128
139
 
@@ -170,7 +181,7 @@ module.exports = function(RED) {
170
181
  if (err.name === 'AggregateError' || err.errors) {
171
182
  node.debug(`RS485 TCP连接失败: 无法连接到 ${node.host}:${node.port}`);
172
183
  } else {
173
- node.error(`RS485 TCP错误: ${err.message}`);
184
+ node.logErrorThrottled(`RS485 TCP错误: ${err.message}`);
174
185
  }
175
186
  node.emit('error', err);
176
187
  });
@@ -332,7 +332,7 @@ module.exports = function(RED) {
332
332
  msg.payload = { scene_id: scene.scene_id };
333
333
  node.send(msg);
334
334
  } else {
335
- node.warn(`场景ID ${msg.payload.scene_id} 不存在`);
335
+ node.log(`场景ID ${msg.payload.scene_id} 不存在`);
336
336
  }
337
337
  }
338
338
  });
@@ -99,21 +99,21 @@ module.exports = function(RED) {
99
99
  node.on('input', async function(msg) {
100
100
  try {
101
101
  if (!node.gateway.connected) {
102
- node.error('网关未连接');
102
+ node.log('网关未连接');
103
103
  return;
104
104
  }
105
105
 
106
106
  if (!node.device) {
107
107
  updateDevice();
108
108
  if (!node.device) {
109
- node.error('设备未找到');
109
+ node.log('设备未找到');
110
110
  return;
111
111
  }
112
112
  }
113
113
 
114
114
  const command = node.parseInputCommand(msg);
115
115
  if (!command) {
116
- node.warn('无效的命令格式');
116
+ node.log('无效的命令格式');
117
117
  return;
118
118
  }
119
119
 
@@ -122,7 +122,7 @@ module.exports = function(RED) {
122
122
  node.status({ fill: 'green', shape: 'dot', text: '命令已发送' });
123
123
 
124
124
  } catch (error) {
125
- node.error(`控制失败: ${error.message}`);
125
+ node.log(`控制失败: ${error.message}`);
126
126
  node.status({ fill: 'red', shape: 'dot', text: '控制失败' });
127
127
  }
128
128
  });
@@ -70,6 +70,17 @@ module.exports = function(RED) {
70
70
  this.sceneExecutionInProgress = false; // 场景执行中标志
71
71
  this.sceneExecutionTimer = null; // 场景执行超时定时器
72
72
 
73
+ // 限流错误日志
74
+ this._lastErrorLog = 0;
75
+ this._ERROR_LOG_INTERVAL = 60000;
76
+ this.logErrorThrottled = function(msg) {
77
+ const now = Date.now();
78
+ if (now - this._lastErrorLog > this._ERROR_LOG_INTERVAL) {
79
+ this._lastErrorLog = now;
80
+ this.log(msg);
81
+ }
82
+ };
83
+
73
84
  this.log(`Initializing Symi Gateway: ${this.connectionType === 'tcp' ? `${this.host}:${this.port}` : this.serialPort}`);
74
85
 
75
86
  // 三合一设备检测已在 queryAllDeviceStates 中实现,无需额外事件监听
@@ -128,7 +139,7 @@ module.exports = function(RED) {
128
139
 
129
140
  } catch (error) {
130
141
  // 初始连接失败,但自动重连会继续尝试
131
- this.error(`Initial connection failed: ${error.message}, will retry automatically`);
142
+ this.logErrorThrottled(`Initial connection failed: ${error.message}, will retry automatically`);
132
143
  }
133
144
 
134
145
  // 节点关闭时清理资源
@@ -138,6 +149,8 @@ module.exports = function(RED) {
138
149
  clearTimeout(this.sceneExecutionTimer);
139
150
  this.sceneExecutionTimer = null;
140
151
  }
152
+
153
+
141
154
 
142
155
  // 清空队列
143
156
  this.stateEventQueue = [];
@@ -162,7 +175,22 @@ module.exports = function(RED) {
162
175
  SymiGatewayNode.prototype.handleFrame = function(frame) {
163
176
  // 使用debug记录详细frame信息,避免日志泛滥
164
177
  const frameHex = Buffer.from([frame.header, frame.opcode, ...(frame.status !== null ? [frame.status] : []), frame.length, ...frame.payload, frame.checksum]).toString('hex').toUpperCase();
165
- this.debug(`[TCP Frame] 接收: ${frameHex} (opcode=0x${frame.opcode.toString(16).toUpperCase()})`);
178
+
179
+ // 三合一相关的帧使用 log 级别
180
+ const isThreeInOneFrame = frame.opcode === 0x80 && frame.payload && frame.payload.length > 2 &&
181
+ [0x94, 0x68, 0x69, 0x6A, 0x6B, 0x6C].includes(frame.payload[2]);
182
+
183
+ // 调试:打印所有 0x80 帧,确认是否收到三合一状态上报
184
+ if (frame.opcode === 0x80) {
185
+ const msgType = frame.payload && frame.payload.length > 2 ? frame.payload[2] : 'N/A';
186
+ this.debug(`[TCP Frame] 0x80帧: ${frameHex}, msgType=0x${typeof msgType === 'number' ? msgType.toString(16).toUpperCase() : msgType}, isThreeInOne=${isThreeInOneFrame}`);
187
+ }
188
+
189
+ if (isThreeInOneFrame) {
190
+ this.debug(`[TCP Frame] 三合一帧: ${frameHex} (opcode=0x${frame.opcode.toString(16).toUpperCase()})`);
191
+ } else {
192
+ this.debug(`[TCP Frame] 接收: ${frameHex} (opcode=0x${frame.opcode.toString(16).toUpperCase()})`)
193
+ }
166
194
 
167
195
  if (frame.opcode === OP_RESP_DEVICE_LIST && frame.status === 0x00) {
168
196
  this.parseDeviceListFrame(frame);
@@ -243,9 +271,11 @@ module.exports = function(RED) {
243
271
 
244
272
  // 记录状态事件
245
273
  const logPrefix = this.sceneExecutionInProgress ? '[场景执行]' : '[状态事件]';
246
- if (this.sceneExecutionInProgress) {
247
- // 场景执行期间使用log级别,便于查看
248
- this.log(`${logPrefix} 地址=0x${event.networkAddress.toString(16).toUpperCase()}, 消息类型=0x${event.attrType.toString(16).toUpperCase()}, 参数=[${Array.from(event.parameters).map(p => '0x' + p.toString(16).toUpperCase()).join(', ')}]`);
274
+ // 三合一相关的 attrType 使用 log 级别,便于调试
275
+ const isThreeInOneAttr = [0x94, 0x68, 0x69, 0x6A, 0x6B, 0x6C].includes(event.attrType);
276
+ if (this.sceneExecutionInProgress || isThreeInOneAttr) {
277
+ // 场景执行期间或三合一事件使用log级别,便于查看
278
+ this.debug(`${logPrefix} 地址=0x${event.networkAddress.toString(16).toUpperCase()}, 消息类型=0x${event.attrType.toString(16).toUpperCase()}, 参数=[${Array.from(event.parameters).map(p => '0x' + p.toString(16).toUpperCase()).join(', ')}]`);
249
279
  } else {
250
280
  this.debug(`${logPrefix} 地址=0x${event.networkAddress.toString(16).toUpperCase()}, 消息类型=0x${event.attrType.toString(16).toUpperCase()}, 参数=[${Array.from(event.parameters).map(p => '0x' + p.toString(16).toUpperCase()).join(', ')}]`);
251
281
  }
@@ -367,11 +397,27 @@ module.exports = function(RED) {
367
397
  // 对于温控器类型(deviceType=10),需要通过查询新风/地暖来判断是否是三合一
368
398
  // 空调温控器和三合一都属于同一品类,只有通过主动查询才能区分
369
399
  if (deviceType === 10) {
370
- // 如果设备已经被确认为三合一或普通温控器,跳过检测
371
- if (device.isThreeInOne) {
372
- this.log(`[三合一检测] ${device.name} 已确认为三合一面板(从缓存恢复)`);
400
+ // 优先检查文件持久化存储(最可靠)
401
+ const isFileConfirmedThreeInOne = this.deviceManager.isConfirmedThreeInOne(device.macAddress);
402
+ const isFileConfirmedThermostat = this.deviceManager.isConfirmedThermostat(device.macAddress);
403
+
404
+ if (isFileConfirmedThreeInOne) {
405
+ // 文件中已确认为三合一,直接恢复,不需要重新检测
406
+ device.isThreeInOne = true;
407
+ device.needsThreeInOneCheck = false;
408
+ this.log(`[三合一检测] ${device.name} 从文件恢复为三合一面板(永久记录)`);
409
+ } else if (isFileConfirmedThermostat) {
410
+ // 文件中已确认为普通温控器
411
+ device.thermostatConfirmed = true;
412
+ device.needsThreeInOneCheck = false;
413
+ this.log(`[三合一检测] ${device.name} 从文件恢复为普通温控器(永久记录)`);
414
+ } else if (device.isThreeInOne) {
415
+ // 内存中已标记为三合一(可能是旧的context存储)
416
+ this.log(`[三合一检测] ${device.name} 已确认为三合一面板(从内存恢复)`);
417
+ // 同时保存到文件
418
+ this.deviceManager.markAsThreeInOne(device.macAddress);
373
419
  } else if (device.thermostatConfirmed) {
374
- this.log(`[三合一检测] ${device.name} 已确认为普通温控器(从缓存恢复)`);
420
+ this.log(`[三合一检测] ${device.name} 已确认为普通温控器(从内存恢复)`);
375
421
  } else {
376
422
  device.needsThreeInOneCheck = true;
377
423
  this.log(`[三合一检测] 发现温控器类型设备: ${device.name},将通过查询新风/地暖确认类型`);
@@ -441,6 +487,13 @@ module.exports = function(RED) {
441
487
  }
442
488
  }
443
489
 
490
+ // 【重要】在发送查询之前保存检测标记状态
491
+ // 因为响应可能在sendFrame期间就到达,updateFromEvent会修改needsThreeInOneCheck
492
+ const wasCheckingThreeInOne = device.needsThreeInOneCheck;
493
+ if (device.deviceType === 10) {
494
+ this.log(`[三合一检测] ${device.name} wasCheckingThreeInOne=${wasCheckingThreeInOne}, queryAttrs=[${queryAttrs.map(a => '0x' + a.toString(16)).join(',')}]`);
495
+ }
496
+
444
497
  for (const attr of queryAttrs) {
445
498
  const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
446
499
  await this.client.sendFrame(frame, 2);
@@ -448,30 +501,42 @@ module.exports = function(RED) {
448
501
  }
449
502
 
450
503
  // 等待设备响应,检查是否被标记为三合一
451
- if (device.needsThreeInOneCheck) {
452
- // 等待1.5秒确保响应到达(网络延迟可能较大)
453
- await this.sleep(1500);
454
-
455
- // 处理状态事件队列,确保所有响应都被处理
456
- await this.processStateEventQueue();
457
-
458
- if (device.isThreeInOne) {
459
- this.log(`[三合一检测] ${device.name} 确认为三合一面板`);
460
- device.needsThreeInOneCheck = false;
461
- this.deviceManager.saveDevices();
504
+ if (wasCheckingThreeInOne) {
505
+ this.log(`[三合一检测] ${device.name} 进入等待循环,最多等待10秒...`);
506
+ // 等待更长时间确保响应到达(网络延迟可能很大,最多等待10秒)
507
+ // 分多次检查,一旦确认就立即继续
508
+ let confirmed = false;
509
+ for (let i = 0; i < 20; i++) { // 20次 * 500ms = 10秒
510
+ await this.sleep(500);
511
+ await this.processStateEventQueue();
462
512
 
463
- // 查询三合一的完整状态
464
- this.log(`[三合一检测] 查询 ${device.name} 完整状态...`);
465
- const threeInOneAttrs = [0x02, 0x16, 0x1B, 0x1C, 0x1D, 0x6A, 0x6C];
466
- for (const attr of threeInOneAttrs) {
467
- const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
468
- await this.client.sendFrame(frame, 2);
469
- await this.sleep(150);
513
+ if (device.isThreeInOne) {
514
+ confirmed = true;
515
+ this.log(`[三合一检测] ${device.name} 确认为三合一面板(等待${(i+1)*0.5}秒后确认)`);
516
+ device.needsThreeInOneCheck = false;
517
+ // 使用文件持久化存储三合一标记
518
+ this.deviceManager.markAsThreeInOne(device.macAddress);
519
+ this.deviceManager.saveDevices();
520
+
521
+ // 查询三合一的完整状态
522
+ this.log(`[三合一检测] 查询 ${device.name} 完整状态...`);
523
+ const threeInOneAttrs = [0x02, 0x16, 0x1B, 0x1C, 0x1D, 0x6A, 0x6C];
524
+ for (const attr of threeInOneAttrs) {
525
+ const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
526
+ await this.client.sendFrame(frame, 2);
527
+ await this.sleep(150);
528
+ }
529
+ break;
470
530
  }
471
- } else {
472
- this.log(`[三合一检测] ${device.name} 确认为普通温控器`);
531
+ }
532
+
533
+ // 10秒后仍未确认为三合一,标记为普通温控器
534
+ if (!confirmed && !device.isThreeInOne) {
535
+ this.log(`[三合一检测] ${device.name} 确认为普通温控器(10秒内无三合一响应)`);
473
536
  device.needsThreeInOneCheck = false;
474
537
  device.thermostatConfirmed = true;
538
+ // 使用文件持久化存储温控器标记
539
+ this.deviceManager.markAsThermostat(device.macAddress);
475
540
  this.deviceManager.saveDevices();
476
541
 
477
542
  // 触发温控器确认事件
@@ -485,6 +550,11 @@ module.exports = function(RED) {
485
550
  await this.sleep(150);
486
551
  }
487
552
  }
553
+ } else if (device.isThreeInOne && !this.deviceManager.isConfirmedThreeInOne(device.macAddress)) {
554
+ // 设备已经被标记为三合一(可能在之前的响应中),但还没有持久化
555
+ this.log(`[三合一检测] ${device.name} 已被标记为三合一,执行持久化`);
556
+ this.deviceManager.markAsThreeInOne(device.macAddress);
557
+ this.deviceManager.saveDevices();
488
558
  }
489
559
  } catch(e) {
490
560
  this.error(`查询设备${device.name}失败: ${e.message}`);
@@ -64,7 +64,14 @@ module.exports = function(RED) {
64
64
 
65
65
  // 风速映射 (Mesh: 1=高, 2=中, 3=低, 4=自动)
66
66
  const FAN_MODE_TO_HA = { 1: 'high', 2: 'medium', 3: 'low', 4: 'auto' };
67
- const HA_TO_FAN_MODE = { high: 1, medium: 2, low: 3, auto: 4 };
67
+ const HA_TO_FAN_MODE = {
68
+ 'high': 1, 'High': 1, 'HIGH': 1, 'strong': 1, '高风': 1, '强': 1,
69
+ 'medium': 2, 'Medium': 2, 'MEDIUM': 2, 'middle': 2, '中风': 2, '中档': 2, '中': 2,
70
+ 'low': 3, 'Low': 3, 'LOW': 3, 'weak': 3, '低风': 3, '弱': 3,
71
+ 'auto': 4, 'Auto': 4, 'AUTO': 4, '自动': 4
72
+ };
73
+ // 专门用于发送给 HA 的风速映射(针对中文版 HA 实体)
74
+ const FAN_MODE_FOR_HA = { 1: '高风', 2: '中风', 3: '低风', 4: '自动' };
68
75
 
69
76
  function SymiHASyncNode(config) {
70
77
  RED.nodes.createNode(this, config);
@@ -110,7 +117,7 @@ module.exports = function(RED) {
110
117
  }
111
118
  } catch (e) {
112
119
  node.mappings = [];
113
- node.error('映射配置解析失败: ' + e.message);
120
+ node.log('映射配置解析失败: ' + e.message);
114
121
  }
115
122
 
116
123
  node.commandQueue = [];
@@ -155,7 +162,7 @@ module.exports = function(RED) {
155
162
 
156
163
  if (configError) {
157
164
  node.status({ fill: 'red', shape: 'ring', text: configError });
158
- node.warn(`[HA同步] ${configError}`);
165
+ node.log(`[HA同步] ${configError}`);
159
166
  // 不要return,继续注册input监听器
160
167
  } else {
161
168
  // 初始状态:只有Mesh→HA方向,等待HA输入连接
@@ -245,18 +252,35 @@ module.exports = function(RED) {
245
252
  const attrType = eventData.attrType;
246
253
  const state = eventData.state || {};
247
254
 
248
- node.debug(`[Symi->HA] 设备状态变化: ${device.macAddress}, attrType=0x${attrType?.toString(16) || 'unknown'}, state=${JSON.stringify(state)}`);
255
+ // 对于三合一相关的 attrType,使用 log 级别以便调试
256
+ const isThreeInOneAttr = [ATTR_THREE_IN_ONE, ATTR_FRESH_AIR_SWITCH, ATTR_FRESH_AIR_MODE,
257
+ ATTR_FRESH_AIR_SPEED, ATTR_FLOOR_HEATING_SWITCH, ATTR_FLOOR_HEATING_TEMP,
258
+ ATTR_TARGET_TEMP, ATTR_FAN_MODE, ATTR_CLIMATE_MODE].includes(attrType);
259
+
260
+ if (isThreeInOneAttr) {
261
+ node.debug(`[Symi->HA] 三合一设备状态变化: ${device.macAddress}, attrType=0x${attrType?.toString(16) || 'unknown'}, isThreeInOne=${device.isThreeInOne}`);
262
+ } else {
263
+ node.debug(`[Symi->HA] 设备状态变化: ${device.macAddress}, attrType=0x${attrType?.toString(16) || 'unknown'}, state=${JSON.stringify(state)}`);
264
+ }
249
265
 
250
266
  // 遍历该设备的所有映射
251
267
  const deviceMappings = node.mappings.filter(m =>
252
268
  m.symiMac.toLowerCase().replace(/:/g, '') === device.macAddress.toLowerCase().replace(/:/g, '')
253
269
  );
254
270
  if (deviceMappings.length === 0) {
255
- node.debug(`[Symi->HA] 设备不在映射中: ${device.macAddress}`);
271
+ if (isThreeInOneAttr) {
272
+ node.log(`[Symi->HA] 三合一设备不在映射中: ${device.macAddress}`);
273
+ } else {
274
+ node.debug(`[Symi->HA] 设备不在映射中: ${device.macAddress}`);
275
+ }
256
276
  return;
257
277
  }
258
278
 
259
- node.debug(`[Symi->HA] 找到 ${deviceMappings.length} 个映射`);
279
+ if (isThreeInOneAttr) {
280
+ node.debug(`[Symi->HA] 找到 ${deviceMappings.length} 个映射: ${deviceMappings.map(m => `${m.symiKey}->${m.haEntityId}`).join(', ')}`);
281
+ } else {
282
+ node.debug(`[Symi->HA] 找到 ${deviceMappings.length} 个映射`);
283
+ }
260
284
 
261
285
  deviceMappings.forEach(mapping => {
262
286
  // 检查同步模式 (0:双向, 1:Symi->HA, 2:HA->Symi)
@@ -271,50 +295,57 @@ module.exports = function(RED) {
271
295
  // 根据attrType和实体类型处理
272
296
  let syncData = null;
273
297
 
274
- switch (attrType) {
275
- case ATTR_SWITCH: // 0x02 开关状态
276
- syncData = node.handleSwitchChange(device, mapping, state);
277
- break;
278
-
279
- case ATTR_BRIGHTNESS: // 0x03 亮度
280
- if (domain === 'light') {
281
- syncData = node.handleBrightnessChange(device, mapping, state);
282
- }
283
- break;
284
-
285
- case ATTR_CURTAIN_STATUS: // 0x05 窗帘运行状态
286
- case ATTR_CURTAIN_POSITION: // 0x06 窗帘位置
287
- if (domain === 'cover') {
288
- syncData = node.handleCurtainChange(device, mapping, state, attrType);
289
- }
290
- break;
291
-
292
- case ATTR_TARGET_TEMP: // 0x1B 目标温度
293
- if (domain === 'climate') {
294
- syncData = node.handleTemperatureChange(device, mapping, state);
295
- }
296
- break;
297
-
298
- case ATTR_FAN_MODE: // 0x1C 风速
299
- if (domain === 'climate' || domain === 'fan') {
300
- syncData = node.handleFanModeChange(device, mapping, state);
301
- }
302
- break;
303
-
304
- case ATTR_CLIMATE_MODE: // 0x1D 空调模式
305
- if (domain === 'climate') {
306
- syncData = node.handleClimateModeChange(device, mapping, state);
307
- }
308
- break;
309
-
310
- case ATTR_THREE_IN_ONE: // 0x94 三合一全量状态
311
- case ATTR_FRESH_AIR_SWITCH: // 0x68 新风开关
312
- case ATTR_FRESH_AIR_MODE: // 0x69 新风模式
313
- case ATTR_FRESH_AIR_SPEED: // 0x6A 新风风速
314
- case ATTR_FLOOR_HEATING_SWITCH: // 0x6B 地暖开关
315
- case ATTR_FLOOR_HEATING_TEMP: // 0x6C 地暖温度
316
- syncData = node.handleThreeInOneChange(device, mapping, state, attrType);
317
- break;
298
+ // 特殊处理:三合一设备的属性应该走三合一处理器
299
+ const useThreeInOneProcessor = device.isThreeInOne && (isThreeInOneAttr || attrType === ATTR_SWITCH);
300
+
301
+ if (useThreeInOneProcessor) {
302
+ syncData = node.handleThreeInOneChange(device, mapping, state, attrType);
303
+ } else {
304
+ switch (attrType) {
305
+ case ATTR_SWITCH: // 0x02 开关状态
306
+ syncData = node.handleSwitchChange(device, mapping, state);
307
+ break;
308
+
309
+ case ATTR_BRIGHTNESS: // 0x03 亮度
310
+ if (domain === 'light') {
311
+ syncData = node.handleBrightnessChange(device, mapping, state);
312
+ }
313
+ break;
314
+
315
+ case ATTR_CURTAIN_STATUS: // 0x05 窗帘运行状态
316
+ case ATTR_CURTAIN_POSITION: // 0x06 窗帘位置
317
+ if (domain === 'cover') {
318
+ syncData = node.handleCurtainChange(device, mapping, state, attrType);
319
+ }
320
+ break;
321
+
322
+ case ATTR_TARGET_TEMP: // 0x1B 目标温度
323
+ if (domain === 'climate') {
324
+ syncData = node.handleTemperatureChange(device, mapping, state);
325
+ }
326
+ break;
327
+
328
+ case ATTR_FAN_MODE: // 0x1C 风速
329
+ if (domain === 'climate' || domain === 'fan') {
330
+ syncData = node.handleFanModeChange(device, mapping, state);
331
+ }
332
+ break;
333
+
334
+ case ATTR_CLIMATE_MODE: // 0x1D 空调模式
335
+ if (domain === 'climate') {
336
+ syncData = node.handleClimateModeChange(device, mapping, state);
337
+ }
338
+ break;
339
+
340
+ case ATTR_THREE_IN_ONE: // 0x94 三合一全量状态
341
+ case ATTR_FRESH_AIR_SWITCH: // 0x68 新风开关
342
+ case ATTR_FRESH_AIR_MODE: // 0x69 新风模式
343
+ case ATTR_FRESH_AIR_SPEED: // 0x6A 新风风速
344
+ case ATTR_FLOOR_HEATING_SWITCH: // 0x6B 地暖开关
345
+ case ATTR_FLOOR_HEATING_TEMP: // 0x6C 地暖温度
346
+ syncData = node.handleThreeInOneChange(device, mapping, state, attrType);
347
+ break;
348
+ }
318
349
  }
319
350
 
320
351
  if (syncData) {
@@ -357,10 +388,44 @@ module.exports = function(RED) {
357
388
 
358
389
  // 处理三合一状态变化
359
390
  node.handleThreeInOneChange = function(device, mapping, state, attrType) {
360
- const subType = mapping.symiKey; // 'aircon', 'fresh_air', 'floor_heating'
391
+ let subType = mapping.symiKey; // 'aircon'/'1', 'fresh_air'/'2', 'floor_heating'/'3'
392
+
393
+ // 统一映射数字 ID 到字符串类型
394
+ if (subType === '1' || subType === 1) subType = 'aircon';
395
+ else if (subType === '2' || subType === 2) subType = 'fresh_air';
396
+ else if (subType === '3' || subType === 3) subType = 'floor_heating';
397
+
361
398
  const mac = device.macAddress.toLowerCase().replace(/:/g, '');
362
399
  const changedData = [];
363
400
 
401
+ // 调试日志:记录收到的三合一事件
402
+ node.debug(`[Symi->HA] 三合一事件: mac=${mac}, subType=${subType}, attrType=0x${attrType.toString(16)}, state=${JSON.stringify(state)}`);
403
+
404
+ // 根据 attrType 判断应该处理哪个子实体
405
+ // 0x94: 全量状态,处理所有子实体
406
+ // 0x68, 0x69, 0x6A: 新风相关
407
+ // 0x6B, 0x6C: 地暖相关
408
+ // 0x02, 0x1B, 0x1C, 0x1D: 空调相关
409
+ let shouldProcess = false;
410
+ if (attrType === ATTR_THREE_IN_ONE) {
411
+ // 0x94 全量状态,所有子实体都应该处理
412
+ shouldProcess = true;
413
+ } else if (attrType === ATTR_FRESH_AIR_SWITCH || attrType === ATTR_FRESH_AIR_MODE || attrType === ATTR_FRESH_AIR_SPEED) {
414
+ // 新风相关属性,只有 fresh_air 子实体处理
415
+ shouldProcess = (subType === 'fresh_air');
416
+ } else if (attrType === ATTR_FLOOR_HEATING_SWITCH || attrType === ATTR_FLOOR_HEATING_TEMP) {
417
+ // 地暖相关属性,只有 floor_heating 子实体处理
418
+ shouldProcess = (subType === 'floor_heating');
419
+ } else {
420
+ // 其他属性(如 0x02, 0x1B, 0x1C, 0x1D),只有 aircon 子实体处理
421
+ shouldProcess = (subType === 'aircon');
422
+ }
423
+
424
+ if (!shouldProcess) {
425
+ node.debug(`[Symi->HA] 三合一跳过: attrType=0x${attrType.toString(16)} 不匹配 subType=${subType}`);
426
+ return null;
427
+ }
428
+
364
429
  // 辅助函数:检查属性是否变化,使用细粒度缓存key
365
430
  const checkAndCacheProperty = (property, value, syncData) => {
366
431
  if (value === undefined) return;
@@ -372,24 +437,33 @@ module.exports = function(RED) {
372
437
  if (node.stateCache.hasChanged(cacheKey, value)) {
373
438
  node.stateCache.update(cacheKey, value);
374
439
  changedData.push(syncData);
375
- node.debug(`[三合一] ${subType}.${property} 变化: ${value}`);
440
+ node.log(`[Symi->HA] 三合一 ${subType}.${property} 变化: ${value}`);
441
+ } else {
442
+ node.debug(`[Symi->HA] 三合一 ${subType}.${property} 无变化: ${value}`);
376
443
  }
377
444
  };
378
445
 
379
446
  // 1. 空调部分
380
447
  if (subType === 'aircon') {
448
+ // 将空调开关直接映射为 hvac_mode,以触发 HA 的 set_hvac_mode 服务
449
+ // 优先使用当前上报的模式,如果没有则使用缓存模式,最后兜底 cool
450
+ const currentMode = AC_MODE_TO_HA[state.climateMode] || AC_MODE_TO_HA[device.state.climateMode] || 'cool';
381
451
  checkAndCacheProperty('switch', state.climateSwitch,
382
- { type: 'switch', value: state.climateSwitch });
452
+ { type: 'hvac_mode', value: state.climateSwitch ? currentMode : 'off', meshValue: state.climateMode });
453
+
383
454
  checkAndCacheProperty('temperature', state.targetTemp,
384
455
  { type: 'temperature', value: state.targetTemp });
456
+
385
457
  checkAndCacheProperty('mode', state.climateMode,
386
458
  { type: 'hvac_mode', value: AC_MODE_TO_HA[state.climateMode] || 'off', meshValue: state.climateMode });
459
+
387
460
  checkAndCacheProperty('fanSpeed', state.fanMode,
388
461
  { type: 'fan_mode', value: FAN_MODE_TO_HA[state.fanMode] || 'auto', meshValue: state.fanMode });
389
462
  }
390
463
 
391
464
  // 2. 新风部分
392
465
  else if (subType === 'fresh_air') {
466
+ // 新风如果是 fan 实体,可以使用 switch 类型(HA fan 支持 turn_on/off)
393
467
  checkAndCacheProperty('switch', state.freshAirSwitch,
394
468
  { type: 'switch', value: state.freshAirSwitch });
395
469
  checkAndCacheProperty('speed', state.freshAirSpeed,
@@ -398,13 +472,16 @@ module.exports = function(RED) {
398
472
 
399
473
  // 3. 地暖部分
400
474
  else if (subType === 'floor_heating') {
475
+ // 地暖如果是 climate 实体,同样需要映射为 hvac_mode
401
476
  checkAndCacheProperty('switch', state.floorHeatingSwitch,
402
- { type: 'switch', value: state.floorHeatingSwitch });
477
+ { type: 'hvac_mode', value: state.floorHeatingSwitch ? 'heat' : 'off' });
478
+
403
479
  checkAndCacheProperty('temperature', state.floorHeatingTemp,
404
480
  { type: 'temperature', value: state.floorHeatingTemp });
405
481
  }
406
482
 
407
483
  // 只返回真正变化的属性
484
+ node.log(`[Symi->HA] 三合一处理结果: subType=${subType}, changedData=${JSON.stringify(changedData)}`);
408
485
  return changedData.length > 0 ? changedData : null;
409
486
  };
410
487
 
@@ -530,14 +607,17 @@ module.exports = function(RED) {
530
607
  const position = state.curtainPosition !== undefined ? state.curtainPosition : device.state.curtainPosition;
531
608
  if (position === undefined) return null;
532
609
 
533
- // 核心需求:忽略过程中的实时反馈指令
534
- // 如果当前有任何控制方向的锁定(正在移动中),则忽略位置反馈
535
- if (node.coverMoving[loopKey]) {
536
- node.debug(`[Symi->HA] 忽略移动中的实时位置反馈: ${position}%`);
537
- return null;
610
+ // 优化:允许在移动过程中上报位置,以便 HA 界面能看到动画
611
+ // 只有当锁定方向是 'ha' 且时间非常短(< 2s)时才认为是指令回显,予以忽略
612
+ if (node.coverMoving[loopKey] && node.coverMoving[loopKey].direction === 'ha') {
613
+ const elapsed = now - node.coverMoving[loopKey].startTime;
614
+ if (elapsed < 2000) {
615
+ node.debug(`[Symi->HA] 忽略指令回显初期的位置反馈: ${position}%`);
616
+ return null;
617
+ }
538
618
  }
539
619
 
540
- // 没有移动锁定时,允许同步(例如初始同步或查询返回)
620
+ // 允许同步位置
541
621
  return { type: 'position', value: position };
542
622
  }
543
623
 
@@ -968,6 +1048,8 @@ module.exports = function(RED) {
968
1048
  if (meshFan !== undefined) {
969
1049
  node.debug(`[HA->Symi] 空调风速: ${oldAttrs.fan_mode} -> ${attrs.fan_mode} (mesh: ${meshFan})`);
970
1050
  syncDataList.push({ type: 'fan_mode', value: meshFan });
1051
+ } else {
1052
+ node.warn(`[HA->Symi] 未知的空调风速值: "${attrs.fan_mode}",请检查映射配置`);
971
1053
  }
972
1054
  }
973
1055
  }
@@ -988,6 +1070,7 @@ module.exports = function(RED) {
988
1070
  if (attrs.percentage > 66) meshFan = 1; // high
989
1071
  else if (attrs.percentage > 33) meshFan = 2; // medium
990
1072
  else if (attrs.percentage > 0) meshFan = 3; // low
1073
+ node.debug(`[HA->Symi] 新风百分比: ${attrs.percentage}% -> mesh: ${meshFan}`);
991
1074
  syncDataList.push({ type: 'fan_mode', value: meshFan });
992
1075
  }
993
1076
  }
@@ -1081,7 +1164,13 @@ module.exports = function(RED) {
1081
1164
  try {
1082
1165
  switch (syncData.type) {
1083
1166
  case 'switch':
1084
- service = syncData.value ? 'turn_on' : 'turn_off';
1167
+ if (domain === 'climate') {
1168
+ service = 'set_hvac_mode';
1169
+ // 默认开启模式为 cool,关闭为 off
1170
+ serviceData.hvac_mode = syncData.value ? 'cool' : 'off';
1171
+ } else {
1172
+ service = syncData.value ? 'turn_on' : 'turn_off';
1173
+ }
1085
1174
  break;
1086
1175
 
1087
1176
  case 'brightness':
@@ -1125,15 +1214,23 @@ module.exports = function(RED) {
1125
1214
  break;
1126
1215
 
1127
1216
  case 'fan_mode':
1217
+ // 根据 HA 的要求转换风速值
1218
+ let haFanMode = syncData.value;
1128
1219
  if (domain === 'climate') {
1129
1220
  service = 'set_fan_mode';
1130
- serviceData.fan_mode = syncData.value;
1221
+ // 优先查找中文映射,解决 HA 报 500 错误的问题
1222
+ const meshValue = HA_TO_FAN_MODE[syncData.value];
1223
+ if (meshValue !== undefined && FAN_MODE_FOR_HA[meshValue]) {
1224
+ haFanMode = FAN_MODE_FOR_HA[meshValue];
1225
+ }
1226
+ serviceData.fan_mode = haFanMode;
1131
1227
  } else if (domain === 'fan') {
1132
- // 区分新风和普通风扇
1133
- // 如果是新风且HA实体是fan
1134
1228
  service = 'set_percentage';
1135
1229
  const percentMap = { high: 100, medium: 66, low: 33, auto: 50 };
1136
1230
  serviceData.percentage = percentMap[syncData.value] || 50;
1231
+ } else {
1232
+ service = 'set_fan_mode';
1233
+ serviceData.fan_mode = haFanMode;
1137
1234
  }
1138
1235
  break;
1139
1236
 
@@ -1141,8 +1238,17 @@ module.exports = function(RED) {
1141
1238
  return;
1142
1239
  }
1143
1240
 
1241
+ // 如果 service 为空,说明该属性不支持同步到 HA
1242
+ if (!service) {
1243
+ node.warn(`[Symi->HA] 属性 ${syncData.type} 在领域 ${domain} 下暂不支持同步`);
1244
+ return;
1245
+ }
1246
+
1247
+ // 确保使用正确的领域
1144
1248
  const serviceDomain = (syncData.type === 'position' || syncData.type === 'curtain_stop' || syncData.type === 'curtain_action') ? 'cover' : domain;
1145
1249
 
1250
+ node.log(`[Symi->HA] 发送 HA 请求: ${serviceDomain}/${service}, 数据: ${JSON.stringify(serviceData)}`);
1251
+
1146
1252
  await axios.post(`${baseURL}/api/services/${serviceDomain}/${service}`, serviceData, {
1147
1253
  headers: {
1148
1254
  'Authorization': `Bearer ${token}`,
@@ -1250,6 +1356,9 @@ module.exports = function(RED) {
1250
1356
  const { mapping, syncData } = cmd;
1251
1357
  const subType = mapping.symiKey;
1252
1358
 
1359
+ // 调试日志:记录三合一控制请求
1360
+ node.log(`[HA->Symi] 三合一控制请求: subType=${subType}, syncData=${JSON.stringify(syncData)}, networkAddr=0x${networkAddr.toString(16)}`);
1361
+
1253
1362
  // 动态获取gateway
1254
1363
  const currentGateway = gateway || (node.mqttNode && node.mqttNode.gateway);
1255
1364
  if (!currentGateway) {
@@ -1274,14 +1383,26 @@ module.exports = function(RED) {
1274
1383
  case 'hvac_mode':
1275
1384
  attrType = ATTR_CLIMATE_MODE;
1276
1385
  // HA mode -> Mesh mode
1277
- const haToMeshMode = { cool: 1, heat: 2, fan_only: 3, dry: 4, off: 0 };
1278
- param = [haToMeshMode[syncData.value] || 1];
1386
+ const haToMeshMode = {
1387
+ cool: 1, Cool: 1, COOL: 1,
1388
+ heat: 2, Heat: 2, HEAT: 2,
1389
+ fan_only: 3, Fan_only: 3, fan: 3,
1390
+ dry: 4, Dry: 4, DRY: 4,
1391
+ off: 0, Off: 0, OFF: 0
1392
+ };
1393
+ param = [haToMeshMode[syncData.value] !== undefined ? haToMeshMode[syncData.value] : 1];
1279
1394
  break;
1280
1395
  case 'fan_mode':
1281
1396
  attrType = ATTR_FAN_MODE;
1282
- // HA fan -> Mesh fan
1283
- const haToMeshFan = { high: 1, medium: 2, low: 3, auto: 4 };
1397
+ // HA fan -> Mesh fan: high=1, medium=2, low=3, auto=4
1398
+ const haToMeshFan = {
1399
+ high: 1, High: 1, HIGH: 1, strong: 1,
1400
+ medium: 2, Medium: 2, MEDIUM: 2, middle: 2,
1401
+ low: 3, Low: 3, LOW: 3, weak: 3,
1402
+ auto: 4, Auto: 4, AUTO: 4
1403
+ };
1284
1404
  param = [haToMeshFan[syncData.value] || 4];
1405
+ node.log(`[HA->Symi] 空调风速: HA=${syncData.value} -> Mesh=${param[0]}`);
1285
1406
  break;
1286
1407
  }
1287
1408
  } else if (subType === 'fresh_air') {
@@ -1293,8 +1414,13 @@ module.exports = function(RED) {
1293
1414
  break;
1294
1415
  case 'fan_mode':
1295
1416
  attrType = ATTR_FRESH_AIR_SPEED;
1296
- const haToMeshFan = { high: 1, medium: 2, low: 3, auto: 4 };
1297
- param = [haToMeshFan[syncData.value] || 4];
1417
+ const haToMeshFanFresh = {
1418
+ high: 1, High: 1, HIGH: 1, strong: 1,
1419
+ medium: 2, Medium: 2, MEDIUM: 2, middle: 2,
1420
+ low: 3, Low: 3, LOW: 3, weak: 3,
1421
+ auto: 4, Auto: 4, AUTO: 4
1422
+ };
1423
+ param = [haToMeshFanFresh[syncData.value] || 4];
1298
1424
  break;
1299
1425
  }
1300
1426
  } else if (subType === 'floor_heating') {
@@ -1312,8 +1438,11 @@ module.exports = function(RED) {
1312
1438
  }
1313
1439
 
1314
1440
  if (attrType && param) {
1441
+ node.log(`[HA->Symi] 发送控制命令: attrType=0x${attrType.toString(16)}, param=[${param.map(p => '0x' + p.toString(16)).join(', ')}]`);
1315
1442
  await currentGateway.sendControl(networkAddr, attrType, param);
1316
1443
  node.log(`[HA->Symi] ${mapping.haEntityId} -> ${mapping.symiName}(${subType}): ${syncData.type}=${JSON.stringify(syncData.value)}`);
1444
+ } else {
1445
+ node.warn(`[HA->Symi] 三合一控制未匹配: subType=${subType}, syncData.type=${syncData.type}`);
1317
1446
  }
1318
1447
 
1319
1448
  } catch (err) {
@@ -1145,12 +1145,12 @@ module.exports = function(RED) {
1145
1145
  node.publishCommandFeedback(device, command, payload, topic);
1146
1146
 
1147
1147
  } catch(err) {
1148
- node.error(`[MQTT发送] 失败: ${err.message}`);
1148
+ node.log(`[MQTT发送] 失败: ${err.message}`);
1149
1149
  }
1150
1150
  }
1151
1151
  })();
1152
1152
  } else {
1153
- node.warn(`[MQTT解析] 无法解析命令 - topic: ${topic}, payload: ${payload}`);
1153
+ node.log(`[MQTT解析] 无法解析命令 - topic: ${topic}, payload: ${payload}`);
1154
1154
  }
1155
1155
  };
1156
1156
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.8.5",
3
+ "version": "1.8.7",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "nodes/symi-gateway.js",
6
6
  "scripts": {