node-red-contrib-symi-mesh 1.6.8 → 1.7.0

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,370 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('symi-knx-ha-bridge', {
3
+ category: 'Symi Mesh',
4
+ color: '#41BDF5',
5
+ defaults: {
6
+ name: { value: '' },
7
+ haServer: { value: '', type: 'server' },
8
+ mappings: { value: '[]' },
9
+ knxEntities: { value: '[]' }
10
+ },
11
+ inputs: 1,
12
+ outputs: 2,
13
+ outputLabels: ['KNX输出', '调试信息'],
14
+ icon: 'bridge.svg',
15
+ label: function() { return this.name || 'KNX-HA桥接'; },
16
+ paletteLabel: 'KNX-HA桥接',
17
+ oneditprepare: function() {
18
+ const node = this;
19
+ let mappings = [], haEntities = [], knxEntities = [];
20
+ const typeLabels = {switch:'开关',light_mono:'单调',light_cct:'双调',light_rgb:'RGB',light_rgbcw:'RGBCW',cover:'窗帘',climate:'空调',fresh_air:'新风',floor_heating:'地暖'};
21
+
22
+ try { mappings = JSON.parse(node.mappings || '[]'); } catch(e) { mappings = []; }
23
+ try { knxEntities = JSON.parse(node.knxEntities || '[]'); } catch(e) { knxEntities = []; }
24
+
25
+ function loadHaEntities() {
26
+ const sid = $('#node-input-haServer').val();
27
+ if (!sid) {
28
+ haEntities = [];
29
+ renderMappings();
30
+ return;
31
+ }
32
+
33
+ console.log('[KNX-HA Bridge] 开始加载HA实体,服务器ID:', sid);
34
+
35
+ $.getJSON('symi-knx-ha-bridge/ha-entities/' + sid)
36
+ .done(function(data) {
37
+ console.log('[KNX-HA Bridge] 收到响应:', data);
38
+ haEntities = data || [];
39
+ renderMappings();
40
+ if (haEntities.length > 0) {
41
+ RED.notify('成功加载 ' + haEntities.length + ' 个HA实体', 'success');
42
+ } else {
43
+ RED.notify('HA服务器还未就绪,请稍后重试', 'warning');
44
+ }
45
+ })
46
+ .fail(function(err) {
47
+ console.error('[KNX-HA Bridge] 加载失败:', err);
48
+ haEntities = [];
49
+ renderMappings();
50
+ RED.notify('HA服务器还未就绪,请稍后重试', 'warning');
51
+ });
52
+ }
53
+
54
+ $('#download-tpl-btn').on('click', function() {
55
+ const tpl = `# KNX实体导入模板 (Tab分隔)
56
+ # 格式: 名称 类型 命令地址 状态地址 扩展1 扩展2 扩展3
57
+ # 类型: switch, light_mono, light_cct, light_rgb, light_rgbcw, cover, climate, fresh_air, floor_heating
58
+ #
59
+ # 开关 (命令, 状态)
60
+ 玄关射灯 switch 1/1/28 1/2/28
61
+ 客厅射灯 switch 1/1/25 1/2/25
62
+ # 单色调光 (开关, 状态, 亮度)
63
+ 卧室筒灯 light_mono 1/1/1 1/2/1 1/3/1
64
+ # 双色调光 (开关, 状态, 亮度, 色温)
65
+ 客厅吊灯 light_cct 1/1/10 1/2/10 1/3/10 1/4/10
66
+ # RGB (开关, 状态, 亮度, RGB)
67
+ 氛围灯 light_rgb 1/1/20 1/2/20 1/3/20 1/5/20
68
+ # RGBCW (开关, 状态, 亮度, 色温, RGB)
69
+ 主灯 light_rgbcw 1/1/30 1/2/30 1/3/30 1/4/30 1/5/30
70
+ # 窗帘 (上下, 位置, 停止)
71
+ 客厅布帘 cover 2/1/5 2/2/5 2/3/5
72
+ # 空调 (开关, 温度, 模式, 风速, 当前温度)
73
+ 主卧空调 climate 3/1/1 3/2/1 3/3/1 3/4/1 3/5/1
74
+ # 新风 (开关, 风速)
75
+ 全屋新风 fresh_air 4/1/1 4/2/1
76
+ # 地暖 (开关, 温度, 当前温度)
77
+ 客厅地暖 floor_heating 5/1/1 5/2/1 5/3/1`;
78
+ const blob = new Blob([tpl], {type:'text/plain;charset=utf-8'});
79
+ const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
80
+ a.download = 'knx-template.txt'; a.click();
81
+ });
82
+
83
+ function renderKnxEntities() {
84
+ const c = $('#knx-list'); c.empty(); $('#knx-cnt').text(knxEntities.length);
85
+ if (!knxEntities.length) { c.html('<div class="tips">点击"导入"或"添加"管理KNX实体</div>'); return; }
86
+ let h = '<table class="tbl"><tr><th>名称</th><th>类型</th><th>命令</th><th>状态</th><th>扩展</th><th style="width:60px">操作</th></tr>';
87
+ knxEntities.forEach((e,i) => {
88
+ const ext = [e.ext1,e.ext2,e.ext3].filter(x=>x).join(',');
89
+ const inv = e.type==='cover' ? '<input type="checkbox" class="e-inv" title="位置反转"'+(e.invert?' checked':'')+'>' : '';
90
+ h += '<tr data-ei="'+i+'"><td>'+e.name+'</td><td>'+(typeLabels[e.type]||e.type)+inv+'</td><td>'+e.cmdAddr+'</td><td>'+(e.statusAddr||'-')+'</td><td>'+(ext||'-')+'</td><td><button class="red-ui-button red-ui-button-small e-edit" title="编辑"><i class="fa fa-pencil"></i></button> <button class="red-ui-button red-ui-button-small e-del" title="删除"><i class="fa fa-times"></i></button></td></tr>';
91
+ });
92
+ c.html(h+'</table>');
93
+ $('.e-inv').off('change').on('change', function() {
94
+ const ei = $(this).closest('tr').data('ei');
95
+ knxEntities[ei].invert = $(this).is(':checked');
96
+ saveKnxData();
97
+ });
98
+ $('.e-edit').off('click').on('click', function() {
99
+ const ei = $(this).closest('tr').data('ei');
100
+ editKnxEntity(ei);
101
+ });
102
+ $('.e-del').off('click').on('click', function() {
103
+ const ei = $(this).closest('tr').data('ei');
104
+ knxEntities.splice(ei, 1);
105
+ renderKnxEntities(); renderMappings();
106
+ });
107
+ }
108
+
109
+ const typeFields = {
110
+ 'switch': ['命令地址*','状态地址'],
111
+ 'light_mono': ['开关地址*','状态地址','亮度地址'],
112
+ 'light_cct': ['开关地址*','状态地址','亮度地址','色温地址'],
113
+ 'light_rgb': ['开关地址*','状态地址','亮度地址','RGB地址'],
114
+ 'light_rgbcw': ['开关地址*','状态地址','亮度地址','色温地址','RGB地址'],
115
+ 'cover': ['上下地址*','位置地址','停止地址'],
116
+ 'climate': ['开关地址*','温度地址','模式地址','风速地址','当前温度'],
117
+ 'fresh_air': ['开关地址*','风速地址'],
118
+ 'floor_heating': ['开关地址*','温度地址','当前温度']
119
+ };
120
+
121
+ let editingIndex = -1;
122
+ function showEntityPanel(index) {
123
+ const isEdit = index >= 0;
124
+ const e = isEdit ? knxEntities[index] : {name:'',type:'switch',cmdAddr:'',statusAddr:'',ext1:'',ext2:'',ext3:'',invert:false};
125
+ editingIndex = index;
126
+ const typeOpts = Object.keys(typeLabels).map(t => '<option value="'+t+'"'+(t===e.type?' selected':'')+'>'+typeLabels[t]+'</option>').join('');
127
+ const title = isEdit ? '编辑: '+e.name : '添加KNX实体';
128
+ const btnText = isEdit ? '保存' : '添加';
129
+ $('#edit-panel').html(
130
+ '<div class="edit-form"><h4>'+title+'</h4>'+
131
+ '<div class="form-row"><label>名称*</label><input type="text" id="edit-name" value="'+(e.name||'')+'" placeholder="如: 客厅灯"></div>'+
132
+ '<div class="form-row"><label>类型</label><select id="edit-type">'+typeOpts+'</select></div>'+
133
+ '<div class="form-row" id="row-cmd"><label id="lbl-cmd">命令地址*</label><input type="text" id="edit-cmd" value="'+(e.cmdAddr||'')+'"></div>'+
134
+ '<div class="form-row" id="row-status"><label id="lbl-status">状态地址</label><input type="text" id="edit-status" value="'+(e.statusAddr||'')+'"></div>'+
135
+ '<div class="form-row" id="row-ext1"><label id="lbl-ext1">扩展1</label><input type="text" id="edit-ext1" value="'+(e.ext1||'')+'"></div>'+
136
+ '<div class="form-row" id="row-ext2"><label id="lbl-ext2">扩展2</label><input type="text" id="edit-ext2" value="'+(e.ext2||'')+'"></div>'+
137
+ '<div class="form-row" id="row-ext3"><label id="lbl-ext3">扩展3</label><input type="text" id="edit-ext3" value="'+(e.ext3||'')+'"></div>'+
138
+ '<div class="form-row" id="row-inv" style="display:none"><label>位置反转</label><input type="checkbox" id="edit-inv"'+(e.invert?' checked':'')+'></div>'+
139
+ '<div class="form-row"><button id="save-edit" class="red-ui-button red-ui-button-small">'+btnText+'</button> <button id="cancel-edit" class="red-ui-button red-ui-button-small">取消</button></div>'+
140
+ '</div>'
141
+ ).show();
142
+
143
+ function updateFieldLabels() {
144
+ const type = $('#edit-type').val();
145
+ const fields = typeFields[type] || ['命令地址*','状态地址'];
146
+ const rows = ['row-cmd','row-status','row-ext1','row-ext2','row-ext3'];
147
+ const labels = ['lbl-cmd','lbl-status','lbl-ext1','lbl-ext2','lbl-ext3'];
148
+ rows.forEach((row,i) => {
149
+ if (i < fields.length) {
150
+ $('#'+row).show();
151
+ $('#'+labels[i]).text(fields[i]);
152
+ } else {
153
+ $('#'+row).hide();
154
+ }
155
+ });
156
+ $('#row-inv').toggle(type === 'cover');
157
+ }
158
+ updateFieldLabels();
159
+ $('#edit-type').on('change', updateFieldLabels);
160
+
161
+ $('#save-edit').on('click', function() {
162
+ const name = $('#edit-name').val().trim();
163
+ const cmd = $('#edit-cmd').val().trim();
164
+ if (!name || !cmd) { RED.notify('请填写名称和命令地址', 'warning'); return; }
165
+ const entity = {
166
+ id: isEdit ? e.id : 'k'+Date.now()+Math.random().toString(36).substr(2,4),
167
+ name: name,
168
+ type: $('#edit-type').val(),
169
+ cmdAddr: cmd,
170
+ statusAddr: $('#edit-status').val().trim(),
171
+ ext1: $('#edit-ext1').val().trim(),
172
+ ext2: $('#edit-ext2').val().trim(),
173
+ ext3: $('#edit-ext3').val().trim(),
174
+ invert: $('#edit-inv').is(':checked')
175
+ };
176
+ if (isEdit) { knxEntities[editingIndex] = entity; }
177
+ else { knxEntities.push(entity); }
178
+ $('#edit-panel').hide().empty();
179
+ renderKnxEntities(); renderMappings();
180
+ RED.notify(isEdit?'已更新':'已添加', 'success');
181
+ });
182
+ $('#cancel-edit').on('click', function() { $('#edit-panel').hide().empty(); });
183
+ }
184
+ function editKnxEntity(index) { showEntityPanel(index); }
185
+
186
+ function renderMappings() {
187
+ const c = $('#map-list'); c.empty();
188
+ if (!mappings.length) { c.html('<div class="tips">点击"添加"创建映射</div>'); return; }
189
+ let h = '<table class="tbl"><tr><th style="width:24px">#</th><th style="width:45%">KNX实体</th><th style="width:45%">HA实体</th><th style="width:32px">删除</th></tr>';
190
+ mappings.forEach((m, i) => {
191
+ h += '<tr data-i="'+i+'"><td>'+(i+1)+'</td>';
192
+ h += '<td><select class="m-knx"><option value="">--选择KNX--</option>';
193
+ knxEntities.forEach(e => {
194
+ const inv = e.invert ? '↕' : '';
195
+ h += '<option value="'+e.id+'"'+(e.id===m.knxEntityId?' selected':'')+'>'+e.name+'['+e.cmdAddr+']('+inv+(typeLabels[e.type]||'')+')</option>';
196
+ });
197
+ h += '</select></td>';
198
+ h += '<td><input type="text" class="m-ha-input" placeholder="输入实体ID或名称搜索" value="'+(m.haEntityId||'')+'" list="ha-list-'+i+'" style="width:100%; font-size:11px">';
199
+ h += '<datalist id="ha-list-'+i+'">';
200
+ haEntities.forEach(e => {
201
+ h += '<option value="'+e.entity_id+'">'+e.name+'</option>';
202
+ });
203
+ h += '</datalist></td>';
204
+ h += '<td><button class="red-ui-button red-ui-button-small m-del" title="删除"><i class="fa fa-times"></i></button></td></tr>';
205
+ });
206
+ c.html(h+'</table>');
207
+ bindEvents();
208
+ }
209
+
210
+ function bindEvents() {
211
+ $('.m-knx').off('change').on('change', function() {
212
+ const i = $(this).closest('tr').data('i');
213
+ mappings[i].knxEntityId = $(this).val();
214
+ });
215
+ $('.m-ha-input').off('input change').on('input change', function() {
216
+ const i = $(this).closest('tr').data('i');
217
+ mappings[i].haEntityId = $(this).val();
218
+ });
219
+ $('.m-del').off('click').on('click', function() {
220
+ mappings.splice($(this).closest('tr').data('i'), 1);
221
+ renderMappings();
222
+ });
223
+ }
224
+
225
+ $('#add-map-btn').on('click', function() {
226
+ mappings.push({ knxEntityId:'', haEntityId:'' });
227
+ renderMappings();
228
+ });
229
+ $('#clear-map-btn').on('click', function() { if(confirm('清空映射?')) { mappings=[]; renderMappings(); } });
230
+
231
+ $('#import-btn').on('click', function() { $('#import-modal').show(); });
232
+ $('#import-cancel').on('click', function() { $('#import-modal').hide(); });
233
+ $('#import-confirm').on('click', function() {
234
+ const text = $('#import-input').val().trim();
235
+ if (!text) { $('#import-modal').hide(); return; }
236
+ let cnt = 0;
237
+ text.split('\n').forEach(line => {
238
+ line = line.trim();
239
+ if (!line || line.startsWith('#')) return;
240
+ const p = line.split(/\t+/);
241
+ if (p.length >= 2 && p[1] && /\d+\/\d+\/\d+/.test(p[2]||'')) {
242
+ const id = 'k' + Date.now() + Math.random().toString(36).substr(2,4);
243
+ knxEntities.push({ id, name:p[0].trim(), type:p[1].trim(), cmdAddr:p[2].trim(), statusAddr:(p[3]||'').trim(), ext1:(p[4]||'').trim(), ext2:(p[5]||'').trim(), ext3:(p[6]||'').trim(), invert:false });
244
+ cnt++;
245
+ }
246
+ });
247
+ $('#import-modal').hide(); $('#import-input').val('');
248
+ renderKnxEntities(); renderMappings();
249
+ RED.notify('导入 '+cnt+' 个实体'+(cnt?'':'(需要有效组地址格式如1/2/3)'), cnt?'success':'warning');
250
+ });
251
+ $('#add-knx-btn').on('click', function() { showEntityPanel(-1); });
252
+ $('#clear-knx-btn').on('click', function() { if(confirm('清空KNX实体?')) { knxEntities=[]; renderKnxEntities(); renderMappings(); } });
253
+
254
+ function saveKnxData() { $('#knx-data').val(JSON.stringify(knxEntities)); }
255
+
256
+ const origRender = renderKnxEntities;
257
+ renderKnxEntities = function() { origRender(); saveKnxData(); };
258
+
259
+ $('#node-input-haServer').on('change', function() {
260
+ setTimeout(loadHaEntities, 2000);
261
+ });
262
+ $('#reload-ha-btn').on('click', function() {
263
+ $(this).prop('disabled', true).text('加载中...');
264
+ setTimeout(function() {
265
+ loadHaEntities();
266
+ $('#reload-ha-btn').prop('disabled', false).text('刷新');
267
+ }, 100);
268
+ });
269
+
270
+ setTimeout(function() {
271
+ renderKnxEntities();
272
+ if ($('#node-input-haServer').val()) {
273
+ setTimeout(loadHaEntities, 2000);
274
+ }
275
+ }, 100);
276
+ },
277
+ oneditsave: function() {
278
+ const maps = [];
279
+ $('#map-list tr[data-i]').each(function() {
280
+ const m = { knxEntityId: $(this).find('.m-knx').val(), haEntityId: $(this).find('.m-ha-input').val() };
281
+ if (m.knxEntityId && m.haEntityId) maps.push(m);
282
+ });
283
+ this.mappings = JSON.stringify(maps);
284
+ this.knxEntities = $('#knx-data').val() || '[]';
285
+ }
286
+ });
287
+ </script>
288
+
289
+ <script type="text/html" data-template-name="symi-knx-ha-bridge">
290
+ <style>
291
+ #dialog-form { min-width: 1000px; }
292
+ .tbl { width:100%; border-collapse:collapse; font-size:11px; }
293
+ .tbl th, .tbl td { padding:3px 5px; border:1px solid #ddd; }
294
+ .tbl th { background:#f0f0f0; }
295
+ .tbl select { width:100%; font-size:11px; padding:2px; }
296
+ .tbl input[type="checkbox"] { margin:0; }
297
+ #knx-list { max-height:180px; overflow-y:auto; border:1px solid #ccc; margin:5px 0; padding:3px; }
298
+ #map-list { max-height:400px; overflow-y:auto; border:1px solid #ccc; margin:5px 0; padding:3px; }
299
+ .tips { color:#666; padding:8px; text-align:center; font-size:12px; }
300
+ .sec { display:flex; justify-content:space-between; align-items:center; margin:10px 0 4px; padding-bottom:4px; border-bottom:1px solid #ddd; }
301
+ .sec b { font-size:12px; }
302
+ .btns button { margin-left:4px; }
303
+ .modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:99999; }
304
+ .modal-box { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); background:#fff; padding:15px; border-radius:5px; box-shadow:0 4px 20px rgba(0,0,0,0.3); z-index:100000; }
305
+ #import-modal .modal-box { width:650px; }
306
+ #import-input { width:100%; height:160px; font-family:monospace; font-size:10px; }
307
+ .modal-box h4 { margin:0 0 10px; }
308
+ .modal-box .mbtns { text-align:right; margin-top:10px; }
309
+ .info { background:#f8f8e8; border:1px solid #e0e0c0; padding:5px 8px; margin:6px 0; font-size:11px; border-radius:3px; }
310
+ .edit-form h4 { margin:0 0 10px; color:#333; }
311
+ .edit-form .form-row { margin-bottom:8px; display:flex; align-items:center; }
312
+ .edit-form .form-row label { width:70px; font-size:12px; }
313
+ .edit-form .form-row input, .edit-form .form-row select { flex:1; padding:4px; font-size:12px; }
314
+ datalist { max-height: 300px !important; }
315
+ input[list]::-webkit-calendar-picker-indicator { display: block; }
316
+ </style>
317
+
318
+ <div class="form-row">
319
+ <label for="node-input-name"><i class="fa fa-tag"></i> 名称</label>
320
+ <input type="text" id="node-input-name" placeholder="KNX-HA桥接">
321
+ </div>
322
+ <div class="form-row">
323
+ <label for="node-input-haServer"><i class="fa fa-home"></i> HA服务器</label>
324
+ <input type="text" id="node-input-haServer" style="width:calc(100% - 180px)">
325
+ <button type="button" id="reload-ha-btn" class="red-ui-button red-ui-button-small" style="margin-left:5px">刷新</button>
326
+ </div>
327
+
328
+ <div class="info"><b>连接:</b> <code>[knxUltimate-in] → [KNX-HA桥接] → [knxUltimate-out]</code></div>
329
+
330
+ <div class="sec">
331
+ <b><i class="fa fa-database"></i> KNX实体库 (<span id="knx-cnt">0</span>)</b>
332
+ <span class="btns">
333
+ <button type="button" class="red-ui-button red-ui-button-small" id="download-tpl-btn"><i class="fa fa-download"></i> 模板</button>
334
+ <button type="button" class="red-ui-button red-ui-button-small" id="import-btn"><i class="fa fa-upload"></i> 导入</button>
335
+ <button type="button" class="red-ui-button red-ui-button-small" id="add-knx-btn"><i class="fa fa-plus"></i> 添加</button>
336
+ <button type="button" class="red-ui-button red-ui-button-small" id="clear-knx-btn"><i class="fa fa-trash"></i></button>
337
+ </span>
338
+ </div>
339
+ <div id="knx-list"><div class="tips">点击"导入"或"添加"管理KNX实体</div></div>
340
+ <div id="edit-panel" style="display:none;background:#fffde7;border:1px solid #ffc107;padding:10px;margin:5px 0;border-radius:4px;"></div>
341
+ <input type="hidden" id="knx-data">
342
+
343
+ <div class="sec">
344
+ <b><i class="fa fa-exchange"></i> 实体映射</b>
345
+ <span class="btns">
346
+ <button type="button" class="red-ui-button red-ui-button-small" id="add-map-btn"><i class="fa fa-plus"></i> 添加</button>
347
+ <button type="button" class="red-ui-button red-ui-button-small" id="clear-map-btn"><i class="fa fa-trash"></i> 清空</button>
348
+ </span>
349
+ </div>
350
+ <div id="map-list"><div class="tips">添加KNX↔HA映射</div></div>
351
+
352
+ <div id="import-modal" class="modal-overlay">
353
+ <div class="modal-box">
354
+ <h4>导入KNX实体</h4>
355
+ <p style="font-size:11px;margin:0 0 8px">Tab分隔: <code>名称 类型 命令地址 状态地址 扩展...</code>(无效行自动忽略)</p>
356
+ <textarea id="import-input" placeholder="玄关射灯 switch 1/1/28 1/2/28
357
+ 客厅吊灯 light_cct 1/1/10 1/2/10 1/3/10 1/4/10
358
+ 客厅布帘 cover 2/1/5 2/2/5 2/3/5"></textarea>
359
+ <div class="mbtns">
360
+ <button type="button" class="red-ui-button" id="import-cancel">取消</button>
361
+ <button type="button" class="red-ui-button red-ui-button-primary" id="import-confirm">导入</button>
362
+ </div>
363
+ </div>
364
+ </div>
365
+
366
+ </script>
367
+
368
+ <script type="text/html" data-help-name="symi-knx-ha-bridge">
369
+ <p>KNX与Home Assistant实体双向同步桥接</p>
370
+ </script>