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

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.
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Sync Utils - 通用同步工具类
3
+ * 版本: 1.8.5
4
+ *
5
+ * 提供统一的防环路、状态缓存和同步队列管理功能
6
+ * 供 symi-ha-sync、symi-mqtt-sync、symi-rs485-sync 等节点共用
7
+ */
8
+
9
+ // 默认超时配置
10
+ const DEFAULT_TIMEOUT = 2000; // 普通设备防环路超时
11
+ const COVER_TIMEOUT = 40000; // 窗帘防环路超时(增加至40s,针对无限位模块)
12
+ const BRIGHTNESS_TIMEOUT = 800; // 亮度防抖超时
13
+ const CLEANUP_INTERVAL = 60000; // 清理间隔
14
+ const TIMESTAMP_EXPIRE = 60000; // 时间戳过期时间
15
+
16
+ // 三合一子实体映射配置
17
+ const THREE_IN_ONE_SUB_ENTITIES = {
18
+ aircon: {
19
+ name: '空调',
20
+ hyqwType: 12,
21
+ haEntityType: 'climate',
22
+ properties: ['switch', 'temperature', 'mode', 'fanSpeed'],
23
+ meshAttrs: {
24
+ switch: 0x02,
25
+ temperature: 0x1B,
26
+ mode: 0x1D,
27
+ fanSpeed: 0x1C
28
+ }
29
+ },
30
+ fresh_air: {
31
+ name: '新风',
32
+ hyqwType: 36,
33
+ haEntityType: 'fan',
34
+ properties: ['switch', 'speed'],
35
+ meshAttrs: {
36
+ switch: 0x68,
37
+ speed: 0x6A
38
+ }
39
+ },
40
+ floor_heating: {
41
+ name: '地暖',
42
+ hyqwType: 16,
43
+ haEntityType: 'climate',
44
+ properties: ['switch', 'temperature'],
45
+ meshAttrs: {
46
+ switch: 0x6B,
47
+ temperature: 0x6C
48
+ }
49
+ }
50
+ };
51
+
52
+ /**
53
+ * 同步工具类 - 提供统一的防环路逻辑
54
+ */
55
+ class SyncUtils {
56
+ constructor(options = {}) {
57
+ this.defaultTimeout = options.defaultTimeout || DEFAULT_TIMEOUT;
58
+ this.coverTimeout = options.coverTimeout || COVER_TIMEOUT;
59
+ this.brightnessTimeout = options.brightnessTimeout || BRIGHTNESS_TIMEOUT;
60
+ this.syncTimestamps = new Map(); // key -> { direction, timestamp }
61
+ this.cleanupInterval = null;
62
+
63
+ // 启动定期清理
64
+ this.startCleanup();
65
+ }
66
+
67
+ /**
68
+ * 检查是否应该阻止同步(防环路)
69
+ * @param {string} direction - 'mesh-to-target' 或 'target-to-mesh'
70
+ * @param {string} key - 唯一标识符 (mac_channel_property)
71
+ * @param {number} timeout - 超时时间(毫秒),可选
72
+ * @returns {boolean} - true表示应该阻止
73
+ */
74
+ shouldPreventSync(direction, key, timeout) {
75
+ const now = Date.now();
76
+ const record = this.syncTimestamps.get(key);
77
+
78
+ if (!record) return false;
79
+
80
+ // 使用传入的timeout或默认值
81
+ const effectiveTimeout = timeout || this.defaultTimeout;
82
+
83
+ // 检查反向同步是否在超时时间内
84
+ const oppositeDirection = direction === 'mesh-to-target' ? 'target-to-mesh' : 'mesh-to-target';
85
+ if (record.direction === oppositeDirection && (now - record.timestamp) < effectiveTimeout) {
86
+ return true;
87
+ }
88
+
89
+ return false;
90
+ }
91
+
92
+ /**
93
+ * 记录同步时间
94
+ * @param {string} direction - 同步方向
95
+ * @param {string} key - 唯一标识符
96
+ */
97
+ recordSyncTime(direction, key) {
98
+ this.syncTimestamps.set(key, {
99
+ direction: direction,
100
+ timestamp: Date.now()
101
+ });
102
+ }
103
+
104
+ /**
105
+ * 清理过期的时间戳
106
+ * @param {number} expireMs - 过期时间(毫秒)
107
+ */
108
+ cleanupExpiredTimestamps(expireMs = TIMESTAMP_EXPIRE) {
109
+ const now = Date.now();
110
+ for (const [key, record] of this.syncTimestamps) {
111
+ if (now - record.timestamp > expireMs) {
112
+ this.syncTimestamps.delete(key);
113
+ }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * 根据设备类型获取超时时间
119
+ * @param {string} deviceType - 设备类型
120
+ * @returns {number} - 超时时间(毫秒)
121
+ */
122
+ getTimeoutForDevice(deviceType) {
123
+ switch (deviceType) {
124
+ case 'cover':
125
+ case 'curtain':
126
+ return this.coverTimeout;
127
+ case 'light':
128
+ case 'brightness':
129
+ return this.brightnessTimeout;
130
+ default:
131
+ return this.defaultTimeout;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * 启动定期清理
137
+ */
138
+ startCleanup() {
139
+ if (this.cleanupInterval) return;
140
+ this.cleanupInterval = setInterval(() => {
141
+ this.cleanupExpiredTimestamps();
142
+ }, CLEANUP_INTERVAL);
143
+ }
144
+
145
+ /**
146
+ * 销毁实例,清理资源
147
+ */
148
+ destroy() {
149
+ if (this.cleanupInterval) {
150
+ clearInterval(this.cleanupInterval);
151
+ this.cleanupInterval = null;
152
+ }
153
+ this.syncTimestamps.clear();
154
+ }
155
+ }
156
+
157
+ /**
158
+ * 状态缓存类 - 用于对比状态变化,避免重复同步
159
+ */
160
+ class StateCache {
161
+ constructor(context, storageKey) {
162
+ this.context = context;
163
+ this.storageKey = storageKey || 'sync_state_cache';
164
+ this.cache = new Map();
165
+ this.loadFromContext();
166
+ }
167
+
168
+ /**
169
+ * 从Node-RED context加载缓存
170
+ */
171
+ loadFromContext() {
172
+ if (!this.context) return;
173
+ try {
174
+ const saved = this.context.get(this.storageKey) || {};
175
+ for (const [key, value] of Object.entries(saved)) {
176
+ this.cache.set(key, value);
177
+ }
178
+ } catch (e) {
179
+ // 静默处理
180
+ }
181
+ }
182
+
183
+ /**
184
+ * 保存缓存到Node-RED context
185
+ */
186
+ saveToContext() {
187
+ if (!this.context) return;
188
+ try {
189
+ const obj = Object.fromEntries(this.cache);
190
+ this.context.set(this.storageKey, obj);
191
+ } catch (e) {
192
+ // 静默处理
193
+ }
194
+ }
195
+
196
+ /**
197
+ * 检查状态是否有变化
198
+ * @param {string} key - 唯一标识符
199
+ * @param {any} newState - 新状态
200
+ * @returns {boolean} - true表示有变化
201
+ */
202
+ hasChanged(key, newState) {
203
+ const oldState = this.cache.get(key);
204
+ if (oldState === undefined) return true;
205
+
206
+ // 深度比较
207
+ return JSON.stringify(oldState) !== JSON.stringify(newState);
208
+ }
209
+
210
+ /**
211
+ * 更新缓存
212
+ * @param {string} key - 唯一标识符
213
+ * @param {any} state - 状态值
214
+ */
215
+ update(key, state) {
216
+ this.cache.set(key, JSON.parse(JSON.stringify(state))); // 深拷贝
217
+ this.saveToContext();
218
+ }
219
+
220
+ /**
221
+ * 获取缓存的状态
222
+ * @param {string} key - 唯一标识符
223
+ * @returns {any} - 缓存的状态值
224
+ */
225
+ get(key) {
226
+ return this.cache.get(key);
227
+ }
228
+
229
+ /**
230
+ * 删除缓存项
231
+ * @param {string} key - 唯一标识符
232
+ */
233
+ remove(key) {
234
+ this.cache.delete(key);
235
+ this.saveToContext();
236
+ }
237
+
238
+ /**
239
+ * 清空缓存
240
+ */
241
+ clear() {
242
+ this.cache.clear();
243
+ this.saveToContext();
244
+ }
245
+ }
246
+
247
+ /**
248
+ * 同步队列类 - 管理同步命令的排队和执行
249
+ */
250
+ class SyncQueue {
251
+ constructor(options = {}) {
252
+ this.maxSize = options.maxSize || 100;
253
+ this.processInterval = options.processInterval || 50;
254
+ this.queue = [];
255
+ this.processing = false;
256
+ this.logger = options.logger || console;
257
+ this.onProcess = options.onProcess || (() => {});
258
+ this.processTimer = null;
259
+ }
260
+
261
+ /**
262
+ * 添加命令到队列
263
+ * @param {object} command - 同步命令
264
+ * @returns {boolean} - 是否成功添加
265
+ */
266
+ add(command) {
267
+ // 队列满时移除最旧的命令
268
+ if (this.queue.length >= this.maxSize) {
269
+ const dropped = this.queue.shift();
270
+ if (this.logger && this.logger.warn) {
271
+ this.logger.warn(`[SyncQueue] 队列已满,丢弃旧命令: ${dropped.key || 'unknown'}`);
272
+ }
273
+ }
274
+
275
+ // 去重:相同key的命令只保留最新的
276
+ if (command.key) {
277
+ const existingIndex = this.queue.findIndex(c => c.key === command.key);
278
+ if (existingIndex >= 0) {
279
+ this.queue[existingIndex] = command;
280
+ return true;
281
+ }
282
+ }
283
+
284
+ command.timestamp = Date.now();
285
+ this.queue.push(command);
286
+
287
+ // 如果没有在处理,启动处理
288
+ if (!this.processing) {
289
+ this.startProcessing();
290
+ }
291
+
292
+ return true;
293
+ }
294
+
295
+ /**
296
+ * 开始处理队列
297
+ */
298
+ startProcessing() {
299
+ if (this.processing) return;
300
+ this.processing = true;
301
+
302
+ const processNext = async () => {
303
+ if (this.queue.length === 0) {
304
+ this.processing = false;
305
+ return;
306
+ }
307
+
308
+ const command = this.queue.shift();
309
+
310
+ try {
311
+ await this.onProcess(command);
312
+ } catch (e) {
313
+ if (this.logger && this.logger.error) {
314
+ this.logger.error(`[SyncQueue] 处理命令失败: ${e.message}`);
315
+ }
316
+ }
317
+
318
+ // 继续处理下一个
319
+ this.processTimer = setTimeout(processNext, this.processInterval);
320
+ };
321
+
322
+ processNext();
323
+ }
324
+
325
+ /**
326
+ * 获取队列深度
327
+ * @returns {number}
328
+ */
329
+ getDepth() {
330
+ return this.queue.length;
331
+ }
332
+
333
+ /**
334
+ * 清空队列
335
+ */
336
+ clear() {
337
+ this.queue = [];
338
+ if (this.processTimer) {
339
+ clearTimeout(this.processTimer);
340
+ this.processTimer = null;
341
+ }
342
+ this.processing = false;
343
+ }
344
+
345
+ /**
346
+ * 销毁队列
347
+ */
348
+ destroy() {
349
+ this.clear();
350
+ this.onProcess = null;
351
+ this.logger = null;
352
+ }
353
+ }
354
+
355
+ /**
356
+ * 获取三合一子实体配置
357
+ * @param {string} subType - 子实体类型 ('aircon', 'fresh_air', 'floor_heating')
358
+ * @returns {object|null} - 子实体配置
359
+ */
360
+ function getThreeInOneSubEntity(subType) {
361
+ return THREE_IN_ONE_SUB_ENTITIES[subType] || null;
362
+ }
363
+
364
+ /**
365
+ * 获取所有三合一子实体类型
366
+ * @returns {string[]} - 子实体类型数组
367
+ */
368
+ function getThreeInOneSubTypes() {
369
+ return Object.keys(THREE_IN_ONE_SUB_ENTITIES);
370
+ }
371
+
372
+ /**
373
+ * 根据HYQW设备类型获取对应的三合一子实体类型
374
+ * @param {number} hyqwType - HYQW设备类型
375
+ * @returns {string|null} - 子实体类型
376
+ */
377
+ function getSubEntityByHyqwType(hyqwType) {
378
+ for (const [subType, config] of Object.entries(THREE_IN_ONE_SUB_ENTITIES)) {
379
+ if (config.hyqwType === hyqwType) {
380
+ return subType;
381
+ }
382
+ }
383
+ return null;
384
+ }
385
+
386
+ module.exports = {
387
+ SyncUtils,
388
+ StateCache,
389
+ SyncQueue,
390
+ THREE_IN_ONE_SUB_ENTITIES,
391
+ getThreeInOneSubEntity,
392
+ getThreeInOneSubTypes,
393
+ getSubEntityByHyqwType,
394
+ // 导出常量供外部使用
395
+ DEFAULT_TIMEOUT,
396
+ COVER_TIMEOUT,
397
+ BRIGHTNESS_TIMEOUT
398
+ };
@@ -22,10 +22,11 @@
22
22
  var autoRefreshInterval = null;
23
23
  var autoRefreshEnabled = true;
24
24
 
25
- // 设置编辑面板更宽
25
+ // 设置编辑面板更宽更高
26
26
  var panel = $('#dialog-form').parent();
27
- if (panel.length && panel.width() < 700) {
28
- panel.css('width', '800px');
27
+ if (panel.length) {
28
+ if (panel.width() < 1000) panel.css('width', '1000px');
29
+ if (panel.height() < 1000) panel.css('min-height', '1000px');
29
30
  }
30
31
 
31
32
  // 加载历史消息 - 显示全部100条(增量更新避免闪烁)
@@ -30,10 +30,11 @@
30
30
  var mappings = [];
31
31
  var cachedMeshDevices = [];
32
32
 
33
- // 设置编辑面板更宽
33
+ // 设置编辑面板更宽更高
34
34
  var panel = $('#dialog-form').parent();
35
- if (panel.length && panel.width() < 920) {
36
- panel.css('width', '900px');
35
+ if (panel.length) {
36
+ if (panel.width() < 1000) panel.css('width', '1000px');
37
+ if (panel.height() < 1000) panel.css('min-height', '1000px');
37
38
  }
38
39
 
39
40
  try { mappings = JSON.parse(node.mappings || '[]'); } catch(e) { mappings = []; }
@@ -2,6 +2,11 @@
2
2
  * Symi RS485 Bridge Node - Mesh与RS485设备双向同步桥接
3
3
  * 使用配置节点管理RS485连接(与Mesh网关相同的配置方式)
4
4
  * 事件驱动架构,命令队列顺序处理
5
+ *
6
+ * 版本: 1.8.5
7
+ *
8
+ * v1.8.5 更新:
9
+ * - 使用通用 SyncUtils 类统一防环路逻辑
5
10
  */
6
11
 
7
12
  // 兼容serialport v9和v10+
@@ -12,6 +17,8 @@ try {
12
17
  SerialPort = require('serialport');
13
18
  }
14
19
 
20
+ const { SyncUtils, COVER_TIMEOUT, BRIGHTNESS_TIMEOUT, DEFAULT_TIMEOUT } = require('../lib/sync-utils');
21
+
15
22
  module.exports = function(RED) {
16
23
 
17
24
  // 协议模板定义 - 按品牌/小区组织,不含房间名,只有设备类型标准模板
@@ -966,6 +973,13 @@ module.exports = function(RED) {
966
973
  node.syncLock = false;
967
974
  node.lastSyncTime = {};
968
975
  node.pendingVerify = false;
976
+
977
+ // 初始化通用同步工具类
978
+ node.syncUtils = new SyncUtils({
979
+ defaultTimeout: DEFAULT_TIMEOUT,
980
+ coverTimeout: COVER_TIMEOUT,
981
+ brightnessTimeout: BRIGHTNESS_TIMEOUT
982
+ });
969
983
 
970
984
  // RS485连接信息
971
985
  const rs485Info = node.rs485Config.connectionType === 'tcp'
@@ -1051,6 +1065,19 @@ module.exports = function(RED) {
1051
1065
  node.log('[RS485 Bridge] 初始化完成,开始同步');
1052
1066
  }, 5000); // 5秒初始化延迟
1053
1067
 
1068
+ // 检查是否应该阻止同步(防死循环)- 使用通用 SyncUtils
1069
+ node.shouldPreventSync = function(direction, key) {
1070
+ // 转换方向名称以匹配 SyncUtils 的格式
1071
+ const syncDirection = direction === 'mesh-to-rs485' ? 'mesh-to-target' : 'target-to-mesh';
1072
+ return node.syncUtils.shouldPreventSync(syncDirection, key);
1073
+ };
1074
+
1075
+ // 记录同步时间(用于防死循环)- 使用通用 SyncUtils
1076
+ node.recordSyncTime = function(direction, key) {
1077
+ const syncDirection = direction === 'mesh-to-rs485' ? 'mesh-to-target' : 'target-to-mesh';
1078
+ node.syncUtils.recordSyncTime(syncDirection, key);
1079
+ };
1080
+
1054
1081
  // Mesh设备状态变化处理(事件驱动)
1055
1082
  const handleMeshStateChange = (eventData) => {
1056
1083
  // 只检查initializing,不检查syncLock以避免丢失事件
@@ -3077,6 +3104,11 @@ module.exports = function(RED) {
3077
3104
  node.rs485Config.deregister(node);
3078
3105
  }
3079
3106
 
3107
+ // 销毁 SyncUtils 实例,清理资源
3108
+ if (node.syncUtils) {
3109
+ node.syncUtils.destroy();
3110
+ }
3111
+
3080
3112
  // 清理缓存和队列,防止内存泄漏
3081
3113
  node.stateCache = {};
3082
3114
  node.commandQueue = [];
@@ -20,6 +20,13 @@
20
20
  oneditprepare: function() {
21
21
  var node = this;
22
22
 
23
+ // 设置编辑面板更宽更高
24
+ var panel = $('#dialog-form').parent();
25
+ if (panel.length) {
26
+ if (panel.width() < 1000) panel.css('width', '1000px');
27
+ if (panel.height() < 1000) panel.css('min-height', '1000px');
28
+ }
29
+
23
30
  // 连接类型切换
24
31
  $('#node-config-input-connectionType').on('change', function() {
25
32
  if ($(this).val() === 'tcp') {
@@ -29,6 +29,13 @@
29
29
  oneditprepare: function() {
30
30
  const node = this;
31
31
 
32
+ // 设置编辑面板更宽更高
33
+ var panel = $('#dialog-form').parent();
34
+ if (panel.length) {
35
+ if (panel.width() < 1000) panel.css('width', '1000px');
36
+ if (panel.height() < 1000) panel.css('min-height', '1000px');
37
+ }
38
+
32
39
  $('#node-input-autoSync').prop('checked', node.autoSync !== false);
33
40
 
34
41
  // 初始化已保存的酒店和房间信息
@@ -31,6 +31,13 @@
31
31
  oneditprepare: function() {
32
32
  var node = this;
33
33
  node._deviceCache = {};
34
+
35
+ // 设置编辑面板更宽更高
36
+ var panel = $('#dialog-form').parent();
37
+ if (panel.length) {
38
+ if (panel.width() < 1000) panel.css('width', '1000px');
39
+ if (panel.height() < 1000) panel.css('min-height', '1000px');
40
+ }
34
41
 
35
42
  var updateChannelSelector = function(channels) {
36
43
  if (channels > 1) {
@@ -20,6 +20,13 @@
20
20
  oneditprepare: function() {
21
21
  var node = this;
22
22
 
23
+ // 设置编辑面板更宽更高
24
+ var panel = $('#dialog-form').parent();
25
+ if (panel.length) {
26
+ if (panel.width() < 1000) panel.css('width', '1000px');
27
+ if (panel.height() < 1000) panel.css('min-height', '1000px');
28
+ }
29
+
23
30
  $('#node-config-input-connectionType').on('change', function() {
24
31
  if ($(this).val() === 'tcp') {
25
32
  $('.tcp-config').show();
@@ -35,8 +35,8 @@
35
35
  // 设置编辑面板更宽更高
36
36
  var panel = $('#dialog-form').parent();
37
37
  if (panel.length) {
38
- if (panel.width() < 920) panel.css('width', '900px');
39
- if (panel.height() < 700) panel.css('min-height', '700px');
38
+ if (panel.width() < 1000) panel.css('width', '1000px');
39
+ if (panel.height() < 1000) panel.css('min-height', '1000px');
40
40
  }
41
41
 
42
42
  try { mappings = JSON.parse(node.mappings || '[]'); } catch(e) { mappings = []; }
@@ -202,7 +202,7 @@
202
202
  });
203
203
  } else {
204
204
  // 多路开关
205
- var channels = parseInt(device ? (device.channels || 1) : (savedChannels || 1)) || 1;
205
+ var channels = device ? (device.channels || 1) : (savedChannels || 1);
206
206
  for (var i = 1; i <= channels; i++) {
207
207
  var sel = (i == selectedKey) ? ' selected' : '';
208
208
  html += '<option value="' + i + '"' + sel + '>按键' + i + '</option>';
@@ -477,44 +477,11 @@
477
477
  <h3>功能特性</h3>
478
478
  <ul>
479
479
  <li><strong>完美双向同步</strong>:Symi↔HA实时状态同步</li>
480
- <li><strong>多设备类型支持</strong>:开关、调光灯、窗帘、温控器/空调、新风、地暖</li>
480
+ <li><strong>多设备类型支持</strong>:开关、调光灯、窗帘、温控器/空调</li>
481
481
  <li><strong>智能按键选择</strong>:只有多路开关才显示按键选择</li>
482
482
  <li><strong>配置持久化</strong>:设备列表和映射配置持久保存</li>
483
483
  <li><strong>防死循环</strong>:内置2秒防抖机制</li>
484
- <li><strong>智能防抖</strong>:窗帘/调光灯只同步最终位置,过程状态不同步</li>
485
- </ul>
486
-
487
- <h3>⚠️ 双向同步连接方式(重要)</h3>
488
- <p>要实现 HA→Symi 方向同步,必须连接 HA 事件节点到本节点输入端:</p>
489
-
490
- <h4>方式1:使用 server-events 节点(推荐)</h4>
491
- <pre>
492
- [server-events] → [symi-ha-sync]
493
- (事件类型: state_changed)
494
- </pre>
495
- <p><strong>配置步骤:</strong></p>
496
- <ol>
497
- <li>添加 <code>events: all</code> 节点(Home Assistant 分类下)</li>
498
- <li>事件类型(Event Type)填写: <code>state_changed</code></li>
499
- <li>将输出连接到 symi-ha-sync 节点的输入端</li>
500
- </ol>
501
-
502
- <h4>方式2:使用 server-state-changed 节点</h4>
503
- <pre>
504
- [server-state-changed] → [symi-ha-sync]
505
- </pre>
506
- <p><strong>配置步骤:</strong></p>
507
- <ol>
508
- <li>添加 <code>events: state</code> 节点</li>
509
- <li>实体ID可留空(监听所有实体)或指定特定实体</li>
510
- <li>将输出连接到 symi-ha-sync 节点的输入端</li>
511
- </ol>
512
-
513
- <h3>状态指示</h3>
514
- <ul>
515
- <li><strong>蓝色 "Mesh→HA"</strong>:仅 Mesh 到 HA 方向工作(未连接 HA 事件节点)</li>
516
- <li><strong>绿色 "双向同步"</strong>:双向同步正常工作</li>
517
- <li><strong>红色</strong>:配置错误</li>
484
+ <li><strong>智能防抖</strong>:窗帘/调光灯只同步最终位置</li>
518
485
  </ul>
519
486
 
520
487
  <h3>支持的设备类型</h3>
@@ -523,15 +490,6 @@
523
490
  <li><strong>调光灯</strong>:开关 + 亮度 (0-100)</li>
524
491
  <li><strong>窗帘</strong>:开/关/停 + 位置 (0-100%)</li>
525
492
  <li><strong>温控器/空调</strong>:开关 + 温度 + 模式 + 风速</li>
526
- <li><strong>三合一面板</strong>:空调 + 新风 + 地暖,分别配置</li>
527
- </ul>
528
-
529
- <h3>三合一面板配置</h3>
530
- <p>三合一面板需要分别为每个子设备创建映射:</p>
531
- <ul>
532
- <li><strong>空调</strong>:选择子设备"空调",映射到 climate 实体</li>
533
- <li><strong>新风</strong>:选择子设备"新风",映射到 fan 实体</li>
534
- <li><strong>地暖</strong>:选择子设备"地暖",映射到 climate 实体</li>
535
493
  </ul>
536
494
 
537
495
  <h3>离线设备显示</h3>