node-red-contrib-symi-mesh 1.8.5 → 1.8.6
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 +25 -2
- package/lib/device-manager.js +191 -9
- package/nodes/symi-gateway.js +87 -28
- package/nodes/symi-ha-sync.js +197 -68
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -702,6 +702,29 @@ node-red-contrib-symi-mesh/
|
|
|
702
702
|
|
|
703
703
|
## 更新日志
|
|
704
704
|
|
|
705
|
+
### v1.8.6 (2026-01-07)
|
|
706
|
+
|
|
707
|
+
**核心修复与同步增强**:
|
|
708
|
+
- **网关初始化修复**:修复 `symi-gateway` 节点在初始化查询设备状态时的语法错误 (SyntaxError),确保节点能正常加载。
|
|
709
|
+
- **三合一属性路由优化**:优化 `symi-ha-sync` 路由逻辑,支持将 0x02 属性(开关)正确重定向至三合一子实体处理器,解决三合一面板状态同步失效问题。
|
|
710
|
+
- **HA Climate 协议匹配**:修复 HA `climate` 实体协议不匹配问题,将空调/地暖开关操作映射为 `hvac_mode` (heat/cool/off),解决同步时的 500 错误。
|
|
711
|
+
- **风速同步双向修复**:
|
|
712
|
+
- **中英文映射支持**:在 `HA_TO_FAN_MODE` 中增加中文风速(高风/中风/低风/自动)映射,解决 HA 界面操作时的“未知风速值”警告。
|
|
713
|
+
- **Symi -> HA 值转换**:同步至 HA 时自动将英文风速转换为中文,匹配中文版 HA 实体要求,彻底解决同步时的 500 错误。
|
|
714
|
+
- **详细日志追踪**:增加 `[Symi->HA] 发送 HA 请求` 详细日志,包含完整的服务调用路径和 Payload。
|
|
715
|
+
|
|
716
|
+
**三合一设备持久化优化**:
|
|
717
|
+
- **文件持久化存储**:三合一设备类型信息保存到 `~/.node-red/symi-mesh-data/` 目录,实现永久记忆。
|
|
718
|
+
- **检测逻辑优化**:延长检测等待时间至 10 秒(分 20 次检查),解决因网络延迟导致的识别失败。
|
|
719
|
+
- **时序修复**:提前保存 `needsThreeInOneCheck` 状态,确保在响应到达时能正确触发持久化流程。
|
|
720
|
+
|
|
721
|
+
**Mesh -> HA 同步增强**:
|
|
722
|
+
- **状态缓存细粒度化**:采用 `StateCache` 实现 `{mac}_{subEntity}_{property}` 级别的精确对比,只同步真正变化的属性。
|
|
723
|
+
- **调试日志增强**:三合一相关事件统一使用 `log` 级别输出,便于快速排查同步链路问题。
|
|
724
|
+
|
|
725
|
+
**设备管理器功能增强**:
|
|
726
|
+
- 新增 `markAsThreeInOne` / `markAsThermostat` 等持久化管理方法,提升系统重启后的设备恢复速度。
|
|
727
|
+
|
|
705
728
|
### v1.8.5 (2026-01-06)
|
|
706
729
|
|
|
707
730
|
**通用同步工具类重构**:
|
|
@@ -803,8 +826,8 @@ Copyright (c) 2025 SYMI 亖米
|
|
|
803
826
|
---
|
|
804
827
|
|
|
805
828
|
**作者**: SYMI 亖米
|
|
806
|
-
**版本**: 1.8.
|
|
829
|
+
**版本**: 1.8.6
|
|
807
830
|
**协议**: 蓝牙MESH网关(初级版)串口协议V1.0
|
|
808
|
-
**最后更新**: 2026-01-
|
|
831
|
+
**最后更新**: 2026-01-07
|
|
809
832
|
**仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
|
|
810
833
|
**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/nodes/symi-gateway.js
CHANGED
|
@@ -138,6 +138,8 @@ module.exports = function(RED) {
|
|
|
138
138
|
clearTimeout(this.sceneExecutionTimer);
|
|
139
139
|
this.sceneExecutionTimer = null;
|
|
140
140
|
}
|
|
141
|
+
|
|
142
|
+
|
|
141
143
|
|
|
142
144
|
// 清空队列
|
|
143
145
|
this.stateEventQueue = [];
|
|
@@ -162,7 +164,22 @@ module.exports = function(RED) {
|
|
|
162
164
|
SymiGatewayNode.prototype.handleFrame = function(frame) {
|
|
163
165
|
// 使用debug记录详细frame信息,避免日志泛滥
|
|
164
166
|
const frameHex = Buffer.from([frame.header, frame.opcode, ...(frame.status !== null ? [frame.status] : []), frame.length, ...frame.payload, frame.checksum]).toString('hex').toUpperCase();
|
|
165
|
-
|
|
167
|
+
|
|
168
|
+
// 三合一相关的帧使用 log 级别
|
|
169
|
+
const isThreeInOneFrame = frame.opcode === 0x80 && frame.payload && frame.payload.length > 2 &&
|
|
170
|
+
[0x94, 0x68, 0x69, 0x6A, 0x6B, 0x6C].includes(frame.payload[2]);
|
|
171
|
+
|
|
172
|
+
// 调试:打印所有 0x80 帧,确认是否收到三合一状态上报
|
|
173
|
+
if (frame.opcode === 0x80) {
|
|
174
|
+
const msgType = frame.payload && frame.payload.length > 2 ? frame.payload[2] : 'N/A';
|
|
175
|
+
this.debug(`[TCP Frame] 0x80帧: ${frameHex}, msgType=0x${typeof msgType === 'number' ? msgType.toString(16).toUpperCase() : msgType}, isThreeInOne=${isThreeInOneFrame}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (isThreeInOneFrame) {
|
|
179
|
+
this.debug(`[TCP Frame] 三合一帧: ${frameHex} (opcode=0x${frame.opcode.toString(16).toUpperCase()})`);
|
|
180
|
+
} else {
|
|
181
|
+
this.debug(`[TCP Frame] 接收: ${frameHex} (opcode=0x${frame.opcode.toString(16).toUpperCase()})`)
|
|
182
|
+
}
|
|
166
183
|
|
|
167
184
|
if (frame.opcode === OP_RESP_DEVICE_LIST && frame.status === 0x00) {
|
|
168
185
|
this.parseDeviceListFrame(frame);
|
|
@@ -243,9 +260,11 @@ module.exports = function(RED) {
|
|
|
243
260
|
|
|
244
261
|
// 记录状态事件
|
|
245
262
|
const logPrefix = this.sceneExecutionInProgress ? '[场景执行]' : '[状态事件]';
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
263
|
+
// 三合一相关的 attrType 使用 log 级别,便于调试
|
|
264
|
+
const isThreeInOneAttr = [0x94, 0x68, 0x69, 0x6A, 0x6B, 0x6C].includes(event.attrType);
|
|
265
|
+
if (this.sceneExecutionInProgress || isThreeInOneAttr) {
|
|
266
|
+
// 场景执行期间或三合一事件使用log级别,便于查看
|
|
267
|
+
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
268
|
} else {
|
|
250
269
|
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
270
|
}
|
|
@@ -367,11 +386,27 @@ module.exports = function(RED) {
|
|
|
367
386
|
// 对于温控器类型(deviceType=10),需要通过查询新风/地暖来判断是否是三合一
|
|
368
387
|
// 空调温控器和三合一都属于同一品类,只有通过主动查询才能区分
|
|
369
388
|
if (deviceType === 10) {
|
|
370
|
-
//
|
|
371
|
-
|
|
372
|
-
|
|
389
|
+
// 优先检查文件持久化存储(最可靠)
|
|
390
|
+
const isFileConfirmedThreeInOne = this.deviceManager.isConfirmedThreeInOne(device.macAddress);
|
|
391
|
+
const isFileConfirmedThermostat = this.deviceManager.isConfirmedThermostat(device.macAddress);
|
|
392
|
+
|
|
393
|
+
if (isFileConfirmedThreeInOne) {
|
|
394
|
+
// 文件中已确认为三合一,直接恢复,不需要重新检测
|
|
395
|
+
device.isThreeInOne = true;
|
|
396
|
+
device.needsThreeInOneCheck = false;
|
|
397
|
+
this.log(`[三合一检测] ${device.name} 从文件恢复为三合一面板(永久记录)`);
|
|
398
|
+
} else if (isFileConfirmedThermostat) {
|
|
399
|
+
// 文件中已确认为普通温控器
|
|
400
|
+
device.thermostatConfirmed = true;
|
|
401
|
+
device.needsThreeInOneCheck = false;
|
|
402
|
+
this.log(`[三合一检测] ${device.name} 从文件恢复为普通温控器(永久记录)`);
|
|
403
|
+
} else if (device.isThreeInOne) {
|
|
404
|
+
// 内存中已标记为三合一(可能是旧的context存储)
|
|
405
|
+
this.log(`[三合一检测] ${device.name} 已确认为三合一面板(从内存恢复)`);
|
|
406
|
+
// 同时保存到文件
|
|
407
|
+
this.deviceManager.markAsThreeInOne(device.macAddress);
|
|
373
408
|
} else if (device.thermostatConfirmed) {
|
|
374
|
-
this.log(`[三合一检测] ${device.name}
|
|
409
|
+
this.log(`[三合一检测] ${device.name} 已确认为普通温控器(从内存恢复)`);
|
|
375
410
|
} else {
|
|
376
411
|
device.needsThreeInOneCheck = true;
|
|
377
412
|
this.log(`[三合一检测] 发现温控器类型设备: ${device.name},将通过查询新风/地暖确认类型`);
|
|
@@ -441,6 +476,13 @@ module.exports = function(RED) {
|
|
|
441
476
|
}
|
|
442
477
|
}
|
|
443
478
|
|
|
479
|
+
// 【重要】在发送查询之前保存检测标记状态
|
|
480
|
+
// 因为响应可能在sendFrame期间就到达,updateFromEvent会修改needsThreeInOneCheck
|
|
481
|
+
const wasCheckingThreeInOne = device.needsThreeInOneCheck;
|
|
482
|
+
if (device.deviceType === 10) {
|
|
483
|
+
this.log(`[三合一检测] ${device.name} wasCheckingThreeInOne=${wasCheckingThreeInOne}, queryAttrs=[${queryAttrs.map(a => '0x' + a.toString(16)).join(',')}]`);
|
|
484
|
+
}
|
|
485
|
+
|
|
444
486
|
for (const attr of queryAttrs) {
|
|
445
487
|
const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
|
|
446
488
|
await this.client.sendFrame(frame, 2);
|
|
@@ -448,30 +490,42 @@ module.exports = function(RED) {
|
|
|
448
490
|
}
|
|
449
491
|
|
|
450
492
|
// 等待设备响应,检查是否被标记为三合一
|
|
451
|
-
if (
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
this.log(`[三合一检测] ${device.name} 确认为三合一面板`);
|
|
460
|
-
device.needsThreeInOneCheck = false;
|
|
461
|
-
this.deviceManager.saveDevices();
|
|
493
|
+
if (wasCheckingThreeInOne) {
|
|
494
|
+
this.log(`[三合一检测] ${device.name} 进入等待循环,最多等待10秒...`);
|
|
495
|
+
// 等待更长时间确保响应到达(网络延迟可能很大,最多等待10秒)
|
|
496
|
+
// 分多次检查,一旦确认就立即继续
|
|
497
|
+
let confirmed = false;
|
|
498
|
+
for (let i = 0; i < 20; i++) { // 20次 * 500ms = 10秒
|
|
499
|
+
await this.sleep(500);
|
|
500
|
+
await this.processStateEventQueue();
|
|
462
501
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
502
|
+
if (device.isThreeInOne) {
|
|
503
|
+
confirmed = true;
|
|
504
|
+
this.log(`[三合一检测] ${device.name} 确认为三合一面板(等待${(i+1)*0.5}秒后确认)`);
|
|
505
|
+
device.needsThreeInOneCheck = false;
|
|
506
|
+
// 使用文件持久化存储三合一标记
|
|
507
|
+
this.deviceManager.markAsThreeInOne(device.macAddress);
|
|
508
|
+
this.deviceManager.saveDevices();
|
|
509
|
+
|
|
510
|
+
// 查询三合一的完整状态
|
|
511
|
+
this.log(`[三合一检测] 查询 ${device.name} 完整状态...`);
|
|
512
|
+
const threeInOneAttrs = [0x02, 0x16, 0x1B, 0x1C, 0x1D, 0x6A, 0x6C];
|
|
513
|
+
for (const attr of threeInOneAttrs) {
|
|
514
|
+
const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
|
|
515
|
+
await this.client.sendFrame(frame, 2);
|
|
516
|
+
await this.sleep(150);
|
|
517
|
+
}
|
|
518
|
+
break;
|
|
470
519
|
}
|
|
471
|
-
}
|
|
472
|
-
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// 10秒后仍未确认为三合一,标记为普通温控器
|
|
523
|
+
if (!confirmed && !device.isThreeInOne) {
|
|
524
|
+
this.log(`[三合一检测] ${device.name} 确认为普通温控器(10秒内无三合一响应)`);
|
|
473
525
|
device.needsThreeInOneCheck = false;
|
|
474
526
|
device.thermostatConfirmed = true;
|
|
527
|
+
// 使用文件持久化存储温控器标记
|
|
528
|
+
this.deviceManager.markAsThermostat(device.macAddress);
|
|
475
529
|
this.deviceManager.saveDevices();
|
|
476
530
|
|
|
477
531
|
// 触发温控器确认事件
|
|
@@ -485,6 +539,11 @@ module.exports = function(RED) {
|
|
|
485
539
|
await this.sleep(150);
|
|
486
540
|
}
|
|
487
541
|
}
|
|
542
|
+
} else if (device.isThreeInOne && !this.deviceManager.isConfirmedThreeInOne(device.macAddress)) {
|
|
543
|
+
// 设备已经被标记为三合一(可能在之前的响应中),但还没有持久化
|
|
544
|
+
this.log(`[三合一检测] ${device.name} 已被标记为三合一,执行持久化`);
|
|
545
|
+
this.deviceManager.markAsThreeInOne(device.macAddress);
|
|
546
|
+
this.deviceManager.saveDevices();
|
|
488
547
|
}
|
|
489
548
|
} catch(e) {
|
|
490
549
|
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);
|
|
@@ -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) {
|