node-red-contrib-symi-mesh 1.7.5 → 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.
@@ -0,0 +1,451 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('symi-ha-sync', {
3
+ category: 'Symi Mesh',
4
+ color: '#41BDF5',
5
+ defaults: {
6
+ name: { value: '' },
7
+ mqttConfig: { value: '', type: 'symi-mqtt', required: true },
8
+ haServer: { value: '', type: 'server', required: true },
9
+ mappings: { value: '[]' },
10
+ cachedSymiDevices: { value: '[]' },
11
+ cachedHaEntities: { value: '[]' }
12
+ },
13
+ inputs: 1,
14
+ outputs: 1,
15
+ outputLabels: ['日志'],
16
+ icon: 'font-awesome/fa-refresh',
17
+ align: 'left',
18
+ paletteLabel: 'HA同步',
19
+ label: function() {
20
+ if (this.name) return this.name;
21
+ try {
22
+ var m = JSON.parse(this.mappings || '[]');
23
+ if (m.length > 0) return 'HA同步(' + m.length + '组)';
24
+ } catch(e) {}
25
+ return 'HA同步';
26
+ },
27
+ oneditprepare: function() {
28
+ var node = this;
29
+ var mappings = [];
30
+ var symiDevices = [];
31
+ var haEntities = [];
32
+ var cachedSymiDevices = [];
33
+ var cachedHaEntities = [];
34
+
35
+ // 设置编辑面板更宽更高
36
+ var panel = $('#dialog-form').parent();
37
+ if (panel.length) {
38
+ if (panel.width() < 920) panel.css('width', '900px');
39
+ if (panel.height() < 700) panel.css('min-height', '700px');
40
+ }
41
+
42
+ try { mappings = JSON.parse(node.mappings || '[]'); } catch(e) { mappings = []; }
43
+ try { cachedSymiDevices = JSON.parse(node.cachedSymiDevices || '[]'); } catch(e) { cachedSymiDevices = []; }
44
+ try { cachedHaEntities = JSON.parse(node.cachedHaEntities || '[]'); } catch(e) { cachedHaEntities = []; }
45
+
46
+ // 合并设备列表
47
+ function mergeDevices(onlineDevices, cachedDevices, keyField) {
48
+ var merged = [];
49
+ var keys = new Set();
50
+ (onlineDevices || []).forEach(function(d) {
51
+ if (!d) return;
52
+ var key = keyField === 'mac' ? (d.macAddress || '').toLowerCase().replace(/:/g, '') : d.entity_id;
53
+ if (key && !keys.has(key)) {
54
+ keys.add(key);
55
+ d._online = true;
56
+ merged.push(d);
57
+ }
58
+ });
59
+ (cachedDevices || []).forEach(function(d) {
60
+ if (!d) return;
61
+ var key = keyField === 'mac' ? (d.macAddress || '').toLowerCase().replace(/:/g, '') : d.entity_id;
62
+ if (key && !keys.has(key)) {
63
+ keys.add(key);
64
+ d._online = false;
65
+ merged.push(d);
66
+ }
67
+ });
68
+ return merged;
69
+ }
70
+
71
+ // 加载Symi设备列表
72
+ function loadSymiDevices(callback) {
73
+ var mqttConfigId = $('#node-input-mqttConfig').val();
74
+ if (!mqttConfigId || mqttConfigId === '_ADD_') {
75
+ symiDevices = mergeDevices([], cachedSymiDevices, 'mac');
76
+ if (callback) callback();
77
+ return;
78
+ }
79
+ $.getJSON('symi-ha-sync/symi-devices/' + mqttConfigId)
80
+ .done(function(data) {
81
+ symiDevices = mergeDevices(data || [], cachedSymiDevices, 'mac');
82
+ if (data && data.length > 0) {
83
+ cachedSymiDevices = data.map(function(d) {
84
+ return { macAddress: d.macAddress, name: d.name, channels: d.channels, deviceType: d.deviceType, entityType: d.entityType };
85
+ });
86
+ }
87
+ if (callback) callback();
88
+ })
89
+ .fail(function() {
90
+ symiDevices = mergeDevices([], cachedSymiDevices, 'mac');
91
+ if (callback) callback();
92
+ });
93
+ }
94
+
95
+ // 加载HA实体列表
96
+ function loadHaEntities(callback) {
97
+ var serverId = $('#node-input-haServer').val();
98
+ if (!serverId || serverId === '_ADD_') {
99
+ haEntities = mergeDevices([], cachedHaEntities, 'entity');
100
+ if (callback) callback();
101
+ return;
102
+ }
103
+ $.getJSON('symi-ha-sync/ha-entities/' + serverId)
104
+ .done(function(data) {
105
+ haEntities = mergeDevices(data || [], cachedHaEntities, 'entity');
106
+ if (data && data.length > 0) {
107
+ cachedHaEntities = data.map(function(e) {
108
+ return { entity_id: e.entity_id, name: e.name, type: e.type };
109
+ });
110
+ }
111
+ if (callback) callback();
112
+ })
113
+ .fail(function() {
114
+ haEntities = mergeDevices([], cachedHaEntities, 'entity');
115
+ if (callback) callback();
116
+ });
117
+ }
118
+
119
+ // 判断是否需要按键选择
120
+ function needsKeySelection(device) {
121
+ if (!device) return false;
122
+ var entityType = device.entityType || '';
123
+ // 如果是温控器、窗帘、灯具,不需要选择按键(通常是单路或特殊处理)
124
+ if (entityType === 'climate' || entityType === 'cover' || entityType === 'light') {
125
+ return false;
126
+ }
127
+ // 开关设备,如果路数 > 1,则需要选择按键
128
+ var channels = parseInt(device.channels) || 1;
129
+ return channels > 1;
130
+ }
131
+
132
+ // 获取设备类型标签
133
+ function getDeviceTypeLabel(device) {
134
+ if (!device) return '';
135
+ var entityType = device.entityType || '';
136
+ if (entityType === 'climate') return ' [温控器]';
137
+ if (entityType === 'cover') return ' [窗帘]';
138
+ if (entityType === 'light') return ' [灯具]';
139
+
140
+ var channels = parseInt(device.channels) || 1;
141
+ if (channels > 1) {
142
+ return ' [' + channels + '路开关]';
143
+ }
144
+ if (entityType === 'switch') return ' [单路开关]';
145
+ return ' [' + (entityType || '未知') + ']';
146
+ }
147
+
148
+ // 构建Symi设备选项
149
+ function getSymiOptions(selectedMac, savedName) {
150
+ var html = '<option value="">-- 选择Symi设备 --</option>';
151
+ var selMacNorm = (selectedMac || '').toLowerCase().replace(/:/g, '');
152
+ var found = false;
153
+
154
+ symiDevices.forEach(function(d) {
155
+ var devMacNorm = (d.macAddress || '').toLowerCase().replace(/:/g, '');
156
+ var selected = (devMacNorm === selMacNorm && selMacNorm !== '') ? ' selected' : '';
157
+ if (selected) found = true;
158
+ var statusIcon = d._online === false ? ' [离线]' : '';
159
+ var style = d._online === false ? ' style="color:#999;"' : '';
160
+ var typeLabel = getDeviceTypeLabel(d);
161
+ html += '<option value="' + d.macAddress + '" data-channels="' + (d.channels || 1) + '" data-name="' + (d.name || '') + '" data-devicetype="' + (d.deviceType || '') + '" data-entitytype="' + (d.entityType || '') + '"' + selected + style + '>' + (d.name || d.macAddress) + typeLabel + statusIcon + '</option>';
162
+ });
163
+
164
+ if (selMacNorm && !found) {
165
+ var displayName = savedName || selectedMac;
166
+ html += '<option value="' + selectedMac + '" selected style="color:#c00;">' + displayName + ' [未找到]</option>';
167
+ }
168
+ return html;
169
+ }
170
+
171
+ // 构建按键选项
172
+ function getKeyOptions(mac, selectedKey, savedChannels) {
173
+ var macNorm = (mac || '').toLowerCase().replace(/:/g, '');
174
+ var device = null;
175
+ symiDevices.forEach(function(d) {
176
+ if ((d.macAddress || '').toLowerCase().replace(/:/g, '') === macNorm) {
177
+ device = d;
178
+ }
179
+ });
180
+
181
+ if (!needsKeySelection(device)) return '';
182
+
183
+ var channels = device ? (device.channels || 1) : (savedChannels || 1);
184
+ var html = '<select class="symi-key">';
185
+ for (var i = 1; i <= channels; i++) {
186
+ var sel = (i == selectedKey) ? ' selected' : '';
187
+ html += '<option value="' + i + '"' + sel + '>按键' + i + '</option>';
188
+ }
189
+ html += '</select>';
190
+ return html;
191
+ }
192
+
193
+ // 构建HA实体选项
194
+ function getHaOptions(selectedEntityId, savedName) {
195
+ var html = '<option value="">-- 选择HA实体 --</option>';
196
+ var found = false;
197
+
198
+ haEntities.forEach(function(e) {
199
+ var selected = (e.entity_id === selectedEntityId) ? ' selected' : '';
200
+ if (selected) found = true;
201
+ var statusIcon = e._online === false ? ' [离线]' : '';
202
+ var style = e._online === false ? ' style="color:#999;"' : '';
203
+ html += '<option value="' + e.entity_id + '" data-name="' + (e.name || '') + '"' + selected + style + '>' + (e.name || e.entity_id) + ' (' + (e.type || 'unknown') + ')' + statusIcon + '</option>';
204
+ });
205
+
206
+ if (selectedEntityId && !found) {
207
+ var displayName = savedName || selectedEntityId;
208
+ html += '<option value="' + selectedEntityId + '" selected style="color:#c00;">' + displayName + ' [未找到]</option>';
209
+ }
210
+ return html;
211
+ }
212
+
213
+ // 渲染映射列表
214
+ function renderMappings() {
215
+ var container = $('#mapping-list');
216
+ container.empty();
217
+
218
+ if (!mappings || !Array.isArray(mappings) || mappings.length === 0) {
219
+ container.append('<div class="mapping-empty">暂无映射,点击下方按钮添加</div>');
220
+ return;
221
+ }
222
+
223
+ mappings.forEach(function(m, idx) {
224
+ try {
225
+ var row = $('<div class="mapping-row" data-idx="' + idx + '"></div>');
226
+ var symiOpts = getSymiOptions(m.symiMac, m.symiName);
227
+ var keyOpts = getKeyOptions(m.symiMac, m.symiKey || 1, m.symiChannels);
228
+ var haOpts = getHaOptions(m.haEntityId, m.haEntityName);
229
+
230
+ row.html(
231
+ '<div class="mapping-main">' +
232
+ '<div class="symi-col">' +
233
+ ' <select class="symi-select">' + symiOpts + '</select>' +
234
+ ' <span class="symi-key-wrap">' + keyOpts + '</span>' +
235
+ '</div>' +
236
+ '<div class="arrow-col"><i class="fa fa-arrows-h"></i></div>' +
237
+ '<div class="ha-col">' +
238
+ ' <select class="ha-select">' + haOpts + '</select>' +
239
+ '</div>' +
240
+ '<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>' +
241
+ '</div>'
242
+ );
243
+ container.append(row);
244
+ } catch (err) {
245
+ console.error('[symi-ha-sync] Render row error:', err, m);
246
+ }
247
+ });
248
+
249
+ bindEvents();
250
+ }
251
+
252
+ // 绑定事件
253
+ function bindEvents() {
254
+ var container = $('#mapping-list');
255
+
256
+ container.find('.symi-select').off('change').on('change', function() {
257
+ var row = $(this).closest('.mapping-row');
258
+ var idx = row.data('idx');
259
+ var mac = $(this).val();
260
+ var opt = $(this).find('option:selected');
261
+
262
+ mappings[idx].symiMac = mac || '';
263
+ mappings[idx].symiName = opt.data('name') || opt.text().replace(/ \[.*?\]/g, '');
264
+ mappings[idx].symiChannels = parseInt(opt.data('channels')) || 1;
265
+ mappings[idx].symiDeviceType = opt.data('devicetype') || '';
266
+ mappings[idx].symiEntityType = opt.data('entitytype') || '';
267
+ mappings[idx].symiKey = 1;
268
+
269
+ row.find('.symi-key-wrap').html(getKeyOptions(mac, 1, mappings[idx].symiChannels));
270
+ bindEvents();
271
+ });
272
+
273
+ container.find('.symi-key').off('change').on('change', function() {
274
+ var idx = $(this).closest('.mapping-row').data('idx');
275
+ mappings[idx].symiKey = parseInt($(this).val()) || 1;
276
+ });
277
+
278
+ container.find('.ha-select').off('change').on('change', function() {
279
+ var idx = $(this).closest('.mapping-row').data('idx');
280
+ var opt = $(this).find('option:selected');
281
+ mappings[idx].haEntityId = $(this).val();
282
+ mappings[idx].haEntityName = opt.data('name') || opt.text().split(' (')[0].replace(/ \[.*?\]/g, '');
283
+ });
284
+
285
+ container.find('.btn-remove').off('click').on('click', function() {
286
+ var idx = $(this).closest('.mapping-row').data('idx');
287
+ mappings.splice(idx, 1);
288
+ renderMappings();
289
+ });
290
+ }
291
+
292
+ // 刷新按钮
293
+ $('#refresh-symi-btn').on('click', function() {
294
+ var btn = $(this);
295
+ btn.prop('disabled', true).find('i').addClass('fa-spin');
296
+ loadSymiDevices(function() {
297
+ renderMappings();
298
+ btn.prop('disabled', false).find('i').removeClass('fa-spin');
299
+ });
300
+ });
301
+
302
+ $('#refresh-ha-btn').on('click', function() {
303
+ var btn = $(this);
304
+ btn.prop('disabled', true).find('i').addClass('fa-spin');
305
+ loadHaEntities(function() {
306
+ renderMappings();
307
+ btn.prop('disabled', false).find('i').removeClass('fa-spin');
308
+ });
309
+ });
310
+
311
+ // 添加映射按钮
312
+ $('#btn-add-mapping').on('click', function() {
313
+ mappings.push({
314
+ symiMac: '', symiName: '', symiKey: 1, symiChannels: 1, symiDeviceType: '',
315
+ haEntityId: '', haEntityName: ''
316
+ });
317
+ renderMappings();
318
+ });
319
+
320
+ // 配置变化时重新加载
321
+ $('#node-input-mqttConfig').on('change', function() {
322
+ setTimeout(function() {
323
+ loadSymiDevices(renderMappings);
324
+ }, 100);
325
+ });
326
+
327
+ $('#node-input-haServer').on('change', function() {
328
+ setTimeout(function() {
329
+ loadHaEntities(renderMappings);
330
+ }, 100);
331
+ });
332
+
333
+ // 保存时更新所有数据
334
+ node._saveAll = function() {
335
+ node.mappings = JSON.stringify(mappings);
336
+ node.cachedSymiDevices = JSON.stringify(cachedSymiDevices);
337
+ node.cachedHaEntities = JSON.stringify(cachedHaEntities);
338
+ $('#node-input-mappings').val(node.mappings);
339
+ $('#node-input-cachedSymiDevices').val(node.cachedSymiDevices);
340
+ $('#node-input-cachedHaEntities').val(node.cachedHaEntities);
341
+ };
342
+
343
+ // 初始加载
344
+ setTimeout(function() {
345
+ loadSymiDevices(function() {
346
+ loadHaEntities(renderMappings);
347
+ });
348
+ }, 300);
349
+ },
350
+ oneditresize: function(size) {
351
+ var rows = $('#dialog-form>div:not(.bridge-section)');
352
+ var height = size.height;
353
+ for (var i = 0; i < rows.length; i++) { height -= $(rows[i]).outerHeight(true); }
354
+ height -= 120;
355
+ $('#mapping-list').css('max-height', Math.max(150, height) + 'px');
356
+ },
357
+ oneditsave: function() {
358
+ if (this._saveAll) { this._saveAll(); }
359
+ }
360
+ });
361
+ </script>
362
+
363
+ <script type="text/html" data-template-name="symi-ha-sync">
364
+ <style>
365
+ .bridge-section { margin: 12px 0; padding: 10px; border: 1px solid #ddd; border-radius: 5px; background: #fafafa; }
366
+ .bridge-section h4 { margin: 0 0 10px 0; padding-bottom: 6px; border-bottom: 1px solid #eee; color: #333; font-size: 13px; }
367
+ .bridge-section h4 i { margin-right: 6px; color: #666; }
368
+ .section-btns { float: right; }
369
+ .section-btns button { margin-left: 4px; }
370
+
371
+ #mapping-list { max-height: 600px; min-height: 400px; overflow-y: auto; }
372
+ .mapping-empty { padding: 15px; text-align: center; color: #999; font-size: 12px; }
373
+ .mapping-row { display: flex; flex-direction: column; padding: 6px 8px; margin-bottom: 6px; background: #fff; border: 1px solid #e0e0e0; border-radius: 4px; gap: 6px; }
374
+ .mapping-main { display: flex; align-items: center; width: 100%; gap: 6px; min-width: 0; }
375
+ .symi-col { flex: 1 1 45%; min-width: 0; display: flex; gap: 4px; }
376
+ .symi-col .symi-select { flex: 1; padding: 4px; border: 1px solid #81c784; border-radius: 3px; background: #e8f5e9; font-size: 12px; }
377
+ .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 20px; text-align: center; color: #999; }
379
+ .ha-col { flex: 1 1 45%; }
380
+ .ha-col .ha-select { width: 100%; padding: 4px; border: 1px solid #41BDF5; border-radius: 3px; background: #e3f2fd; font-size: 12px; }
381
+ .del-col { flex: 0 0 auto; }
382
+ .btn-remove { color: #d32f2f !important; padding: 2px 6px !important; }
383
+ </style>
384
+
385
+ <div class="form-row">
386
+ <label for="node-input-name"><i class="fa fa-tag"></i> 名称</label>
387
+ <input type="text" id="node-input-name" placeholder="如:客厅HA同步">
388
+ </div>
389
+
390
+ <div class="bridge-section">
391
+ <h4><i class="fa fa-server"></i> 连接配置</h4>
392
+ <div class="form-row">
393
+ <label for="node-input-mqttConfig"><i class="fa fa-wifi"></i> MQTT配置</label>
394
+ <input type="text" id="node-input-mqttConfig">
395
+ </div>
396
+ <div class="form-row">
397
+ <label for="node-input-haServer"><i class="fa fa-home"></i> HA服务器</label>
398
+ <input type="text" id="node-input-haServer">
399
+ </div>
400
+ </div>
401
+
402
+ <div class="bridge-section">
403
+ <h4><i class="fa fa-exchange"></i> 实体映射(Symi ↔ HA)
404
+ <span class="section-btns">
405
+ <button type="button" id="refresh-symi-btn" class="red-ui-button red-ui-button-small" title="刷新Symi设备"><i class="fa fa-refresh"></i> Symi</button>
406
+ <button type="button" id="refresh-ha-btn" class="red-ui-button red-ui-button-small" title="刷新HA实体"><i class="fa fa-refresh"></i> HA</button>
407
+ </span>
408
+ </h4>
409
+ <div style="display:flex; padding:4px 8px; font-size:11px; color:#666; border-bottom:1px solid #eee; margin-bottom:6px; gap:6px;">
410
+ <span style="flex:1 1 45%">Symi设备/按键</span>
411
+ <span style="flex:0 0 20px"></span>
412
+ <span style="flex:1 1 45%">HA实体</span>
413
+ </div>
414
+ <div id="mapping-list"></div>
415
+ <button type="button" id="btn-add-mapping" class="red-ui-button" style="margin-top:8px; width:100%">
416
+ <i class="fa fa-plus"></i> 添加映射
417
+ </button>
418
+ </div>
419
+
420
+ <input type="hidden" id="node-input-mappings">
421
+ <input type="hidden" id="node-input-cachedSymiDevices">
422
+ <input type="hidden" id="node-input-cachedHaEntities">
423
+ </script>
424
+
425
+ <script type="text/html" data-help-name="symi-ha-sync">
426
+ <p>HA同步节点 - 实现Symi Mesh设备与Home Assistant实体的完美双向状态同步。</p>
427
+
428
+ <h3>功能特性</h3>
429
+ <ul>
430
+ <li><strong>完美双向同步</strong>:Symi↔HA实时状态同步</li>
431
+ <li><strong>多设备类型支持</strong>:开关、调光灯、窗帘、温控器/空调</li>
432
+ <li><strong>智能按键选择</strong>:只有多路开关才显示按键选择</li>
433
+ <li><strong>配置持久化</strong>:设备列表和映射配置持久保存</li>
434
+ <li><strong>防死循环</strong>:内置2秒防抖机制</li>
435
+ <li><strong>智能防抖</strong>:窗帘/调光灯只同步最终位置</li>
436
+ </ul>
437
+
438
+ <h3>支持的设备类型</h3>
439
+ <ul>
440
+ <li><strong>开关(多路)</strong>:开关状态,支持按键选择</li>
441
+ <li><strong>调光灯</strong>:开关 + 亮度 (0-100)</li>
442
+ <li><strong>窗帘</strong>:开/关/停 + 位置 (0-100%)</li>
443
+ <li><strong>温控器/空调</strong>:开关 + 温度 + 模式 + 风速</li>
444
+ </ul>
445
+
446
+ <h3>离线设备显示</h3>
447
+ <ul>
448
+ <li><strong>[离线]</strong>:设备在缓存中但当前不在线</li>
449
+ <li><strong>[未找到]</strong>:设备既不在线也不在缓存中</li>
450
+ </ul>
451
+ </script>