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 +35 -2
- package/lib/device-manager.js +191 -9
- package/lib/tcp-client.js +1 -1
- package/nodes/rs485-debug.js +3 -2
- package/nodes/symi-485-config.js +13 -2
- package/nodes/symi-cloud-sync.js +1 -1
- package/nodes/symi-device.js +4 -4
- package/nodes/symi-gateway.js +99 -29
- package/nodes/symi-ha-sync.js +199 -70
- package/nodes/symi-mqtt.js +2 -2
- package/package.json +1 -1
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.
|
|
839
|
+
**版本**: 1.8.6
|
|
807
840
|
**协议**: 蓝牙MESH网关(初级版)串口协议V1.0
|
|
808
|
-
**最后更新**: 2026-01-
|
|
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
|
package/lib/device-manager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
+
|
|
782
|
+
// 优先从文件存储恢复三合一标记(持久化)
|
|
783
|
+
if (this.isConfirmedThreeInOne(mac)) {
|
|
635
784
|
device.isThreeInOne = true;
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
if (
|
|
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.
|
|
114
|
+
this.logger.log('TCP client error: ' + error.message);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
// 确保错误不会导致uncaught exception
|
package/nodes/rs485-debug.js
CHANGED
|
@@ -145,12 +145,13 @@ module.exports = function(RED) {
|
|
|
145
145
|
});
|
|
146
146
|
|
|
147
147
|
node.rs485Config.on('disconnected', function() {
|
|
148
|
-
node.
|
|
148
|
+
node.log('RS485连接已断开');
|
|
149
149
|
updateStatus();
|
|
150
150
|
});
|
|
151
151
|
|
|
152
152
|
node.rs485Config.on('error', function(err) {
|
|
153
|
-
|
|
153
|
+
// 降级为 log,避免控制台报错
|
|
154
|
+
node.log('RS485错误: ' + err.message);
|
|
154
155
|
node.status({ fill: 'red', shape: 'ring', text: '错误: ' + err.message });
|
|
155
156
|
});
|
|
156
157
|
|
package/nodes/symi-485-config.js
CHANGED
|
@@ -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.
|
|
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.
|
|
184
|
+
node.logErrorThrottled(`RS485 TCP错误: ${err.message}`);
|
|
174
185
|
}
|
|
175
186
|
node.emit('error', err);
|
|
176
187
|
});
|
package/nodes/symi-cloud-sync.js
CHANGED
package/nodes/symi-device.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
125
|
+
node.log(`控制失败: ${error.message}`);
|
|
126
126
|
node.status({ fill: 'red', shape: 'dot', text: '控制失败' });
|
|
127
127
|
}
|
|
128
128
|
});
|
package/nodes/symi-gateway.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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 (
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
}
|
|
472
|
-
|
|
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}`);
|
package/nodes/symi-ha-sync.js
CHANGED
|
@@ -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 = {
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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.
|
|
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: '
|
|
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: '
|
|
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.
|
|
537
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
1278
|
-
|
|
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 = {
|
|
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
|
|
1297
|
-
|
|
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) {
|
package/nodes/symi-mqtt.js
CHANGED
|
@@ -1145,12 +1145,12 @@ module.exports = function(RED) {
|
|
|
1145
1145
|
node.publishCommandFeedback(device, command, payload, topic);
|
|
1146
1146
|
|
|
1147
1147
|
} catch(err) {
|
|
1148
|
-
node.
|
|
1148
|
+
node.log(`[MQTT发送] 失败: ${err.message}`);
|
|
1149
1149
|
}
|
|
1150
1150
|
}
|
|
1151
1151
|
})();
|
|
1152
1152
|
} else {
|
|
1153
|
-
node.
|
|
1153
|
+
node.log(`[MQTT解析] 无法解析命令 - topic: ${topic}, payload: ${payload}`);
|
|
1154
1154
|
}
|
|
1155
1155
|
};
|
|
1156
1156
|
|