node-red-contrib-symi-mesh 1.7.9 → 1.8.1
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 +31 -75
- package/nodes/symi-ha-sync.html +67 -18
- package/nodes/symi-ha-sync.js +317 -42
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1503,6 +1503,36 @@ node-red-contrib-symi-mesh/
|
|
|
1503
1503
|
|
|
1504
1504
|
## 更新日志
|
|
1505
1505
|
|
|
1506
|
+
### v1.8.1 (2026-01-05)
|
|
1507
|
+
- **三合一面板深度集成**:
|
|
1508
|
+
- **子设备选择**:HA同步节点新增三合一子设备选择功能,可分别选择“空调”、“新风”、“地暖”进行独立映射。
|
|
1509
|
+
- **完整属性同步**:支持空调(开关/模式/温度/风速)、新风(开关/风速)、地暖(开关/温度)的全功能双向同步。
|
|
1510
|
+
- **配置与文档优化**:
|
|
1511
|
+
- **纯配置同步**:明确HA同步节点为纯配置模式,无需额外连线即可实现双向同步。
|
|
1512
|
+
- **文档更新**:全面更新README,增加三合一配置示例和Config-Only说明。
|
|
1513
|
+
- **稳定性修复**:
|
|
1514
|
+
- 修复 Symi→HA 同步时的调试日志重复输出问题。
|
|
1515
|
+
- 增强 HA 事件监听的健壮性,支持多种输入格式。
|
|
1516
|
+
- **窗帘双向同步修复**:
|
|
1517
|
+
- **运动状态跟踪**:新增 `coverMoving` 状态跟踪机制,记录窗帘运动的发起方(Symi/HA)和开始时间
|
|
1518
|
+
- **步进反馈过滤**:当 HA 发起窗帘控制时,自动忽略 Mesh 窗帘运动过程中的步进位置反馈,避免干扰
|
|
1519
|
+
- **运动中状态过滤**:当 HA 窗帘处于 `opening`/`closing` 状态时,不同步位置变化到 Mesh,只同步最终位置
|
|
1520
|
+
- **专用防死循环时间**:窗帘使用 30 秒防死循环窗口(普通设备 2 秒),适应窗帘较长的运动时间
|
|
1521
|
+
- **防抖时间优化**:窗帘防抖时间从 500ms 增加到 1500ms,确保位置稳定后再同步
|
|
1522
|
+
- **自动清理机制**:运动状态超时(30秒)自动清理,防止内存泄漏
|
|
1523
|
+
|
|
1524
|
+
### v1.8.0 (2026-01-05)
|
|
1525
|
+
- **HA同步节点重大增强**:
|
|
1526
|
+
- **同步模式选择**:新增“双向同步”、“仅Symi→HA”、“仅HA→Symi”三种模式,配置更加灵活。
|
|
1527
|
+
- **持久化修复**:修复了 `syncMode` 和 `symiEntityType` 在节点保存时丢失的问题,确保配置 100% 永久保存。
|
|
1528
|
+
- **UI 体验优化**:重新设计了映射列表布局,增加了“同步模式”列,并优化了窄屏下的显示效果。
|
|
1529
|
+
- **容错能力增强**:修复了设备离线时无法正确显示按键选择器的问题,现在会自动回退到保存的配置。
|
|
1530
|
+
- **发布包质量保证**:
|
|
1531
|
+
- 重新核对并优化了 `package.json` 的 `files` 字段,确保所有必要的 `lib` 和 `nodes` 文件在发布包中完整无缺,解决部分客户反馈的安装不完整问题。
|
|
1532
|
+
- **性能与稳定性**:
|
|
1533
|
+
- 优化了双向同步的防死循环逻辑,减少了在高频触发场景下的 CPU 占用。
|
|
1534
|
+
- 修复了 MQTT 配置下拉框在节点编辑面板打开时偶尔出现的加载卡顿问题。
|
|
1535
|
+
|
|
1506
1536
|
### v1.7.9 (2026-01-05)
|
|
1507
1537
|
- **HA同步节点UI修复**:修复添加映射按钮不显示选择界面的问题
|
|
1508
1538
|
- 修复`renderMappings()`函数中的数组检查逻辑
|
|
@@ -1559,80 +1589,6 @@ node-red-contrib-symi-mesh/
|
|
|
1559
1589
|
- 完善的节点关闭清理逻辑
|
|
1560
1590
|
- 防死循环机制增强:双向时间戳检查
|
|
1561
1591
|
|
|
1562
|
-
### v1.7.7 (2026-01-05)
|
|
1563
|
-
- **RS485桥接节点逻辑优化**:
|
|
1564
|
-
- 修复自定义码模式下"反馈"按钮点击部署后依旧开启的问题
|
|
1565
|
-
- 完善 `oneditsave` 逻辑,确保反馈选项状态被持久化保存
|
|
1566
|
-
- **防循环优化**:优化 `loopKey` 生成逻辑,加入 `meshChannel` 字段,支持多通道设备独立防循环,解决多路开关干扰问题
|
|
1567
|
-
- **冷却时间调整**:将防死循环冷却时间从 500ms 延长至 800ms,提高复杂网络环境下的同步稳定性
|
|
1568
|
-
- **反馈逻辑细化**:严格执行"反馈"勾选逻辑,未勾选时禁止回环发送自定义码到总线
|
|
1569
|
-
- **调试增强**:增加自定义码匹配成功的详细日志(包含设备 MAC 和具体动作)
|
|
1570
|
-
|
|
1571
|
-
### v1.7.5 (2025-12-24)
|
|
1572
|
-
- **RS485双向同步节点**:新增`symi-rs485-sync`独立节点实现两种不同RS485协议之间的双向同步
|
|
1573
|
-
- 支持中弘VRF网关协议(功能码0x31-0x34控制,0x50查询)
|
|
1574
|
-
- 支持SYMI空调面板Modbus协议
|
|
1575
|
-
- 支持自定义码协议
|
|
1576
|
-
- 双向状态同步,2秒防抖防止死循环
|
|
1577
|
-
- 多组映射配置,持久化保存
|
|
1578
|
-
- **反馈选项**:新增"反馈"复选框,控制RS485收码后是否发送反馈码
|
|
1579
|
-
- **Mesh→RS485方向**:
|
|
1580
|
-
- 修复开关控制:只同步配置的通道,避免其他通道变化误触发
|
|
1581
|
-
- 修复空调风速:fanMode变化正确发送fanSendHigh/Mid/Low/Auto码
|
|
1582
|
-
- 修复空调中速(fanMode=2)事件触发
|
|
1583
|
-
- 修复空调开关:去重处理,避免switch和acSwitch重复发送
|
|
1584
|
-
- 添加自动风速(fanSendAuto/fanRecvAuto)配置支持
|
|
1585
|
-
- **RS485→Mesh方向**:
|
|
1586
|
-
- 修复收码匹配触发Mesh实体状态变化
|
|
1587
|
-
- 自定义开关recvOn/recvOff正确触发开关动作
|
|
1588
|
-
- 自定义空调acRecvOn/acRecvOff/fanRecvHigh等正确触发空调控制
|
|
1589
|
-
- **事件系统优化**:
|
|
1590
|
-
- DeviceManager事件添加完整字段(isUserControl、isSceneExecution等)
|
|
1591
|
-
- 用户控制事件允许绕过首次状态缓存
|
|
1592
|
-
- 修复单路温控器开关事件丢失问题
|
|
1593
|
-
- fanMode事件同时发送acFanSpeed别名字段
|
|
1594
|
-
- **防死循环优化**:
|
|
1595
|
-
- 使用映射特定时间戳,避免不同设备同步互相影响
|
|
1596
|
-
- Mesh→RS485和RS485→Mesh双向都记录同步时间
|
|
1597
|
-
- 500ms冷却时间防止状态回环
|
|
1598
|
-
- 空调命令去重(同类命令只发送一次)
|
|
1599
|
-
- **生产稳定性**:
|
|
1600
|
-
- 初始化延迟从10秒缩短为5秒
|
|
1601
|
-
- 详细日志改为debug级别,减少生产环境日志量
|
|
1602
|
-
- 保留关键同步日志便于问题排查
|
|
1603
|
-
- **UI优化**:
|
|
1604
|
-
- 自定义码折叠按钮移到同一行,减少占用空间
|
|
1605
|
-
- 修复MAC地址大小写匹配问题
|
|
1606
|
-
- 添加自动风速配置框(发自动/收自动)
|
|
1607
|
-
- **RS485-to-RS485空调桥接**:SYMI空调面板与中弘VRF系统双向同步
|
|
1608
|
-
- 支持SYMI 485空调面板协议(7E...7D帧格式,CRC8校验)
|
|
1609
|
-
- 支持中弘VRF空调协议(求和校验,功能码0x31-0x34控制,0x50查询)
|
|
1610
|
-
- 双向状态同步:SYMI面板操作→中弘VRF执行,中弘状态→SYMI面板显示
|
|
1611
|
-
|
|
1612
|
-
### v1.7.1 (2025-12-21)
|
|
1613
|
-
- **自定义协议全面修复**:完善自定义开关/窗帘/空调双向同步
|
|
1614
|
-
- **空调风速控制**:修复风速变化事件触发机制
|
|
1615
|
-
- 添加fanMode字段支持(mesh空调实际使用的字段)
|
|
1616
|
-
- 修复DeviceInfo事件触发,正确发送device-state-changed事件
|
|
1617
|
-
- 支持风速值1-4(1=高, 2=中, 3=低, 4=自动)
|
|
1618
|
-
- 自动识别温控器0x02消息中的风速控制
|
|
1619
|
-
- **空调开关控制**:修复开关状态变化事件触发
|
|
1620
|
-
- 温控器0x02开关消息正确触发device-state-changed事件
|
|
1621
|
-
- 自定义空调开关码(acSendOn/acSendOff)正确发送到RS485总线
|
|
1622
|
-
- **RS485收码匹配**:修复hexStr格式处理
|
|
1623
|
-
- hexStr完全去掉空格,确保标准格式(如030610330001BD27)
|
|
1624
|
-
- 用户录入支持带空格格式(如01 06 10 34 00 01 0D 04)
|
|
1625
|
-
- 自动匹配并触发mesh实体动作
|
|
1626
|
-
- **事件系统优化**:
|
|
1627
|
-
- DeviceInfo添加manager引用,正确触发事件
|
|
1628
|
-
- 修复事件名称不匹配问题(stateChange -> device-state-changed)
|
|
1629
|
-
- 确保所有自定义码双向同步正常
|
|
1630
|
-
- **队列处理**:
|
|
1631
|
-
- 命令队列顺序处理,防止并发冲突
|
|
1632
|
-
- 500ms防死循环机制
|
|
1633
|
-
- 队列限制100条,防止内存溢出
|
|
1634
|
-
- **调试日志**:添加详细调试日志,方便排查问题
|
|
1635
|
-
|
|
1636
1592
|
## 许可证
|
|
1637
1593
|
|
|
1638
1594
|
MIT License
|
|
@@ -1644,7 +1600,7 @@ Copyright (c) 2025 SYMI 亖米
|
|
|
1644
1600
|
## 关于
|
|
1645
1601
|
|
|
1646
1602
|
**作者**: SYMI 亖米
|
|
1647
|
-
**版本**: 1.
|
|
1603
|
+
**版本**: 1.8.1
|
|
1648
1604
|
**协议**: 蓝牙MESH网关(初级版)串口协议V1.0
|
|
1649
1605
|
**最后更新**: 2026-01-05
|
|
1650
1606
|
**仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
|
package/nodes/symi-ha-sync.html
CHANGED
|
@@ -117,15 +117,20 @@
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
// 判断是否需要按键选择
|
|
120
|
-
function needsKeySelection(device) {
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
function needsKeySelection(device, savedChannels, savedEntityType) {
|
|
121
|
+
var entityType = (device ? device.entityType : savedEntityType) || '';
|
|
122
|
+
|
|
123
|
+
// 三合一面板必须选择子设备
|
|
124
|
+
if (entityType === 'three_in_one') {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
123
128
|
// 如果是温控器、窗帘、灯具,不需要选择按键(通常是单路或特殊处理)
|
|
124
129
|
if (entityType === 'climate' || entityType === 'cover' || entityType === 'light') {
|
|
125
130
|
return false;
|
|
126
131
|
}
|
|
127
132
|
// 开关设备,如果路数 > 1,则需要选择按键
|
|
128
|
-
var channels = parseInt(device.channels) || 1;
|
|
133
|
+
var channels = parseInt(device ? device.channels : savedChannels) || 1;
|
|
129
134
|
return channels > 1;
|
|
130
135
|
}
|
|
131
136
|
|
|
@@ -133,6 +138,7 @@
|
|
|
133
138
|
function getDeviceTypeLabel(device) {
|
|
134
139
|
if (!device) return '';
|
|
135
140
|
var entityType = device.entityType || '';
|
|
141
|
+
if (entityType === 'three_in_one') return ' [三合一]';
|
|
136
142
|
if (entityType === 'climate') return ' [温控器]';
|
|
137
143
|
if (entityType === 'cover') return ' [窗帘]';
|
|
138
144
|
if (entityType === 'light') return ' [灯具]';
|
|
@@ -169,7 +175,7 @@
|
|
|
169
175
|
}
|
|
170
176
|
|
|
171
177
|
// 构建按键选项
|
|
172
|
-
function getKeyOptions(mac, selectedKey, savedChannels) {
|
|
178
|
+
function getKeyOptions(mac, selectedKey, savedChannels, savedEntityType) {
|
|
173
179
|
var macNorm = (mac || '').toLowerCase().replace(/:/g, '');
|
|
174
180
|
var device = null;
|
|
175
181
|
symiDevices.forEach(function(d) {
|
|
@@ -178,14 +184,47 @@
|
|
|
178
184
|
}
|
|
179
185
|
});
|
|
180
186
|
|
|
181
|
-
|
|
187
|
+
var entityType = (device ? device.entityType : savedEntityType) || '';
|
|
188
|
+
if (!needsKeySelection(device, savedChannels, savedEntityType)) return '';
|
|
182
189
|
|
|
183
|
-
var channels = device ? (device.channels || 1) : (savedChannels || 1);
|
|
184
190
|
var html = '<select class="symi-key">';
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
191
|
+
|
|
192
|
+
// 三合一设备特殊处理
|
|
193
|
+
if (entityType === 'three_in_one') {
|
|
194
|
+
var opts = [
|
|
195
|
+
{v: 'aircon', l: '空调'},
|
|
196
|
+
{v: 'fresh_air', l: '新风'},
|
|
197
|
+
{v: 'floor_heating', l: '地暖'}
|
|
198
|
+
];
|
|
199
|
+
opts.forEach(function(o) {
|
|
200
|
+
var sel = (o.v == selectedKey) ? ' selected' : '';
|
|
201
|
+
html += '<option value="' + o.v + '"' + sel + '>' + o.l + '</option>';
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
// 多路开关
|
|
205
|
+
var channels = parseInt(device ? (device.channels || 1) : (savedChannels || 1)) || 1;
|
|
206
|
+
for (var i = 1; i <= channels; i++) {
|
|
207
|
+
var sel = (i == selectedKey) ? ' selected' : '';
|
|
208
|
+
html += '<option value="' + i + '"' + sel + '>按键' + i + '</option>';
|
|
209
|
+
}
|
|
188
210
|
}
|
|
211
|
+
|
|
212
|
+
html += '</select>';
|
|
213
|
+
return html;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 构建同步模式选项
|
|
217
|
+
function getSyncModeOptions(selectedMode) {
|
|
218
|
+
var modes = [
|
|
219
|
+
{ val: 0, label: '双向同步' },
|
|
220
|
+
{ val: 1, label: 'Symi → HA' },
|
|
221
|
+
{ val: 2, label: 'HA → Symi' }
|
|
222
|
+
];
|
|
223
|
+
var html = '<select class="sync-mode">';
|
|
224
|
+
modes.forEach(function(m) {
|
|
225
|
+
var sel = (m.val == (selectedMode || 0)) ? ' selected' : '';
|
|
226
|
+
html += '<option value="' + m.val + '"' + sel + '>' + m.label + '</option>';
|
|
227
|
+
});
|
|
189
228
|
html += '</select>';
|
|
190
229
|
return html;
|
|
191
230
|
}
|
|
@@ -224,7 +263,8 @@
|
|
|
224
263
|
try {
|
|
225
264
|
var row = $('<div class="mapping-row" data-idx="' + idx + '"></div>');
|
|
226
265
|
var symiOpts = getSymiOptions(m.symiMac, m.symiName);
|
|
227
|
-
var keyOpts = getKeyOptions(m.symiMac, m.symiKey || 1, m.symiChannels);
|
|
266
|
+
var keyOpts = getKeyOptions(m.symiMac, m.symiKey || 1, m.symiChannels, m.symiEntityType);
|
|
267
|
+
var syncModeOpts = getSyncModeOptions(m.syncMode || 0);
|
|
228
268
|
var haOpts = getHaOptions(m.haEntityId, m.haEntityName);
|
|
229
269
|
|
|
230
270
|
row.html(
|
|
@@ -233,7 +273,7 @@
|
|
|
233
273
|
' <select class="symi-select">' + symiOpts + '</select>' +
|
|
234
274
|
' <span class="symi-key-wrap">' + keyOpts + '</span>' +
|
|
235
275
|
'</div>' +
|
|
236
|
-
'<div class="arrow-col"
|
|
276
|
+
'<div class="arrow-col">' + syncModeOpts + '</div>' +
|
|
237
277
|
'<div class="ha-col">' +
|
|
238
278
|
' <select class="ha-select">' + haOpts + '</select>' +
|
|
239
279
|
'</div>' +
|
|
@@ -242,7 +282,7 @@
|
|
|
242
282
|
);
|
|
243
283
|
container.append(row);
|
|
244
284
|
} catch (err) {
|
|
245
|
-
console.error(
|
|
285
|
+
console.error("[symi-ha-sync] Render row error:", err, m);
|
|
246
286
|
}
|
|
247
287
|
});
|
|
248
288
|
|
|
@@ -266,7 +306,7 @@
|
|
|
266
306
|
mappings[idx].symiEntityType = opt.data('entitytype') || '';
|
|
267
307
|
mappings[idx].symiKey = 1;
|
|
268
308
|
|
|
269
|
-
row.find('.symi-key-wrap').html(getKeyOptions(mac, 1, mappings[idx].symiChannels));
|
|
309
|
+
row.find('.symi-key-wrap').html(getKeyOptions(mac, 1, mappings[idx].symiChannels, mappings[idx].symiEntityType));
|
|
270
310
|
bindEvents();
|
|
271
311
|
});
|
|
272
312
|
|
|
@@ -275,6 +315,11 @@
|
|
|
275
315
|
mappings[idx].symiKey = parseInt($(this).val()) || 1;
|
|
276
316
|
});
|
|
277
317
|
|
|
318
|
+
container.find('.sync-mode').off('change').on('change', function() {
|
|
319
|
+
var idx = $(this).closest('.mapping-row').data('idx');
|
|
320
|
+
mappings[idx].syncMode = parseInt($(this).val()) || 0;
|
|
321
|
+
});
|
|
322
|
+
|
|
278
323
|
container.find('.ha-select').off('change').on('change', function() {
|
|
279
324
|
var idx = $(this).closest('.mapping-row').data('idx');
|
|
280
325
|
var opt = $(this).find('option:selected');
|
|
@@ -312,9 +357,12 @@
|
|
|
312
357
|
$('#btn-add-mapping').on('click', function() {
|
|
313
358
|
mappings.push({
|
|
314
359
|
symiMac: '', symiName: '', symiKey: 1, symiChannels: 1, symiDeviceType: '',
|
|
360
|
+
symiEntityType: '', syncMode: 0,
|
|
315
361
|
haEntityId: '', haEntityName: ''
|
|
316
362
|
});
|
|
317
363
|
renderMappings();
|
|
364
|
+
var list = $('#mapping-list');
|
|
365
|
+
list.scrollTop(list.prop('scrollHeight'));
|
|
318
366
|
});
|
|
319
367
|
|
|
320
368
|
// 配置变化时重新加载
|
|
@@ -375,8 +423,9 @@
|
|
|
375
423
|
.symi-col { flex: 1 1 45%; min-width: 0; display: flex; gap: 4px; }
|
|
376
424
|
.symi-col .symi-select { flex: 1; padding: 4px; border: 1px solid #81c784; border-radius: 3px; background: #e8f5e9; font-size: 12px; }
|
|
377
425
|
.symi-col .symi-key { width: 70px; padding: 4px; border: 1px solid #81c784; border-radius: 3px; background: #c8e6c9; font-size: 11px; font-weight: bold; }
|
|
378
|
-
.arrow-col { flex: 0 0
|
|
379
|
-
.
|
|
426
|
+
.arrow-col { flex: 0 0 100px; text-align: center; color: #999; }
|
|
427
|
+
.arrow-col .sync-mode { width: 100%; padding: 4px; border: 1px solid #ccc; border-radius: 3px; font-size: 11px; background: #fff; }
|
|
428
|
+
.ha-col { flex: 1 1 35%; }
|
|
380
429
|
.ha-col .ha-select { width: 100%; padding: 4px; border: 1px solid #41BDF5; border-radius: 3px; background: #e3f2fd; font-size: 12px; }
|
|
381
430
|
.del-col { flex: 0 0 auto; }
|
|
382
431
|
.btn-remove { color: #d32f2f !important; padding: 2px 6px !important; }
|
|
@@ -408,8 +457,8 @@
|
|
|
408
457
|
</h4>
|
|
409
458
|
<div style="display:flex; padding:4px 8px; font-size:11px; color:#666; border-bottom:1px solid #eee; margin-bottom:6px; gap:6px;">
|
|
410
459
|
<span style="flex:1 1 45%">Symi设备/按键</span>
|
|
411
|
-
<span style="flex:0 0
|
|
412
|
-
<span style="flex:1 1
|
|
460
|
+
<span style="flex:0 0 100px; text-align:center;">同步模式</span>
|
|
461
|
+
<span style="flex:1 1 35%">HA实体</span>
|
|
413
462
|
</div>
|
|
414
463
|
<div id="mapping-list"></div>
|
|
415
464
|
<button type="button" id="btn-add-mapping" class="red-ui-button" style="margin-top:8px; width:100%">
|
package/nodes/symi-ha-sync.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Symi HA Sync Node - Symi设备与Home Assistant实体双向同步
|
|
3
|
-
* 版本: 1.
|
|
3
|
+
* 版本: 1.8.1
|
|
4
4
|
*
|
|
5
5
|
* 支持的实体类型和属性:
|
|
6
6
|
* - light: on/off, brightness (0-255)
|
|
@@ -15,10 +15,14 @@ module.exports = function(RED) {
|
|
|
15
15
|
|
|
16
16
|
// 常量定义
|
|
17
17
|
const LOOP_PREVENTION_MS = 2000; // 防死循环时间窗口
|
|
18
|
-
const DEBOUNCE_MS = 500; //
|
|
18
|
+
const DEBOUNCE_MS = 500; // 防抖时间(调光用)
|
|
19
19
|
const MAX_QUEUE_SIZE = 100;
|
|
20
20
|
const CLEANUP_INTERVAL_MS = 60000;
|
|
21
21
|
const TIMESTAMP_EXPIRE_MS = 60000;
|
|
22
|
+
|
|
23
|
+
// 窗帘专用常量
|
|
24
|
+
const COVER_LOOP_PREVENTION_MS = 30000; // 30秒防死循环
|
|
25
|
+
const COVER_DEBOUNCE_MS = 1500; // 1.5秒防抖
|
|
22
26
|
|
|
23
27
|
// Mesh属性类型
|
|
24
28
|
const ATTR_SWITCH = 0x02;
|
|
@@ -28,6 +32,14 @@ module.exports = function(RED) {
|
|
|
28
32
|
const ATTR_TARGET_TEMP = 0x1B;
|
|
29
33
|
const ATTR_FAN_MODE = 0x1C;
|
|
30
34
|
const ATTR_CLIMATE_MODE = 0x1D;
|
|
35
|
+
|
|
36
|
+
// 三合一属性
|
|
37
|
+
const ATTR_FRESH_AIR_SWITCH = 0x68;
|
|
38
|
+
const ATTR_FRESH_AIR_MODE = 0x69;
|
|
39
|
+
const ATTR_FRESH_AIR_SPEED = 0x6A;
|
|
40
|
+
const ATTR_FLOOR_HEATING_SWITCH = 0x6B;
|
|
41
|
+
const ATTR_FLOOR_HEATING_TEMP = 0x6C;
|
|
42
|
+
const ATTR_THREE_IN_ONE = 0x94;
|
|
31
43
|
|
|
32
44
|
// 空调模式映射
|
|
33
45
|
const AC_MODE_TO_HA = { 1: 'cool', 2: 'heat', 3: 'fan_only', 4: 'dry' };
|
|
@@ -48,13 +60,23 @@ module.exports = function(RED) {
|
|
|
48
60
|
// 解析映射配置
|
|
49
61
|
try {
|
|
50
62
|
const rawMappings = JSON.parse(config.mappings || '[]');
|
|
51
|
-
node.mappings = rawMappings.map(m =>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
node.mappings = rawMappings.map(m => {
|
|
64
|
+
// symiKey可能是数字(按键索引)或字符串(三合一子设备ID)
|
|
65
|
+
let key = m.symiKey;
|
|
66
|
+
if (typeof key !== 'string' || !isNaN(parseInt(key))) {
|
|
67
|
+
key = parseInt(key) || 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
symiMac: m.symiMac,
|
|
72
|
+
symiKey: key,
|
|
73
|
+
haEntityId: m.haEntityId,
|
|
74
|
+
symiName: m.symiName || '',
|
|
75
|
+
haEntityName: m.haEntityName || '',
|
|
76
|
+
symiEntityType: m.symiEntityType || '',
|
|
77
|
+
syncMode: parseInt(m.syncMode) || 0
|
|
78
|
+
};
|
|
79
|
+
}).filter(m => m.symiMac && m.haEntityId);
|
|
58
80
|
|
|
59
81
|
if (node.mappings.length > 0) {
|
|
60
82
|
node.log(`[HA同步] 已加载 ${node.mappings.length} 个映射`);
|
|
@@ -69,6 +91,7 @@ module.exports = function(RED) {
|
|
|
69
91
|
node.lastSymiToHa = {};
|
|
70
92
|
node.lastHaToSymi = {};
|
|
71
93
|
node.pendingDebounce = {}; // 防抖定时器
|
|
94
|
+
node.coverMoving = {}; // 窗帘运动状态跟踪 { loopKey: { direction: 'symi'|'ha', startTime, targetPosition } }
|
|
72
95
|
|
|
73
96
|
node.status({ fill: 'yellow', shape: 'ring', text: '初始化中' });
|
|
74
97
|
|
|
@@ -104,6 +127,13 @@ module.exports = function(RED) {
|
|
|
104
127
|
delete node.lastHaToSymi[key];
|
|
105
128
|
}
|
|
106
129
|
}
|
|
130
|
+
|
|
131
|
+
// 清理过期的窗帘运动状态
|
|
132
|
+
for (const key in node.coverMoving) {
|
|
133
|
+
if (now - node.coverMoving[key].timestamp > COVER_LOOP_PREVENTION_MS) {
|
|
134
|
+
delete node.coverMoving[key];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
107
137
|
}, CLEANUP_INTERVAL_MS);
|
|
108
138
|
|
|
109
139
|
// 防死循环检查 - 双向时间戳检查
|
|
@@ -163,6 +193,12 @@ module.exports = function(RED) {
|
|
|
163
193
|
if (deviceMappings.length === 0) return;
|
|
164
194
|
|
|
165
195
|
deviceMappings.forEach(mapping => {
|
|
196
|
+
// 检查同步模式 (0:双向, 1:Symi->HA, 2:HA->Symi)
|
|
197
|
+
const syncMode = mapping.syncMode !== undefined ? mapping.syncMode : 0;
|
|
198
|
+
if (syncMode !== 0 && syncMode !== 1) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
166
202
|
const domain = node.getEntityDomain(mapping.haEntityId);
|
|
167
203
|
const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
168
204
|
|
|
@@ -204,24 +240,116 @@ module.exports = function(RED) {
|
|
|
204
240
|
syncData = node.handleClimateModeChange(device, mapping, state);
|
|
205
241
|
}
|
|
206
242
|
break;
|
|
243
|
+
|
|
244
|
+
case ATTR_THREE_IN_ONE: // 0x94 三合一全量状态
|
|
245
|
+
case ATTR_FRESH_AIR_SWITCH: // 0x68 新风开关
|
|
246
|
+
case ATTR_FRESH_AIR_MODE: // 0x69 新风模式
|
|
247
|
+
case ATTR_FRESH_AIR_SPEED: // 0x6A 新风风速
|
|
248
|
+
case ATTR_FLOOR_HEATING_SWITCH: // 0x6B 地暖开关
|
|
249
|
+
case ATTR_FLOOR_HEATING_TEMP: // 0x6C 地暖温度
|
|
250
|
+
syncData = node.handleThreeInOneChange(device, mapping, state, attrType);
|
|
251
|
+
break;
|
|
207
252
|
}
|
|
208
253
|
|
|
209
254
|
if (syncData) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
255
|
+
// 支持返回数组(用于三合一同时同步多个属性)
|
|
256
|
+
const syncDataList = Array.isArray(syncData) ? syncData : [syncData];
|
|
214
257
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
258
|
+
syncDataList.forEach(data => {
|
|
259
|
+
if (node.shouldPreventSync('symi-to-ha', loopKey)) {
|
|
260
|
+
node.debug(`[Symi->HA] 跳过(防死循环): ${loopKey} ${data.type}`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
node.queueCommand({
|
|
265
|
+
direction: 'symi-to-ha',
|
|
266
|
+
mapping: mapping,
|
|
267
|
+
syncData: data,
|
|
268
|
+
key: loopKey
|
|
269
|
+
});
|
|
220
270
|
});
|
|
221
271
|
}
|
|
222
272
|
});
|
|
223
273
|
};
|
|
224
274
|
|
|
275
|
+
// 处理三合一状态变化
|
|
276
|
+
node.handleThreeInOneChange = function(device, mapping, state, attrType) {
|
|
277
|
+
const subType = mapping.symiKey; // 'aircon', 'fresh_air', 'floor_heating'
|
|
278
|
+
|
|
279
|
+
// 1. 空调部分 (通常通过0x94或标准温控指令更新)
|
|
280
|
+
if (subType === 'aircon') {
|
|
281
|
+
// 如果是标准温控属性更新,已经在switch case中处理了
|
|
282
|
+
// 这里主要处理0x94带来的全量更新
|
|
283
|
+
if (attrType === ATTR_THREE_IN_ONE) {
|
|
284
|
+
// 此时state已经包含了所有更新
|
|
285
|
+
// 需要检查哪些属性变了,但这里只能返回一个syncData
|
|
286
|
+
// 我们可以返回一个特殊对象,或者分别检查
|
|
287
|
+
// 为简化,这里假设HA端会处理部分更新,或者我们按优先级返回
|
|
288
|
+
|
|
289
|
+
// 检查开关
|
|
290
|
+
if (state.climateSwitch !== undefined) {
|
|
291
|
+
// 注意:这里需要比对旧状态,但在handleSymiStateChange中难以获取旧状态
|
|
292
|
+
// 我们可以利用node.queueCommand的去重机制,发送所有可能的状态
|
|
293
|
+
// 但这样会产生大量流量。
|
|
294
|
+
// 实际上DeviceManager触发事件时,如果是0x94,是全量更新。
|
|
295
|
+
// 我们可以只处理核心属性。
|
|
296
|
+
// 更好的方式是:在device-manager中,0x94更新会触发一次事件。
|
|
297
|
+
// 这里我们返回一个复合对象,或者由上层逻辑拆分。
|
|
298
|
+
// 由于syncData只能是一个对象,我们优先同步开关,然后是模式/温度
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// 由于0x94更新时,device-manager已经更新了state
|
|
302
|
+
// 我们可以直接从state读取当前值
|
|
303
|
+
|
|
304
|
+
// 构造空调状态
|
|
305
|
+
// 这里我们可能需要多次调用queueCommand,但handle函数只能返回一个
|
|
306
|
+
// 解决方案:handleSymiStateChange支持返回数组
|
|
307
|
+
return [
|
|
308
|
+
{ type: 'switch', value: state.climateSwitch },
|
|
309
|
+
{ type: 'temperature', value: state.targetTemp },
|
|
310
|
+
{ type: 'hvac_mode', value: AC_MODE_TO_HA[state.climateMode] || 'off', meshValue: state.climateMode },
|
|
311
|
+
{ type: 'fan_mode', value: FAN_MODE_TO_HA[state.fanMode] || 'auto', meshValue: state.fanMode }
|
|
312
|
+
];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 2. 新风部分
|
|
316
|
+
if (subType === 'fresh_air') {
|
|
317
|
+
// 新风开关 (0x68或0x94)
|
|
318
|
+
if (attrType === ATTR_FRESH_AIR_SWITCH || attrType === ATTR_THREE_IN_ONE) {
|
|
319
|
+
if (state.freshAirSwitch !== undefined) {
|
|
320
|
+
return { type: 'switch', value: state.freshAirSwitch };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// 新风风速 (0x6A或0x94)
|
|
324
|
+
if (attrType === ATTR_FRESH_AIR_SPEED || attrType === ATTR_THREE_IN_ONE) {
|
|
325
|
+
if (state.freshAirSpeed !== undefined) {
|
|
326
|
+
return { type: 'fan_mode', value: FAN_MODE_TO_HA[state.freshAirSpeed] || 'auto', meshValue: state.freshAirSpeed };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// 新风模式 (0x69或0x94)
|
|
330
|
+
// 目前HA Fan实体通常只支持on/off和speed,mode可能不支持或映射到preset_mode
|
|
331
|
+
// 暂时忽略模式,或视需求添加
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 3. 地暖部分
|
|
335
|
+
if (subType === 'floor_heating') {
|
|
336
|
+
// 地暖开关 (0x6B或0x94)
|
|
337
|
+
if (attrType === ATTR_FLOOR_HEATING_SWITCH || attrType === ATTR_THREE_IN_ONE) {
|
|
338
|
+
if (state.floorHeatingSwitch !== undefined) {
|
|
339
|
+
return { type: 'switch', value: state.floorHeatingSwitch };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// 地暖温度 (0x6C或0x94)
|
|
343
|
+
if (attrType === ATTR_FLOOR_HEATING_TEMP || attrType === ATTR_THREE_IN_ONE) {
|
|
344
|
+
if (state.floorHeatingTemp !== undefined) {
|
|
345
|
+
return { type: 'temperature', value: state.floorHeatingTemp };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return null;
|
|
351
|
+
};
|
|
352
|
+
|
|
225
353
|
// 处理开关状态变化
|
|
226
354
|
node.handleSwitchChange = function(device, mapping, state) {
|
|
227
355
|
let isOn = false;
|
|
@@ -249,11 +377,24 @@ module.exports = function(RED) {
|
|
|
249
377
|
return { type: 'brightness', value: haBrightness, meshValue: brightness };
|
|
250
378
|
};
|
|
251
379
|
|
|
252
|
-
//
|
|
380
|
+
// 处理窗帘变化(带防抖,避免步进反馈干扰)
|
|
253
381
|
node.handleCurtainChange = function(device, mapping, state, attrType) {
|
|
254
382
|
const domain = node.getEntityDomain(mapping.haEntityId);
|
|
255
383
|
if (domain !== 'cover') return null;
|
|
256
384
|
|
|
385
|
+
const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
386
|
+
|
|
387
|
+
// 检查是否是HA发起的运动,如果是则忽略Mesh的位置反馈(步进码)
|
|
388
|
+
if (node.coverMoving[loopKey] && node.coverMoving[loopKey].direction === 'ha') {
|
|
389
|
+
const elapsed = Date.now() - node.coverMoving[loopKey].startTime;
|
|
390
|
+
if (elapsed < COVER_LOOP_PREVENTION_MS) {
|
|
391
|
+
node.debug(`[Symi->HA] 窗帘忽略(HA发起运动中): ${loopKey}`);
|
|
392
|
+
return null; // 忽略HA发起运动期间的Mesh反馈
|
|
393
|
+
} else {
|
|
394
|
+
delete node.coverMoving[loopKey]; // 超时清理
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
257
398
|
// 窗帘位置变化 - 使用防抖,只同步最终位置
|
|
258
399
|
if (attrType === ATTR_CURTAIN_POSITION) {
|
|
259
400
|
const position = state.curtainPosition !== undefined ? state.curtainPosition : device.state.curtainPosition;
|
|
@@ -265,27 +406,34 @@ module.exports = function(RED) {
|
|
|
265
406
|
clearTimeout(node.pendingDebounce[debounceKey]);
|
|
266
407
|
}
|
|
267
408
|
|
|
409
|
+
// 标记Symi发起的运动
|
|
410
|
+
node.coverMoving[loopKey] = { direction: 'symi', startTime: Date.now(), targetPosition: position };
|
|
411
|
+
|
|
268
412
|
// 延迟同步,等待位置稳定
|
|
269
413
|
node.pendingDebounce[debounceKey] = setTimeout(() => {
|
|
270
414
|
delete node.pendingDebounce[debounceKey];
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
415
|
+
// 运动结束,清理状态
|
|
416
|
+
delete node.coverMoving[loopKey];
|
|
417
|
+
|
|
418
|
+
// 直接入队,跳过常规防死循环检查(窗帘有专门的运动状态跟踪)
|
|
419
|
+
node.queueCommand({
|
|
420
|
+
direction: 'symi-to-ha',
|
|
421
|
+
mapping: mapping,
|
|
422
|
+
syncData: { type: 'position', value: position },
|
|
423
|
+
key: loopKey,
|
|
424
|
+
skipLoopCheck: true
|
|
425
|
+
});
|
|
426
|
+
}, COVER_DEBOUNCE_MS);
|
|
281
427
|
|
|
282
428
|
return null; // 不立即同步
|
|
283
429
|
}
|
|
284
430
|
|
|
285
|
-
// 窗帘运行状态 -
|
|
431
|
+
// 窗帘运行状态 - 同步停止动作
|
|
286
432
|
if (attrType === ATTR_CURTAIN_STATUS) {
|
|
287
433
|
const action = state.curtainAction || device.state.curtainAction;
|
|
288
434
|
if (action === 'stopped') {
|
|
435
|
+
// 停止时清理运动状态
|
|
436
|
+
delete node.coverMoving[loopKey];
|
|
289
437
|
return { type: 'curtain_stop' };
|
|
290
438
|
}
|
|
291
439
|
}
|
|
@@ -325,7 +473,16 @@ module.exports = function(RED) {
|
|
|
325
473
|
|
|
326
474
|
// 方式A: 通过Input输入 (server-state-changed节点)
|
|
327
475
|
node.on('input', function(msg) {
|
|
328
|
-
if (msg.
|
|
476
|
+
if (msg.payload && (msg.payload.entity_id || (msg.data && msg.data.entity_id))) {
|
|
477
|
+
const entityId = msg.payload.entity_id || msg.data.entity_id;
|
|
478
|
+
const newState = msg.payload.new_state || msg.data.new_state;
|
|
479
|
+
const oldState = msg.payload.old_state || msg.data.old_state;
|
|
480
|
+
|
|
481
|
+
if (entityId && newState) {
|
|
482
|
+
node.handleHaStateChange(entityId, newState, oldState);
|
|
483
|
+
}
|
|
484
|
+
} else if (msg.data && msg.data.entity_id && msg.data.new_state) {
|
|
485
|
+
// 兼容旧格式
|
|
329
486
|
node.handleHaStateChange(msg.data.entity_id, msg.data.new_state, msg.data.old_state);
|
|
330
487
|
}
|
|
331
488
|
});
|
|
@@ -333,12 +490,15 @@ module.exports = function(RED) {
|
|
|
333
490
|
// 方式B: 尝试订阅HA Server事件总线
|
|
334
491
|
if (node.haServer && node.haServer.eventBus) {
|
|
335
492
|
node.haEventHandler = (evt) => {
|
|
493
|
+
// node.debug(`[HA事件] type=${evt.event_type}, data=${JSON.stringify(evt.data)}`);
|
|
336
494
|
if (evt && evt.event_type === 'state_changed' && evt.data) {
|
|
337
495
|
node.handleHaStateChange(evt.data.entity_id, evt.data.new_state, evt.data.old_state);
|
|
338
496
|
}
|
|
339
497
|
};
|
|
340
498
|
node.haServer.eventBus.on('ha_events:all', node.haEventHandler);
|
|
341
499
|
node.log('[HA同步] 已订阅HA事件总线');
|
|
500
|
+
} else {
|
|
501
|
+
node.warn('[HA同步] 未能订阅HA事件总线,请确保HA节点配置正确且已连接');
|
|
342
502
|
}
|
|
343
503
|
|
|
344
504
|
node.handleHaStateChange = function(entityId, newState, oldState) {
|
|
@@ -352,6 +512,12 @@ module.exports = function(RED) {
|
|
|
352
512
|
const oldAttrs = oldState ? (oldState.attributes || {}) : {};
|
|
353
513
|
|
|
354
514
|
mappings.forEach(mapping => {
|
|
515
|
+
// 检查同步模式 (0:双向, 1:Symi->HA, 2:HA->Symi)
|
|
516
|
+
const syncMode = mapping.syncMode !== undefined ? mapping.syncMode : 0;
|
|
517
|
+
if (syncMode !== 0 && syncMode !== 2) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
355
521
|
const loopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
356
522
|
|
|
357
523
|
// 根据实体类型提取变化
|
|
@@ -385,17 +551,43 @@ module.exports = function(RED) {
|
|
|
385
551
|
break;
|
|
386
552
|
|
|
387
553
|
case 'cover':
|
|
388
|
-
|
|
554
|
+
const coverLoopKey = `${mapping.symiMac}_${mapping.symiKey}_${mapping.haEntityId}`;
|
|
555
|
+
|
|
556
|
+
// 检查是否是Symi发起的运动,如果是则忽略HA的位置反馈
|
|
557
|
+
if (node.coverMoving[coverLoopKey] && node.coverMoving[coverLoopKey].direction === 'symi') {
|
|
558
|
+
const elapsed = Date.now() - node.coverMoving[coverLoopKey].startTime;
|
|
559
|
+
if (elapsed < COVER_LOOP_PREVENTION_MS) {
|
|
560
|
+
node.debug(`[HA->Symi] 窗帘忽略(Symi发起运动中): ${coverLoopKey}`);
|
|
561
|
+
break; // 忽略Symi发起运动期间的HA反馈
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// 运动中状态(opening/closing)不同步位置,避免步进反馈干扰
|
|
566
|
+
if (newState.state === 'opening' || newState.state === 'closing') {
|
|
567
|
+
node.debug(`[HA->Symi] 窗帘运动中,跳过位置同步: ${newState.state}`);
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 窗帘位置变化 - 只在停止状态时同步
|
|
389
572
|
if (attrs.current_position !== undefined) {
|
|
390
573
|
if (!oldState || oldAttrs.current_position !== attrs.current_position) {
|
|
574
|
+
// 标记HA发起的运动
|
|
575
|
+
node.coverMoving[coverLoopKey] = {
|
|
576
|
+
direction: 'ha',
|
|
577
|
+
startTime: Date.now(),
|
|
578
|
+
targetPosition: attrs.current_position
|
|
579
|
+
};
|
|
391
580
|
syncDataList.push({ type: 'position', value: attrs.current_position });
|
|
392
581
|
}
|
|
393
582
|
}
|
|
394
|
-
|
|
583
|
+
|
|
584
|
+
// 窗帘动作 - open/closed 状态变化
|
|
395
585
|
if (newState.state !== oldState?.state) {
|
|
396
586
|
if (newState.state === 'open') {
|
|
587
|
+
node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
|
|
397
588
|
syncDataList.push({ type: 'curtain_action', value: 'open' });
|
|
398
589
|
} else if (newState.state === 'closed') {
|
|
590
|
+
node.coverMoving[coverLoopKey] = { direction: 'ha', startTime: Date.now() };
|
|
399
591
|
syncDataList.push({ type: 'curtain_action', value: 'close' });
|
|
400
592
|
}
|
|
401
593
|
}
|
|
@@ -405,7 +597,11 @@ module.exports = function(RED) {
|
|
|
405
597
|
// 开关状态
|
|
406
598
|
if (!oldState || newState.state !== oldState.state) {
|
|
407
599
|
const isOn = newState.state !== 'off' && newState.state !== 'unavailable';
|
|
408
|
-
|
|
600
|
+
// 仅当状态从off变on,或on变off时才同步开关
|
|
601
|
+
if ((oldState && oldState.state === 'off' && newState.state !== 'off') ||
|
|
602
|
+
(oldState && oldState.state !== 'off' && newState.state === 'off')) {
|
|
603
|
+
syncDataList.push({ type: 'switch', value: isOn });
|
|
604
|
+
}
|
|
409
605
|
}
|
|
410
606
|
// 目标温度
|
|
411
607
|
if (attrs.temperature !== undefined) {
|
|
@@ -414,9 +610,10 @@ module.exports = function(RED) {
|
|
|
414
610
|
}
|
|
415
611
|
}
|
|
416
612
|
// HVAC模式
|
|
417
|
-
|
|
613
|
+
// 过滤掉off模式的变化,因为off已经由开关状态处理
|
|
614
|
+
if (newState.state !== 'off' && (attrs.hvac_mode !== undefined || newState.state !== oldState?.state)) {
|
|
418
615
|
const hvacMode = attrs.hvac_mode || newState.state;
|
|
419
|
-
if (!oldState || (oldAttrs.hvac_mode || oldState.state) !== hvacMode) {
|
|
616
|
+
if (hvacMode !== 'off' && (!oldState || (oldAttrs.hvac_mode || oldState.state) !== hvacMode)) {
|
|
420
617
|
const meshMode = HA_TO_AC_MODE[hvacMode];
|
|
421
618
|
if (meshMode !== undefined) {
|
|
422
619
|
syncDataList.push({ type: 'hvac_mode', value: meshMode });
|
|
@@ -457,7 +654,9 @@ module.exports = function(RED) {
|
|
|
457
654
|
|
|
458
655
|
// 队列同步命令
|
|
459
656
|
syncDataList.forEach(syncData => {
|
|
460
|
-
|
|
657
|
+
// 窗帘使用专门的运动状态跟踪,不使用常规防死循环
|
|
658
|
+
const isCover = domain === 'cover';
|
|
659
|
+
if (!isCover && node.shouldPreventSync('ha-to-symi', loopKey)) {
|
|
461
660
|
node.debug(`[HA->Symi] 跳过(防死循环): ${loopKey} ${syncData.type}`);
|
|
462
661
|
return;
|
|
463
662
|
}
|
|
@@ -466,7 +665,8 @@ module.exports = function(RED) {
|
|
|
466
665
|
direction: 'ha-to-symi',
|
|
467
666
|
mapping: mapping,
|
|
468
667
|
syncData: syncData,
|
|
469
|
-
key: loopKey
|
|
668
|
+
key: loopKey,
|
|
669
|
+
skipLoopCheck: isCover // 窗帘跳过常规防死循环检查
|
|
470
670
|
});
|
|
471
671
|
});
|
|
472
672
|
});
|
|
@@ -571,8 +771,9 @@ module.exports = function(RED) {
|
|
|
571
771
|
service = 'set_fan_mode';
|
|
572
772
|
serviceData.fan_mode = syncData.value;
|
|
573
773
|
} else if (domain === 'fan') {
|
|
774
|
+
// 区分新风和普通风扇
|
|
775
|
+
// 如果是新风且HA实体是fan
|
|
574
776
|
service = 'set_percentage';
|
|
575
|
-
// 风速档位转百分比
|
|
576
777
|
const percentMap = { high: 100, medium: 66, low: 33, auto: 50 };
|
|
577
778
|
serviceData.percentage = percentMap[syncData.value] || 50;
|
|
578
779
|
}
|
|
@@ -594,7 +795,8 @@ module.exports = function(RED) {
|
|
|
594
795
|
|
|
595
796
|
node.log(`[Symi->HA] ${mapping.symiName || mapping.symiMac} -> ${mapping.haEntityId}: ${syncData.type}=${JSON.stringify(syncData.value)}`);
|
|
596
797
|
|
|
597
|
-
//
|
|
798
|
+
// 仅输出到debug,不再重复send
|
|
799
|
+
/*
|
|
598
800
|
node.send({
|
|
599
801
|
topic: 'ha-sync/symi-to-ha',
|
|
600
802
|
payload: {
|
|
@@ -607,6 +809,7 @@ module.exports = function(RED) {
|
|
|
607
809
|
timestamp: Date.now()
|
|
608
810
|
}
|
|
609
811
|
});
|
|
812
|
+
*/
|
|
610
813
|
|
|
611
814
|
} catch (err) {
|
|
612
815
|
node.error(`[Symi->HA] 调用失败: ${err.message}`);
|
|
@@ -678,7 +881,8 @@ module.exports = function(RED) {
|
|
|
678
881
|
|
|
679
882
|
node.log(`[HA->Symi] ${mapping.haEntityId} -> ${device.name || mapping.symiMac}: ${syncData.type}=${JSON.stringify(syncData.value)}`);
|
|
680
883
|
|
|
681
|
-
//
|
|
884
|
+
// 仅输出到debug,不再重复send
|
|
885
|
+
/*
|
|
682
886
|
node.send({
|
|
683
887
|
topic: 'ha-sync/ha-to-symi',
|
|
684
888
|
payload: {
|
|
@@ -692,12 +896,82 @@ module.exports = function(RED) {
|
|
|
692
896
|
timestamp: Date.now()
|
|
693
897
|
}
|
|
694
898
|
});
|
|
899
|
+
*/
|
|
695
900
|
|
|
696
901
|
} catch (err) {
|
|
697
902
|
node.error(`[HA->Symi] 控制失败: ${err.message}`);
|
|
698
903
|
}
|
|
699
904
|
};
|
|
700
905
|
|
|
906
|
+
// ========== 执行 三合一控制 ==========
|
|
907
|
+
node.syncThreeInOne = async function(cmd, device, networkAddr) {
|
|
908
|
+
const { mapping, syncData } = cmd;
|
|
909
|
+
const subType = mapping.symiKey;
|
|
910
|
+
|
|
911
|
+
try {
|
|
912
|
+
let attrType, param;
|
|
913
|
+
|
|
914
|
+
if (subType === 'aircon') {
|
|
915
|
+
// 空调控制
|
|
916
|
+
switch (syncData.type) {
|
|
917
|
+
case 'switch':
|
|
918
|
+
attrType = ATTR_SWITCH;
|
|
919
|
+
param = [syncData.value ? 0x02 : 0x01];
|
|
920
|
+
break;
|
|
921
|
+
case 'temperature':
|
|
922
|
+
attrType = ATTR_TARGET_TEMP;
|
|
923
|
+
param = [syncData.value];
|
|
924
|
+
break;
|
|
925
|
+
case 'hvac_mode':
|
|
926
|
+
attrType = ATTR_CLIMATE_MODE;
|
|
927
|
+
// HA mode -> Mesh mode
|
|
928
|
+
const haToMeshMode = { cool: 1, heat: 2, fan_only: 3, dry: 4, off: 0 };
|
|
929
|
+
param = [haToMeshMode[syncData.value] || 1];
|
|
930
|
+
break;
|
|
931
|
+
case 'fan_mode':
|
|
932
|
+
attrType = ATTR_FAN_MODE;
|
|
933
|
+
// HA fan -> Mesh fan
|
|
934
|
+
const haToMeshFan = { high: 1, medium: 2, low: 3, auto: 4 };
|
|
935
|
+
param = [haToMeshFan[syncData.value] || 4];
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
} else if (subType === 'fresh_air') {
|
|
939
|
+
// 新风控制
|
|
940
|
+
switch (syncData.type) {
|
|
941
|
+
case 'switch':
|
|
942
|
+
attrType = ATTR_FRESH_AIR_SWITCH;
|
|
943
|
+
param = [syncData.value ? 0x02 : 0x01];
|
|
944
|
+
break;
|
|
945
|
+
case 'fan_mode':
|
|
946
|
+
attrType = ATTR_FRESH_AIR_SPEED;
|
|
947
|
+
const haToMeshFan = { high: 1, medium: 2, low: 3, auto: 4 };
|
|
948
|
+
param = [haToMeshFan[syncData.value] || 4];
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
} else if (subType === 'floor_heating') {
|
|
952
|
+
// 地暖控制
|
|
953
|
+
switch (syncData.type) {
|
|
954
|
+
case 'switch':
|
|
955
|
+
attrType = ATTR_FLOOR_HEATING_SWITCH;
|
|
956
|
+
param = [syncData.value ? 0x02 : 0x01];
|
|
957
|
+
break;
|
|
958
|
+
case 'temperature':
|
|
959
|
+
attrType = ATTR_FLOOR_HEATING_TEMP;
|
|
960
|
+
param = [syncData.value];
|
|
961
|
+
break;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (attrType && param) {
|
|
966
|
+
await gateway.sendControl(networkAddr, attrType, param);
|
|
967
|
+
node.log(`[HA->Symi] ${mapping.haEntityId} -> ${mapping.symiName}(${subType}): ${syncData.type}=${JSON.stringify(syncData.value)}`);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
} catch (err) {
|
|
971
|
+
node.error(`[HA->Symi] 三合一控制失败: ${err.message}`);
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
|
|
701
975
|
// ========== 节点关闭 ==========
|
|
702
976
|
node.on('close', function(done) {
|
|
703
977
|
if (node.cleanupInterval) {
|
|
@@ -708,6 +982,7 @@ module.exports = function(RED) {
|
|
|
708
982
|
clearTimeout(node.pendingDebounce[key]);
|
|
709
983
|
}
|
|
710
984
|
node.pendingDebounce = {};
|
|
985
|
+
node.coverMoving = {}; // 清理窗帘运动状态
|
|
711
986
|
|
|
712
987
|
if (gateway) {
|
|
713
988
|
gateway.removeListener('device-state-changed', node.handleSymiStateChange);
|
|
@@ -725,7 +1000,7 @@ module.exports = function(RED) {
|
|
|
725
1000
|
// ========== HTTP API ==========
|
|
726
1001
|
|
|
727
1002
|
// 加载Symi设备
|
|
728
|
-
RED.httpAdmin.get('/symi-ha-sync/symi-devices/:id', function(req, res) {
|
|
1003
|
+
RED.httpAdmin.get('/symi-ha-sync/symi-devices/:id', RED.auth.needsPermission('symi-ha-sync.read'), function(req, res) {
|
|
729
1004
|
const mqttNode = RED.nodes.getNode(req.params.id);
|
|
730
1005
|
if (!mqttNode || !mqttNode.gateway) {
|
|
731
1006
|
return res.json([]);
|