node-red-contrib-symi-mesh 1.7.7 → 1.7.8

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.
@@ -7,7 +7,10 @@
7
7
  mqttConfig: { value: '', type: 'symi-mqtt', required: true },
8
8
  brandMqttConfig: { value: '', type: 'symi-mqtt-brand', required: true },
9
9
  autoDiscover: { value: true },
10
- mappings: { value: '[]' }
10
+ mappings: { value: '[]' },
11
+ // 持久化缓存:保存设备列表,断线后仍可显示
12
+ cachedMeshDevices: { value: '[]' },
13
+ cachedBrandDevices: { value: '[]' }
11
14
  },
12
15
  inputs: 1,
13
16
  outputs: 1,
@@ -27,11 +30,14 @@
27
30
  var mappings = [];
28
31
  var meshDevices = [];
29
32
  var brandDevices = [];
33
+ var cachedMeshDevices = [];
34
+ var cachedBrandDevices = [];
30
35
 
31
- // 设置编辑面板更宽
36
+ // 设置编辑面板更宽更高
32
37
  var panel = $('#dialog-form').parent();
33
- if (panel.length && panel.width() < 920) {
34
- panel.css('width', '900px');
38
+ if (panel.length) {
39
+ if (panel.width() < 920) panel.css('width', '900px');
40
+ if (panel.height() < 700) panel.css('min-height', '700px');
35
41
  }
36
42
 
37
43
  var deviceTypes = {
@@ -42,28 +48,70 @@
42
48
  36: { name: '新风' }
43
49
  };
44
50
 
51
+ // 加载已保存的配置
45
52
  try { mappings = JSON.parse(node.mappings || '[]'); } catch(e) { mappings = []; }
53
+ try { cachedMeshDevices = JSON.parse(node.cachedMeshDevices || '[]'); } catch(e) { cachedMeshDevices = []; }
54
+ try { cachedBrandDevices = JSON.parse(node.cachedBrandDevices || '[]'); } catch(e) { cachedBrandDevices = []; }
55
+
56
+ // 合并设备列表:在线设备 + 缓存设备(去重)
57
+ function mergeDevices(onlineDevices, cachedDevices, keyField) {
58
+ var merged = [];
59
+ var keys = new Set();
60
+
61
+ // 先添加在线设备
62
+ (onlineDevices || []).forEach(function(d) {
63
+ var key = keyField === 'mac' ? (d.mac || '').toLowerCase().replace(/:/g, '') :
64
+ (d.deviceType + '_' + d.deviceId);
65
+ if (!keys.has(key)) {
66
+ keys.add(key);
67
+ d._online = true;
68
+ merged.push(d);
69
+ }
70
+ });
71
+
72
+ // 再添加缓存中不在线的设备
73
+ (cachedDevices || []).forEach(function(d) {
74
+ var key = keyField === 'mac' ? (d.mac || '').toLowerCase().replace(/:/g, '') :
75
+ (d.deviceType + '_' + d.deviceId);
76
+ if (!keys.has(key)) {
77
+ keys.add(key);
78
+ d._online = false;
79
+ merged.push(d);
80
+ }
81
+ });
82
+
83
+ return merged;
84
+ }
46
85
 
47
86
  // 加载Mesh设备列表
48
87
  function loadMeshDevices(callback) {
49
88
  var mqttConfigId = $('#node-input-mqttConfig').val();
50
89
  if (!mqttConfigId) {
51
- meshDevices = [];
90
+ // 无配置时使用缓存
91
+ meshDevices = mergeDevices([], cachedMeshDevices, 'mac');
52
92
  if (callback) callback();
53
93
  return;
54
94
  }
55
95
  var mqttConfigNode = RED.nodes.node(mqttConfigId);
56
96
  var gatewayId = mqttConfigNode ? mqttConfigNode.gateway : null;
57
97
  if (!gatewayId) {
58
- meshDevices = [];
98
+ meshDevices = mergeDevices([], cachedMeshDevices, 'mac');
59
99
  if (callback) callback();
60
100
  return;
61
101
  }
62
102
  $.getJSON('symi-gateway/devices/' + gatewayId, function(devices) {
63
- meshDevices = devices || [];
103
+ // 合并在线设备和缓存设备
104
+ meshDevices = mergeDevices(devices || [], cachedMeshDevices, 'mac');
105
+ // 更新缓存(只保存有效设备)
106
+ if (devices && devices.length > 0) {
107
+ cachedMeshDevices = devices.map(function(d) {
108
+ return { mac: d.mac, name: d.name, channels: d.channels };
109
+ });
110
+ }
64
111
  if (callback) callback();
65
112
  }).fail(function() {
66
- meshDevices = [];
113
+ // 请求失败时使用缓存
114
+ meshDevices = mergeDevices([], cachedMeshDevices, 'mac');
67
115
  if (callback) callback();
68
116
  });
69
117
  }
@@ -72,38 +120,56 @@
72
120
  function loadBrandDevices(callback) {
73
121
  var brandConfigId = $('#node-input-brandMqttConfig').val();
74
122
  if (!brandConfigId) {
75
- brandDevices = [];
123
+ brandDevices = mergeDevices([], cachedBrandDevices, 'brand');
76
124
  if (callback) callback();
77
125
  return;
78
126
  }
79
127
  $.getJSON('symi-mqtt-brand/devices/' + brandConfigId, function(devices) {
80
- brandDevices = devices || [];
128
+ brandDevices = mergeDevices(devices || [], cachedBrandDevices, 'brand');
129
+ // 更新缓存
130
+ if (devices && devices.length > 0) {
131
+ cachedBrandDevices = devices.map(function(d) {
132
+ return { deviceType: d.deviceType, deviceId: d.deviceId, typeName: d.typeName };
133
+ });
134
+ }
81
135
  if (callback) callback();
82
136
  }).fail(function() {
83
- brandDevices = [];
137
+ brandDevices = mergeDevices([], cachedBrandDevices, 'brand');
84
138
  if (callback) callback();
85
139
  });
86
140
  }
87
141
 
88
- // 构建Mesh设备选项
89
- function getMeshOptions(selectedMac) {
142
+ // 构建Mesh设备选项(支持显示离线设备)
143
+ function getMeshOptions(selectedMac, savedName) {
90
144
  var html = '<option value="">-- 选择Mesh设备 --</option>';
91
145
  var selMacNorm = (selectedMac || '').toLowerCase().replace(/:/g, '');
146
+ var found = false;
147
+
92
148
  meshDevices.forEach(function(d) {
93
149
  var devMacNorm = (d.mac || '').toLowerCase().replace(/:/g, '');
94
150
  var selected = (devMacNorm === selMacNorm && selMacNorm !== '') ? ' selected' : '';
95
- html += '<option value="' + d.mac + '" data-channels="' + (d.channels || 1) + '"' + selected + '>' + d.name + '</option>';
151
+ if (selected) found = true;
152
+ var statusIcon = d._online === false ? ' [离线]' : '';
153
+ var style = d._online === false ? ' style="color:#999;"' : '';
154
+ html += '<option value="' + d.mac + '" data-channels="' + (d.channels || 1) + '" data-name="' + (d.name || '') + '"' + selected + style + '>' + (d.name || d.mac) + statusIcon + '</option>';
96
155
  });
156
+
157
+ // 如果已选择的设备不在列表中,添加它(使用保存的名称)
158
+ if (selMacNorm && !found) {
159
+ var displayName = savedName || selectedMac;
160
+ html += '<option value="' + selectedMac + '" selected style="color:#c00;">' + displayName + ' [未找到]</option>';
161
+ }
162
+
97
163
  return html;
98
164
  }
99
165
 
100
166
  // 构建Mesh按键选项
101
- function getMeshChannelOptions(mac, selectedChannel) {
167
+ function getMeshChannelOptions(mac, selectedChannel, savedChannels) {
102
168
  var macNorm = (mac || '').toLowerCase().replace(/:/g, '');
103
169
  var device = meshDevices.find(function(d) {
104
170
  return (d.mac || '').toLowerCase().replace(/:/g, '') === macNorm;
105
171
  });
106
- var channels = device ? (device.channels || 1) : 0;
172
+ var channels = device ? (device.channels || 1) : (savedChannels || 1);
107
173
  if (channels <= 1) return '';
108
174
  var html = '<select class="mesh-channel">';
109
175
  for (var i = 1; i <= channels; i++) {
@@ -114,27 +180,26 @@
114
180
  return html;
115
181
  }
116
182
 
117
- // 构建品牌设备选项
118
- function getBrandOptions(selectedType, selectedId) {
183
+ // 构建品牌设备选项(支持显示离线设备)
184
+ function getBrandOptions(selectedType, selectedId, savedTypeName) {
119
185
  var html = '<option value="">-- 选择品牌设备 --</option>';
120
186
  var selectedKey = selectedType + '_' + selectedId;
187
+ var found = false;
121
188
 
122
- // 从已发现设备生成选项
189
+ // 从设备列表生成选项
123
190
  brandDevices.forEach(function(d) {
124
191
  var key = d.deviceType + '_' + d.deviceId;
125
192
  var selected = (key === selectedKey) ? ' selected' : '';
126
- html += '<option value="' + key + '"' + selected + '>' + d.typeName + ' (ID:' + d.deviceId + ')</option>';
193
+ if (selected) found = true;
194
+ var statusIcon = d._online === false ? ' [离线]' : '';
195
+ var style = d._online === false ? ' style="color:#999;"' : '';
196
+ html += '<option value="' + key + '" data-typename="' + (d.typeName || '') + '"' + selected + style + '>' + (d.typeName || '设备') + ' (ID:' + d.deviceId + ')' + statusIcon + '</option>';
127
197
  });
128
198
 
129
199
  // 如果选中的设备不在列表中,添加它
130
- if (selectedType && selectedId) {
131
- var exists = brandDevices.some(function(d) {
132
- return d.deviceType == selectedType && d.deviceId == selectedId;
133
- });
134
- if (!exists) {
135
- var typeName = deviceTypes[selectedType] ? deviceTypes[selectedType].name : '类型' + selectedType;
136
- html += '<option value="' + selectedKey + '" selected>' + typeName + ' (ID:' + selectedId + ')</option>';
137
- }
200
+ if (selectedType && selectedId && !found) {
201
+ var typeName = savedTypeName || (deviceTypes[selectedType] ? deviceTypes[selectedType].name : '类型' + selectedType);
202
+ html += '<option value="' + selectedKey + '" selected style="color:#c00;">' + typeName + ' (ID:' + selectedId + ') [未找到]</option>';
138
203
  }
139
204
 
140
205
  return html;
@@ -142,7 +207,6 @@
142
207
 
143
208
  // 构建品牌设备通道选项(灯具支持多路)
144
209
  function getBrandChannelOptions(deviceType, selectedChannel) {
145
- // 灯具(8)支持多路,其他设备单路
146
210
  var channels = (parseInt(deviceType) === 8) ? 8 : 1;
147
211
  if (channels <= 1) return '';
148
212
  var html = '<select class="brand-channel">';
@@ -169,12 +233,12 @@
169
233
  row.html(
170
234
  '<div class="mapping-main">' +
171
235
  '<div class="mesh-col">' +
172
- ' <select class="mesh-select">' + getMeshOptions(m.meshMac) + '</select>' +
173
- ' <span class="mesh-ch-wrap">' + getMeshChannelOptions(m.meshMac, m.meshChannel || 1) + '</span>' +
236
+ ' <select class="mesh-select">' + getMeshOptions(m.meshMac, m.meshName) + '</select>' +
237
+ ' <span class="mesh-ch-wrap">' + getMeshChannelOptions(m.meshMac, m.meshChannel || 1, m.meshChannels) + '</span>' +
174
238
  '</div>' +
175
239
  '<div class="arrow-col"><i class="fa fa-arrows-h"></i></div>' +
176
240
  '<div class="brand-col">' +
177
- ' <select class="brand-select">' + getBrandOptions(m.brandDeviceType, m.brandDeviceId) + '</select>' +
241
+ ' <select class="brand-select">' + getBrandOptions(m.brandDeviceType, m.brandDeviceId, m.brandTypeName) + '</select>' +
178
242
  ' <span class="brand-ch-wrap">' + getBrandChannelOptions(m.brandDeviceType, m.brandChannel || 1) + '</span>' +
179
243
  '</div>' +
180
244
  '<div class="del-col"><button type="button" class="red-ui-button red-ui-button-small btn-remove" title="删除"><i class="fa fa-times"></i></button></div>' +
@@ -194,9 +258,14 @@
194
258
  var row = $(this).closest('.mapping-row');
195
259
  var idx = row.data('idx');
196
260
  var mac = $(this).val();
261
+ var opt = $(this).find('option:selected');
262
+
197
263
  mappings[idx].meshMac = mac || '';
264
+ mappings[idx].meshName = opt.data('name') || opt.text().replace(' [离线]', '').replace(' [未找到]', '');
265
+ mappings[idx].meshChannels = parseInt(opt.data('channels')) || 1;
198
266
  mappings[idx].meshChannel = 1;
199
- row.find('.mesh-ch-wrap').html(getMeshChannelOptions(mac, 1));
267
+
268
+ row.find('.mesh-ch-wrap').html(getMeshChannelOptions(mac, 1, mappings[idx].meshChannels));
200
269
  bindEvents();
201
270
  });
202
271
 
@@ -209,17 +278,20 @@
209
278
  var row = $(this).closest('.mapping-row');
210
279
  var idx = row.data('idx');
211
280
  var val = $(this).val();
281
+ var opt = $(this).find('option:selected');
282
+
212
283
  if (val) {
213
284
  var parts = val.split('_');
214
285
  mappings[idx].brandDeviceType = parseInt(parts[0]) || 8;
215
286
  mappings[idx].brandDeviceId = parseInt(parts[1]) || 1;
287
+ mappings[idx].brandTypeName = opt.data('typename') || opt.text().split(' (ID:')[0].replace(' [离线]', '').replace(' [未找到]', '');
216
288
  mappings[idx].brandChannel = 1;
217
- // 更新通道选择器
218
289
  row.find('.brand-ch-wrap').html(getBrandChannelOptions(mappings[idx].brandDeviceType, 1));
219
290
  bindEvents();
220
291
  } else {
221
292
  mappings[idx].brandDeviceType = null;
222
293
  mappings[idx].brandDeviceId = null;
294
+ mappings[idx].brandTypeName = null;
223
295
  mappings[idx].brandChannel = 1;
224
296
  row.find('.brand-ch-wrap').empty();
225
297
  }
@@ -239,12 +311,30 @@
239
311
 
240
312
  // 刷新品牌设备
241
313
  $('#refresh-brand-btn').on('click', function() {
242
- loadBrandDevices(renderMappings);
314
+ var btn = $(this);
315
+ btn.prop('disabled', true).find('i').addClass('fa-spin');
316
+ loadBrandDevices(function() {
317
+ renderMappings();
318
+ btn.prop('disabled', false).find('i').removeClass('fa-spin');
319
+ });
320
+ });
321
+
322
+ // 刷新Mesh设备
323
+ $('#refresh-mesh-btn').on('click', function() {
324
+ var btn = $(this);
325
+ btn.prop('disabled', true).find('i').addClass('fa-spin');
326
+ loadMeshDevices(function() {
327
+ renderMappings();
328
+ btn.prop('disabled', false).find('i').removeClass('fa-spin');
329
+ });
243
330
  });
244
331
 
245
332
  // 添加映射按钮
246
333
  $('#btn-add-mapping').on('click', function() {
247
- mappings.push({ meshMac: '', meshChannel: 1, brandDeviceType: 8, brandDeviceId: 1, brandChannel: 1 });
334
+ mappings.push({
335
+ meshMac: '', meshName: '', meshChannel: 1, meshChannels: 1,
336
+ brandDeviceType: 8, brandDeviceId: 1, brandTypeName: '灯具', brandChannel: 1
337
+ });
248
338
  renderMappings();
249
339
  });
250
340
 
@@ -261,10 +351,14 @@
261
351
  }, 100);
262
352
  });
263
353
 
264
- // 保存时更新mappings
265
- node._saveMappings = function() {
354
+ // 保存时更新所有数据
355
+ node._saveAll = function() {
266
356
  node.mappings = JSON.stringify(mappings);
357
+ node.cachedMeshDevices = JSON.stringify(cachedMeshDevices);
358
+ node.cachedBrandDevices = JSON.stringify(cachedBrandDevices);
267
359
  $('#node-input-mappings').val(node.mappings);
360
+ $('#node-input-cachedMeshDevices').val(node.cachedMeshDevices);
361
+ $('#node-input-cachedBrandDevices').val(node.cachedBrandDevices);
268
362
  };
269
363
 
270
364
  // 初始加载
@@ -282,7 +376,7 @@
282
376
  $('#mapping-list').css('max-height', Math.max(150, height) + 'px');
283
377
  },
284
378
  oneditsave: function() {
285
- if (this._saveMappings) { this._saveMappings(); }
379
+ if (this._saveAll) { this._saveAll(); }
286
380
  }
287
381
  });
288
382
  </script>
@@ -292,8 +386,10 @@
292
386
  .bridge-section { margin: 12px 0; padding: 10px; border: 1px solid #ddd; border-radius: 5px; background: #fafafa; }
293
387
  .bridge-section h4 { margin: 0 0 10px 0; padding-bottom: 6px; border-bottom: 1px solid #eee; color: #333; font-size: 13px; }
294
388
  .bridge-section h4 i { margin-right: 6px; color: #666; }
389
+ .section-btns { float: right; }
390
+ .section-btns button { margin-left: 4px; }
295
391
 
296
- #mapping-list { max-height: calc(100vh - 400px); min-height: 150px; overflow-y: auto; }
392
+ #mapping-list { max-height: 600px; min-height: 400px; overflow-y: auto; }
297
393
  .mapping-empty { padding: 15px; text-align: center; color: #999; font-size: 12px; }
298
394
  .mapping-row { display: flex; flex-direction: column; padding: 6px 8px; margin-bottom: 6px; background: #fff; border: 1px solid #e0e0e0; border-radius: 4px; gap: 6px; }
299
395
  .mapping-main { display: flex; align-items: center; width: 100%; gap: 6px; min-width: 0; }
@@ -331,7 +427,10 @@
331
427
 
332
428
  <div class="bridge-section">
333
429
  <h4><i class="fa fa-exchange"></i> 实体映射(Mesh ↔ 品牌MQTT)
334
- <button type="button" id="refresh-brand-btn" class="red-ui-button red-ui-button-small" style="float:right;" title="刷新品牌设备"><i class="fa fa-refresh"></i></button>
430
+ <span class="section-btns">
431
+ <button type="button" id="refresh-mesh-btn" class="red-ui-button red-ui-button-small" title="刷新Mesh设备"><i class="fa fa-refresh"></i> Mesh</button>
432
+ <button type="button" id="refresh-brand-btn" class="red-ui-button red-ui-button-small" title="刷新品牌设备"><i class="fa fa-refresh"></i> 品牌</button>
433
+ </span>
335
434
  </h4>
336
435
  <div style="display:flex; padding:4px 8px; font-size:11px; color:#666; border-bottom:1px solid #eee; margin-bottom:6px; gap:6px;">
337
436
  <span style="flex:1 1 40%">Mesh设备/按键</span>
@@ -345,6 +444,8 @@
345
444
  </div>
346
445
 
347
446
  <input type="hidden" id="node-input-mappings">
447
+ <input type="hidden" id="node-input-cachedMeshDevices">
448
+ <input type="hidden" id="node-input-cachedBrandDevices">
348
449
  </script>
349
450
 
350
451
  <script type="text/html" data-help-name="symi-mqtt-sync">
@@ -354,6 +455,7 @@
354
455
  <ul>
355
456
  <li><strong>双MQTT配置节点</strong>:Mesh MQTT + 品牌MQTT独立配置</li>
356
457
  <li><strong>设备自动发现</strong>:品牌MQTT连接后自动发现设备</li>
458
+ <li><strong>配置持久化</strong>:设备列表和映射配置持久保存,断线后仍可显示</li>
357
459
  <li><strong>实体映射</strong>:左边选择Mesh设备,右边选择品牌设备</li>
358
460
  <li><strong>双向同步</strong>:MQTT↔Mesh双向状态实时同步</li>
359
461
  <li><strong>防死循环</strong>:内置2秒防抖机制</li>
@@ -367,9 +469,16 @@
367
469
  <dt>品牌MQTT</dt>
368
470
  <dd>选择品牌MQTT配置节点(如HYQW),用于获取品牌设备列表</dd>
369
471
  <dt>实体映射</dt>
370
- <dd>配置Mesh设备与品牌设备的对应关系</dd>
472
+ <dd>配置Mesh设备与品牌设备的对应关系(配置会持久保存)</dd>
371
473
  </dl>
372
474
 
475
+ <h3>离线设备显示</h3>
476
+ <p>当MQTT断开时,已配置的设备仍会显示在列表中:</p>
477
+ <ul>
478
+ <li><strong>[离线]</strong>:设备在缓存中但当前不在线</li>
479
+ <li><strong>[未找到]</strong>:设备既不在线也不在缓存中</li>
480
+ </ul>
481
+
373
482
  <h3>支持的设备类型(HYQW)</h3>
374
483
  <ul>
375
484
  <li><strong>灯具(8)</strong>:开关、亮度</li>
@@ -268,18 +268,25 @@ module.exports = function(RED) {
268
268
  );
269
269
  if (!mapping || !mapping.meshMac) return;
270
270
 
271
- // 防死循环
272
- const syncKey = `mqtt_${deviceType}_${deviceId}_${fn}`;
273
- const lastSync = node._syncTimestamps.get(syncKey) || 0;
274
- if (Date.now() - lastSync < SYNC_DEBOUNCE_MS) return;
275
-
276
271
  const meshMac = mapping.meshMac;
277
272
  const meshChannel = parseInt(mapping.meshChannel) || 1;
273
+
274
+ // 防死循环 - 检查两个方向的时间戳
275
+ const now = Date.now();
276
+ const mqttSyncKey = `mqtt_${deviceType}_${deviceId}_${fn}`;
277
+ const meshSyncKey = `mesh_${meshMac}_${meshChannel}_${fn}`;
278
+ const lastMqttSync = node._syncTimestamps.get(mqttSyncKey) || 0;
279
+ const lastMeshSync = node._syncTimestamps.get(meshSyncKey) || 0;
280
+
281
+ // 如果任一方向在防抖时间内有同步,跳过
282
+ if (now - lastMqttSync < SYNC_DEBOUNCE_MS || now - lastMeshSync < SYNC_DEBOUNCE_MS) return;
283
+
278
284
  const device = node._gateway.deviceManager.getDeviceByMac(meshMac);
279
285
  if (!device) return;
280
286
 
281
- node._syncTimestamps.set(syncKey, Date.now());
282
- node._syncTimestamps.set(`mesh_${meshMac}_${meshChannel}`, Date.now());
287
+ // 记录时间戳 - 同时标记两个方向
288
+ node._syncTimestamps.set(mqttSyncKey, now);
289
+ node._syncTimestamps.set(meshSyncKey, now);
283
290
 
284
291
  const typeInfo = node._brandProtocol.deviceTypes[deviceType];
285
292
  if (!typeInfo) return;
@@ -346,21 +353,42 @@ module.exports = function(RED) {
346
353
  const typeInfo = node._brandProtocol.deviceTypes[deviceType];
347
354
  if (!typeInfo) return;
348
355
 
349
- // 防死循环
350
- const syncKey = `mesh_${meshMac}_${channel}`;
351
- const lastSync = node._syncTimestamps.get(syncKey) || 0;
352
- if (Date.now() - lastSync < SYNC_DEBOUNCE_MS) return;
353
- node._syncTimestamps.set(syncKey, Date.now());
354
-
355
- let fn, fv;
356
+ // 根据属性确定功能码
357
+ let fn;
356
358
  if (property === 'switch' || property === 'on' || property === 'isOn') {
357
- fn = 1; fv = value ? 1 : 0;
359
+ fn = 1;
358
360
  } else if (property === 'brightness' || property === 'temperature' || property === 'position') {
359
- fn = 2; fv = parseInt(value) || 0;
361
+ fn = 2;
360
362
  } else if (property === 'mode' || property === 'hvacMode') {
361
- fn = 3; fv = AC_MODE_REVERSE[value] ?? 0;
363
+ fn = 3;
362
364
  } else if (property === 'fanSpeed' || property === 'fanMode') {
363
365
  fn = typeInfo.meshType === 'climate' ? 4 : 3;
366
+ } else {
367
+ return;
368
+ }
369
+
370
+ // 防死循环 - 检查两个方向的时间戳
371
+ const now = Date.now();
372
+ const meshSyncKey = `mesh_${meshMac}_${channel}_${fn}`;
373
+ const mqttSyncKey = `mqtt_${deviceType}_${deviceId}_${fn}`;
374
+ const lastMeshSync = node._syncTimestamps.get(meshSyncKey) || 0;
375
+ const lastMqttSync = node._syncTimestamps.get(mqttSyncKey) || 0;
376
+
377
+ // 如果任一方向在防抖时间内有同步,跳过
378
+ if (now - lastMeshSync < SYNC_DEBOUNCE_MS || now - lastMqttSync < SYNC_DEBOUNCE_MS) return;
379
+
380
+ // 记录时间戳 - 同时标记两个方向
381
+ node._syncTimestamps.set(meshSyncKey, now);
382
+ node._syncTimestamps.set(mqttSyncKey, now);
383
+
384
+ let fv;
385
+ if (property === 'switch' || property === 'on' || property === 'isOn') {
386
+ fv = value ? 1 : 0;
387
+ } else if (property === 'brightness' || property === 'temperature' || property === 'position') {
388
+ fv = parseInt(value) || 0;
389
+ } else if (property === 'mode' || property === 'hvacMode') {
390
+ fv = AC_MODE_REVERSE[value] ?? 0;
391
+ } else if (property === 'fanSpeed' || property === 'fanMode') {
364
392
  fv = FAN_SPEED_REVERSE[value] ?? 0;
365
393
  } else {
366
394
  return;
@@ -371,7 +399,6 @@ module.exports = function(RED) {
371
399
 
372
400
  try {
373
401
  node._mqttClient.publish(topic, payload, { qos: 0 });
374
- node._syncTimestamps.set(`mqtt_${deviceType}_${deviceId}_${fn}`, Date.now());
375
402
 
376
403
  // 输出到debug
377
404
  node.send({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-symi-mesh",
3
- "version": "1.7.7",
3
+ "version": "1.7.8",
4
4
  "description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
5
5
  "main": "nodes/symi-gateway.js",
6
6
  "scripts": {
@@ -37,6 +37,7 @@
37
37
  "rs485-debug": "nodes/rs485-debug.js",
38
38
  "symi-knx-bridge": "nodes/symi-knx-bridge.js",
39
39
  "symi-knx-ha-bridge": "nodes/symi-knx-ha-bridge.js",
40
+ "symi-ha-sync": "nodes/symi-ha-sync.js",
40
41
  "symi-rs485-sync": "nodes/symi-rs485-sync.js",
41
42
  "symi-mqtt-sync": "nodes/symi-mqtt-sync.js",
42
43
  "symi-mqtt-brand": "nodes/symi-mqtt-brand.js"