node-red-contrib-symi-mesh 1.6.6 → 1.6.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.
- package/README.md +233 -429
- package/examples/knx-sync-example.json +48 -410
- package/lib/device-manager.js +11 -3
- package/lib/tcp-client.js +6 -2
- package/nodes/symi-485-bridge.js +26 -27
- package/nodes/symi-485-config.js +38 -3
- package/nodes/symi-gateway.js +27 -20
- package/nodes/symi-knx-bridge.html +368 -0
- package/nodes/symi-knx-bridge.js +1110 -0
- package/nodes/symi-mqtt.js +52 -10
- package/package.json +4 -3
package/nodes/symi-485-config.js
CHANGED
|
@@ -6,6 +6,37 @@
|
|
|
6
6
|
const { SerialPort } = require('serialport');
|
|
7
7
|
const net = require('net');
|
|
8
8
|
|
|
9
|
+
// 全局禁用 Happy Eyeballs 算法,防止 AggregateError 导致 Node-RED 崩溃
|
|
10
|
+
// 这会影响所有使用 net.Socket 的模块(包括第三方模块如 KNX Ultimate)
|
|
11
|
+
if (typeof net.setDefaultAutoSelectFamily === 'function') {
|
|
12
|
+
net.setDefaultAutoSelectFamily(false);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 全局未捕获异常处理 - 防止网络错误导致 Node-RED 崩溃
|
|
16
|
+
if (!global._symiErrorHandlerInstalled) {
|
|
17
|
+
global._symiErrorHandlerInstalled = true;
|
|
18
|
+
|
|
19
|
+
process.on('uncaughtException', (err) => {
|
|
20
|
+
// 网络相关错误不崩溃
|
|
21
|
+
const netErrors = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENETUNREACH', 'EHOSTUNREACH', 'ENOTFOUND'];
|
|
22
|
+
if (err && err.code && netErrors.includes(err.code)) {
|
|
23
|
+
console.error('[symi] 网络错误已捕获,继续运行:', err.message);
|
|
24
|
+
return; // 不崩溃
|
|
25
|
+
}
|
|
26
|
+
// AggregateError
|
|
27
|
+
if (err && (err.name === 'AggregateError' || (err.errors && Array.isArray(err.errors)))) {
|
|
28
|
+
console.error('[symi] AggregateError已捕获:', err.message);
|
|
29
|
+
return; // 不崩溃
|
|
30
|
+
}
|
|
31
|
+
// 其他错误打印但不崩溃(保护Node-RED)
|
|
32
|
+
console.error('[symi] 未捕获异常:', err);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
process.on('unhandledRejection', (reason) => {
|
|
36
|
+
console.error('[symi] Promise rejection:', reason?.message || reason);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
9
40
|
module.exports = function(RED) {
|
|
10
41
|
|
|
11
42
|
function SymiRS485ConfigNode(config) {
|
|
@@ -114,7 +145,7 @@ module.exports = function(RED) {
|
|
|
114
145
|
|
|
115
146
|
node.client.on('connect', () => {
|
|
116
147
|
node.connected = true;
|
|
117
|
-
node.
|
|
148
|
+
node.log(`[RS485] TCP已连接: ${node.host}:${node.port}`);
|
|
118
149
|
node.emit('connected');
|
|
119
150
|
});
|
|
120
151
|
|
|
@@ -151,9 +182,13 @@ module.exports = function(RED) {
|
|
|
151
182
|
}
|
|
152
183
|
});
|
|
153
184
|
|
|
154
|
-
// 使用
|
|
185
|
+
// 使用family:4强制IPv4,避免Node.js 18+ Happy Eyeballs导致AggregateError
|
|
155
186
|
try {
|
|
156
|
-
node.client.connect(
|
|
187
|
+
node.client.connect({
|
|
188
|
+
port: node.port,
|
|
189
|
+
host: node.host,
|
|
190
|
+
family: 4 // 强制IPv4,避免IPv6连接失败导致AggregateError
|
|
191
|
+
});
|
|
157
192
|
} catch (connectErr) {
|
|
158
193
|
node.error(`RS485 TCP连接异常: ${connectErr.message}`);
|
|
159
194
|
}
|
package/nodes/symi-gateway.js
CHANGED
|
@@ -2,36 +2,36 @@
|
|
|
2
2
|
* Symi Gateway Configuration Node
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
const net = require('net');
|
|
5
6
|
const TCPClient = require('../lib/tcp-client');
|
|
6
7
|
const SerialClient = require('../lib/serial-client');
|
|
7
8
|
const { DeviceManager } = require('../lib/device-manager');
|
|
8
9
|
const { ProtocolHandler, parseStatusEvent, OP_RESP_DEVICE_LIST } = require('../lib/protocol');
|
|
9
10
|
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
// 全局禁用 Happy Eyeballs 算法
|
|
12
|
+
if (typeof net.setDefaultAutoSelectFamily === 'function') {
|
|
13
|
+
net.setDefaultAutoSelectFamily(false);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 全局未捕获异常处理 - 防止网络错误导致 Node-RED 崩溃
|
|
17
|
+
if (!global._symiErrorHandlerInstalled) {
|
|
18
|
+
global._symiErrorHandlerInstalled = true;
|
|
14
19
|
|
|
15
20
|
process.on('uncaughtException', (err) => {
|
|
16
|
-
|
|
17
|
-
if (err.
|
|
18
|
-
(
|
|
19
|
-
|
|
20
|
-
// 不重新抛出,防止崩溃
|
|
21
|
-
} else {
|
|
22
|
-
// 非网络错误,继续原有行为
|
|
23
|
-
throw err;
|
|
21
|
+
const netErrors = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENETUNREACH', 'EHOSTUNREACH', 'ENOTFOUND', 'EADDRNOTAVAIL'];
|
|
22
|
+
if (err && err.code && netErrors.includes(err.code)) {
|
|
23
|
+
console.error('[symi] 网络错误已捕获,继续运行:', err.message);
|
|
24
|
+
return;
|
|
24
25
|
}
|
|
26
|
+
if (err && (err.name === 'AggregateError' || (err.errors && Array.isArray(err.errors)))) {
|
|
27
|
+
console.error('[symi] AggregateError已捕获:', err.message);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
console.error('[symi] 未捕获异常:', err);
|
|
25
31
|
});
|
|
26
32
|
|
|
27
|
-
process.on('unhandledRejection', (reason
|
|
28
|
-
|
|
29
|
-
if (reason && (reason.name === 'AggregateError' ||
|
|
30
|
-
(reason.code && ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENETUNREACH', 'EHOSTUNREACH'].includes(reason.code)))) {
|
|
31
|
-
console.error('[symi-gateway] 网络Promise rejection已捕获:', reason.message || reason);
|
|
32
|
-
// 不重新抛出,防止崩溃
|
|
33
|
-
}
|
|
34
|
-
// 其他rejection由Node-RED默认处理
|
|
33
|
+
process.on('unhandledRejection', (reason) => {
|
|
34
|
+
console.error('[symi] Promise rejection:', reason?.message || reason);
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -217,6 +217,13 @@ module.exports = function(RED) {
|
|
|
217
217
|
Buffer.from([frame.checksum])
|
|
218
218
|
]).toString('hex').toUpperCase();
|
|
219
219
|
this.log(`[场景执行] 收到场景执行通知事件: 场景ID=${sceneId}, 设备地址=0x${event.networkAddress.toString(16).toUpperCase()}, 原始帧=${frameHex}`);
|
|
220
|
+
|
|
221
|
+
// 发出场景执行事件,让桥接节点知道需要查询设备状态
|
|
222
|
+
this.emit('scene-executed', {
|
|
223
|
+
sceneId: sceneId,
|
|
224
|
+
triggerAddress: event.networkAddress,
|
|
225
|
+
timestamp: Date.now()
|
|
226
|
+
});
|
|
220
227
|
continue;
|
|
221
228
|
}
|
|
222
229
|
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('symi-knx-bridge', {
|
|
3
|
+
category: 'Symi Mesh',
|
|
4
|
+
color: '#88AA55',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
gateway: { value: '', type: 'symi-gateway', required: true },
|
|
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 Bridge'; },
|
|
16
|
+
paletteLabel: 'KNX桥接',
|
|
17
|
+
oneditprepare: function() {
|
|
18
|
+
const node = this;
|
|
19
|
+
let mappings = [], devices = [], 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 loadDevices() {
|
|
26
|
+
const gid = $('#node-input-gateway').val();
|
|
27
|
+
if (!gid) { devices = []; renderMappings(); return; }
|
|
28
|
+
$.getJSON('symi-knx-bridge/devices/' + gid, function(data) {
|
|
29
|
+
devices = data || [];
|
|
30
|
+
renderMappings();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 下载模板
|
|
35
|
+
$('#download-tpl-btn').on('click', function() {
|
|
36
|
+
const tpl = `# KNX实体导入模板 (Tab分隔)
|
|
37
|
+
# 格式: 名称 类型 命令地址 状态地址 扩展1 扩展2 扩展3
|
|
38
|
+
# 类型: switch, light_mono, light_cct, light_rgb, light_rgbcw, cover, climate, fresh_air, floor_heating
|
|
39
|
+
#
|
|
40
|
+
# 开关 (命令, 状态)
|
|
41
|
+
玄关射灯 switch 1/1/28 1/2/28
|
|
42
|
+
客厅射灯 switch 1/1/25 1/2/25
|
|
43
|
+
# 单色调光 (开关, 状态, 亮度)
|
|
44
|
+
卧室筒灯 light_mono 1/1/1 1/2/1 1/3/1
|
|
45
|
+
# 双色调光 (开关, 状态, 亮度, 色温)
|
|
46
|
+
客厅吊灯 light_cct 1/1/10 1/2/10 1/3/10 1/4/10
|
|
47
|
+
# RGB (开关, 状态, 亮度, RGB)
|
|
48
|
+
氛围灯 light_rgb 1/1/20 1/2/20 1/3/20 1/5/20
|
|
49
|
+
# RGBCW (开关, 状态, 亮度, 色温, RGB)
|
|
50
|
+
主灯 light_rgbcw 1/1/30 1/2/30 1/3/30 1/4/30 1/5/30
|
|
51
|
+
# 窗帘 (上下, 位置, 停止)
|
|
52
|
+
客厅布帘 cover 2/1/5 2/2/5 2/3/5
|
|
53
|
+
# 空调 (开关, 温度, 模式, 风速, 当前温度)
|
|
54
|
+
主卧空调 climate 3/1/1 3/2/1 3/3/1 3/4/1 3/5/1
|
|
55
|
+
# 新风 (开关, 风速)
|
|
56
|
+
全屋新风 fresh_air 4/1/1 4/2/1
|
|
57
|
+
# 地暖 (开关, 温度, 当前温度)
|
|
58
|
+
客厅地暖 floor_heating 5/1/1 5/2/1 5/3/1`;
|
|
59
|
+
const blob = new Blob([tpl], {type:'text/plain;charset=utf-8'});
|
|
60
|
+
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
|
61
|
+
a.download = 'knx-template.txt'; a.click();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// 渲染KNX实体表格
|
|
65
|
+
function renderKnxEntities() {
|
|
66
|
+
const c = $('#knx-list'); c.empty(); $('#knx-cnt').text(knxEntities.length);
|
|
67
|
+
if (!knxEntities.length) { c.html('<div class="tips">点击"导入"或"添加"管理KNX实体</div>'); return; }
|
|
68
|
+
let h = '<table class="tbl"><tr><th>名称</th><th>类型</th><th>命令</th><th>状态</th><th>扩展</th><th style="width:60px">操作</th></tr>';
|
|
69
|
+
knxEntities.forEach((e,i) => {
|
|
70
|
+
const ext = [e.ext1,e.ext2,e.ext3].filter(x=>x).join(',');
|
|
71
|
+
const inv = e.type==='cover' ? '<input type="checkbox" class="e-inv" title="位置反转"'+(e.invert?' checked':'')+'>' : '';
|
|
72
|
+
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>';
|
|
73
|
+
});
|
|
74
|
+
c.html(h+'</table>');
|
|
75
|
+
// 绑定实体事件
|
|
76
|
+
$('.e-inv').off('change').on('change', function() {
|
|
77
|
+
const ei = $(this).closest('tr').data('ei');
|
|
78
|
+
knxEntities[ei].invert = $(this).is(':checked');
|
|
79
|
+
saveKnxData();
|
|
80
|
+
});
|
|
81
|
+
$('.e-edit').off('click').on('click', function() {
|
|
82
|
+
const ei = $(this).closest('tr').data('ei');
|
|
83
|
+
editKnxEntity(ei);
|
|
84
|
+
});
|
|
85
|
+
$('.e-del').off('click').on('click', function() {
|
|
86
|
+
const ei = $(this).closest('tr').data('ei');
|
|
87
|
+
knxEntities.splice(ei, 1);
|
|
88
|
+
renderKnxEntities(); renderMappings();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 设备类型对应的字段配置
|
|
93
|
+
const typeFields = {
|
|
94
|
+
'switch': ['命令地址*','状态地址'],
|
|
95
|
+
'light_mono': ['开关地址*','状态地址','亮度地址'],
|
|
96
|
+
'light_cct': ['开关地址*','状态地址','亮度地址','色温地址'],
|
|
97
|
+
'light_rgb': ['开关地址*','状态地址','亮度地址','RGB地址'],
|
|
98
|
+
'light_rgbcw': ['开关地址*','状态地址','亮度地址','色温地址','RGB地址'],
|
|
99
|
+
'cover': ['上下地址*','位置地址','停止地址'],
|
|
100
|
+
'climate': ['开关地址*','温度地址','模式地址','风速地址','当前温度'],
|
|
101
|
+
'fresh_air': ['开关地址*','风速地址'],
|
|
102
|
+
'floor_heating': ['开关地址*','温度地址','当前温度']
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// 统一的添加/编辑KNX实体面板
|
|
106
|
+
let editingIndex = -1;
|
|
107
|
+
function showEntityPanel(index) {
|
|
108
|
+
const isEdit = index >= 0;
|
|
109
|
+
const e = isEdit ? knxEntities[index] : {name:'',type:'switch',cmdAddr:'',statusAddr:'',ext1:'',ext2:'',ext3:'',invert:false};
|
|
110
|
+
editingIndex = index;
|
|
111
|
+
const typeOpts = Object.keys(typeLabels).map(t => '<option value="'+t+'"'+(t===e.type?' selected':'')+'>'+typeLabels[t]+'</option>').join('');
|
|
112
|
+
const title = isEdit ? '编辑: '+e.name : '添加KNX实体';
|
|
113
|
+
const btnText = isEdit ? '保存' : '添加';
|
|
114
|
+
$('#edit-panel').html(
|
|
115
|
+
'<div class="edit-form"><h4>'+title+'</h4>'+
|
|
116
|
+
'<div class="form-row"><label>名称*</label><input type="text" id="edit-name" value="'+(e.name||'')+'" placeholder="如: 客厅灯"></div>'+
|
|
117
|
+
'<div class="form-row"><label>类型</label><select id="edit-type">'+typeOpts+'</select></div>'+
|
|
118
|
+
'<div class="form-row" id="row-cmd"><label id="lbl-cmd">命令地址*</label><input type="text" id="edit-cmd" value="'+(e.cmdAddr||'')+'"></div>'+
|
|
119
|
+
'<div class="form-row" id="row-status"><label id="lbl-status">状态地址</label><input type="text" id="edit-status" value="'+(e.statusAddr||'')+'"></div>'+
|
|
120
|
+
'<div class="form-row" id="row-ext1"><label id="lbl-ext1">扩展1</label><input type="text" id="edit-ext1" value="'+(e.ext1||'')+'"></div>'+
|
|
121
|
+
'<div class="form-row" id="row-ext2"><label id="lbl-ext2">扩展2</label><input type="text" id="edit-ext2" value="'+(e.ext2||'')+'"></div>'+
|
|
122
|
+
'<div class="form-row" id="row-ext3"><label id="lbl-ext3">扩展3</label><input type="text" id="edit-ext3" value="'+(e.ext3||'')+'"></div>'+
|
|
123
|
+
'<div class="form-row" id="row-inv" style="display:none"><label>位置反转</label><input type="checkbox" id="edit-inv"'+(e.invert?' checked':'')+'></div>'+
|
|
124
|
+
'<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>'+
|
|
125
|
+
'</div>'
|
|
126
|
+
).show();
|
|
127
|
+
|
|
128
|
+
function updateFieldLabels() {
|
|
129
|
+
const type = $('#edit-type').val();
|
|
130
|
+
const fields = typeFields[type] || ['命令地址*','状态地址'];
|
|
131
|
+
const rows = ['row-cmd','row-status','row-ext1','row-ext2','row-ext3'];
|
|
132
|
+
const labels = ['lbl-cmd','lbl-status','lbl-ext1','lbl-ext2','lbl-ext3'];
|
|
133
|
+
rows.forEach((row,i) => {
|
|
134
|
+
if (i < fields.length) {
|
|
135
|
+
$('#'+row).show();
|
|
136
|
+
$('#'+labels[i]).text(fields[i]);
|
|
137
|
+
} else {
|
|
138
|
+
$('#'+row).hide();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
$('#row-inv').toggle(type === 'cover');
|
|
142
|
+
}
|
|
143
|
+
updateFieldLabels();
|
|
144
|
+
$('#edit-type').on('change', updateFieldLabels);
|
|
145
|
+
|
|
146
|
+
$('#save-edit').on('click', function() {
|
|
147
|
+
const name = $('#edit-name').val().trim();
|
|
148
|
+
const cmd = $('#edit-cmd').val().trim();
|
|
149
|
+
if (!name || !cmd) { RED.notify('请填写名称和命令地址', 'warning'); return; }
|
|
150
|
+
const entity = {
|
|
151
|
+
id: isEdit ? e.id : 'k'+Date.now()+Math.random().toString(36).substr(2,4),
|
|
152
|
+
name: name,
|
|
153
|
+
type: $('#edit-type').val(),
|
|
154
|
+
cmdAddr: cmd,
|
|
155
|
+
statusAddr: $('#edit-status').val().trim(),
|
|
156
|
+
ext1: $('#edit-ext1').val().trim(),
|
|
157
|
+
ext2: $('#edit-ext2').val().trim(),
|
|
158
|
+
ext3: $('#edit-ext3').val().trim(),
|
|
159
|
+
invert: $('#edit-inv').is(':checked')
|
|
160
|
+
};
|
|
161
|
+
if (isEdit) { knxEntities[editingIndex] = entity; }
|
|
162
|
+
else { knxEntities.push(entity); }
|
|
163
|
+
$('#edit-panel').hide().empty();
|
|
164
|
+
renderKnxEntities(); renderMappings();
|
|
165
|
+
RED.notify(isEdit?'已更新':'已添加', 'success');
|
|
166
|
+
});
|
|
167
|
+
$('#cancel-edit').on('click', function() { $('#edit-panel').hide().empty(); });
|
|
168
|
+
}
|
|
169
|
+
function editKnxEntity(index) { showEntityPanel(index); }
|
|
170
|
+
|
|
171
|
+
// 渲染映射表格 - 一行显示
|
|
172
|
+
function renderMappings() {
|
|
173
|
+
const c = $('#map-list'); c.empty();
|
|
174
|
+
if (!mappings.length) { c.html('<div class="tips">点击"添加"创建映射</div>'); return; }
|
|
175
|
+
let h = '<table class="tbl"><tr><th style="width:24px">#</th><th style="width:38%">Mesh设备</th><th style="width:45px">通道</th><th style="width:45%">KNX实体</th><th style="width:32px">删除</th></tr>';
|
|
176
|
+
mappings.forEach((m, i) => {
|
|
177
|
+
const dev = devices.find(d => d.mac === m.meshMac);
|
|
178
|
+
h += '<tr data-i="'+i+'"><td>'+(i+1)+'</td>';
|
|
179
|
+
// Mesh设备选择
|
|
180
|
+
h += '<td><select class="m-dev"><option value="">--选择Mesh--</option>';
|
|
181
|
+
devices.forEach(d => {
|
|
182
|
+
const tl = d.type==='cover'?'窗帘':d.type==='switch'?d.channels+'键':d.type==='light'?'灯':d.type==='climate'?'温控':d.type;
|
|
183
|
+
h += '<option value="'+d.mac+'" data-ch="'+d.channels+'" data-t="'+d.type+'"'+(d.mac===m.meshMac?' selected':'')+'>'+d.name+'['+tl+']</option>';
|
|
184
|
+
});
|
|
185
|
+
h += '</select></td>';
|
|
186
|
+
// 通道
|
|
187
|
+
h += '<td><select class="m-ch">';
|
|
188
|
+
if (dev && dev.type === 'switch') {
|
|
189
|
+
for(let j=1; j<=dev.channels; j++) h += '<option value="'+j+'"'+(j===m.meshChannel?' selected':'')+'>'+j+'</option>';
|
|
190
|
+
} else { h += '<option value="1">-</option>'; }
|
|
191
|
+
h += '</select></td>';
|
|
192
|
+
// KNX实体选择
|
|
193
|
+
h += '<td><select class="m-knx"><option value="">--选择KNX--</option>';
|
|
194
|
+
knxEntities.forEach(e => {
|
|
195
|
+
const inv = e.invert ? '↕' : '';
|
|
196
|
+
h += '<option value="'+e.id+'"'+(e.id===m.knxEntityId?' selected':'')+'>'+e.name+'['+e.cmdAddr+']('+inv+(typeLabels[e.type]||'')+')</option>';
|
|
197
|
+
});
|
|
198
|
+
h += '</select></td>';
|
|
199
|
+
h += '<td><button class="red-ui-button red-ui-button-small m-del" title="删除"><i class="fa fa-times"></i></button></td></tr>';
|
|
200
|
+
});
|
|
201
|
+
c.html(h+'</table>');
|
|
202
|
+
bindEvents();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function bindEvents() {
|
|
206
|
+
$('.m-dev').off('change').on('change', function() {
|
|
207
|
+
const i = $(this).closest('tr').data('i');
|
|
208
|
+
mappings[i].meshMac = $(this).val();
|
|
209
|
+
mappings[i].meshChannel = 1;
|
|
210
|
+
renderMappings();
|
|
211
|
+
});
|
|
212
|
+
$('.m-ch').off('change').on('change', function() {
|
|
213
|
+
const i = $(this).closest('tr').data('i');
|
|
214
|
+
mappings[i].meshChannel = parseInt($(this).val()) || 1;
|
|
215
|
+
});
|
|
216
|
+
$('.m-knx').off('change').on('change', function() {
|
|
217
|
+
const i = $(this).closest('tr').data('i');
|
|
218
|
+
mappings[i].knxEntityId = $(this).val();
|
|
219
|
+
});
|
|
220
|
+
$('.m-del').off('click').on('click', function() {
|
|
221
|
+
mappings.splice($(this).closest('tr').data('i'), 1);
|
|
222
|
+
renderMappings();
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
$('#add-map-btn').on('click', function() {
|
|
227
|
+
mappings.push({ meshMac:'', meshChannel:1, knxEntityId:'' });
|
|
228
|
+
renderMappings();
|
|
229
|
+
});
|
|
230
|
+
$('#clear-map-btn').on('click', function() { if(confirm('清空映射?')) { mappings=[]; renderMappings(); } });
|
|
231
|
+
|
|
232
|
+
$('#import-btn').on('click', function() { $('#import-modal').show(); });
|
|
233
|
+
$('#import-cancel').on('click', function() { $('#import-modal').hide(); });
|
|
234
|
+
$('#import-confirm').on('click', function() {
|
|
235
|
+
const text = $('#import-input').val().trim();
|
|
236
|
+
if (!text) { $('#import-modal').hide(); return; }
|
|
237
|
+
let cnt = 0;
|
|
238
|
+
text.split('\n').forEach(line => {
|
|
239
|
+
line = line.trim();
|
|
240
|
+
if (!line || line.startsWith('#')) return;
|
|
241
|
+
const p = line.split(/\t+/);
|
|
242
|
+
if (p.length >= 2 && p[1] && /\d+\/\d+\/\d+/.test(p[2]||'')) {
|
|
243
|
+
const id = 'k' + Date.now() + Math.random().toString(36).substr(2,4);
|
|
244
|
+
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 });
|
|
245
|
+
cnt++;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
$('#import-modal').hide(); $('#import-input').val('');
|
|
249
|
+
renderKnxEntities(); renderMappings();
|
|
250
|
+
RED.notify('导入 '+cnt+' 个实体'+(cnt?'':'(需要有效组地址格式如1/2/3)'), cnt?'success':'warning');
|
|
251
|
+
});
|
|
252
|
+
$('#add-knx-btn').on('click', function() { showEntityPanel(-1); });
|
|
253
|
+
$('#clear-knx-btn').on('click', function() { if(confirm('清空KNX实体?')) { knxEntities=[]; renderKnxEntities(); renderMappings(); } });
|
|
254
|
+
|
|
255
|
+
// 保存KNX实体到隐藏字段
|
|
256
|
+
function saveKnxData() { $('#knx-data').val(JSON.stringify(knxEntities)); }
|
|
257
|
+
|
|
258
|
+
// 渲染后保存
|
|
259
|
+
const origRender = renderKnxEntities;
|
|
260
|
+
renderKnxEntities = function() { origRender(); saveKnxData(); };
|
|
261
|
+
|
|
262
|
+
$('#node-input-gateway').on('change', loadDevices);
|
|
263
|
+
setTimeout(function() { loadDevices(); renderKnxEntities(); }, 100);
|
|
264
|
+
},
|
|
265
|
+
oneditsave: function() {
|
|
266
|
+
const maps = [];
|
|
267
|
+
$('#map-list tr[data-i]').each(function() {
|
|
268
|
+
const m = { meshMac: $(this).find('.m-dev').val(), meshChannel: parseInt($(this).find('.m-ch').val())||1, knxEntityId: $(this).find('.m-knx').val() };
|
|
269
|
+
if (m.meshMac && m.knxEntityId) maps.push(m);
|
|
270
|
+
});
|
|
271
|
+
this.mappings = JSON.stringify(maps);
|
|
272
|
+
this.knxEntities = $('#knx-data').val() || '[]';
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
</script>
|
|
276
|
+
|
|
277
|
+
<script type="text/html" data-template-name="symi-knx-bridge">
|
|
278
|
+
<style>
|
|
279
|
+
#dialog-form { min-width: 950px; }
|
|
280
|
+
.tbl { width:100%; border-collapse:collapse; font-size:11px; }
|
|
281
|
+
.tbl th, .tbl td { padding:3px 5px; border:1px solid #ddd; }
|
|
282
|
+
.tbl th { background:#f0f0f0; }
|
|
283
|
+
.tbl select { width:100%; font-size:11px; padding:2px; }
|
|
284
|
+
.tbl input[type="checkbox"] { margin:0; }
|
|
285
|
+
#knx-list, #map-list { max-height:180px; overflow-y:auto; border:1px solid #ccc; margin:5px 0; padding:3px; }
|
|
286
|
+
.tips { color:#666; padding:8px; text-align:center; font-size:12px; }
|
|
287
|
+
.sec { display:flex; justify-content:space-between; align-items:center; margin:10px 0 4px; padding-bottom:4px; border-bottom:1px solid #ddd; }
|
|
288
|
+
.sec b { font-size:12px; }
|
|
289
|
+
.btns button { margin-left:4px; }
|
|
290
|
+
.modal-overlay { display:none; position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); z-index:99999; }
|
|
291
|
+
.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; }
|
|
292
|
+
#import-modal .modal-box { width:650px; }
|
|
293
|
+
#import-input { width:100%; height:160px; font-family:monospace; font-size:10px; }
|
|
294
|
+
.modal-box h4 { margin:0 0 10px; }
|
|
295
|
+
.modal-box .mbtns { text-align:right; margin-top:10px; }
|
|
296
|
+
.info { background:#f8f8e8; border:1px solid #e0e0c0; padding:5px 8px; margin:6px 0; font-size:11px; border-radius:3px; }
|
|
297
|
+
.edit-form h4 { margin:0 0 10px; color:#333; }
|
|
298
|
+
.edit-form .form-row { margin-bottom:8px; display:flex; align-items:center; }
|
|
299
|
+
.edit-form .form-row label { width:70px; font-size:12px; }
|
|
300
|
+
.edit-form .form-row input, .edit-form .form-row select { flex:1; padding:4px; font-size:12px; }
|
|
301
|
+
</style>
|
|
302
|
+
|
|
303
|
+
<div class="form-row">
|
|
304
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> 名称</label>
|
|
305
|
+
<input type="text" id="node-input-name" placeholder="KNX Bridge">
|
|
306
|
+
</div>
|
|
307
|
+
<div class="form-row">
|
|
308
|
+
<label for="node-input-gateway"><i class="fa fa-server"></i> Mesh网关</label>
|
|
309
|
+
<input type="text" id="node-input-gateway">
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
<div class="info"><b>连接:</b> <code>[knxUltimate-in] → [KNX桥接] → [knxUltimate-out]</code> | KNX IP网关由knx-ultimate配置(端口3671)</div>
|
|
313
|
+
|
|
314
|
+
<div class="sec">
|
|
315
|
+
<b><i class="fa fa-database"></i> KNX实体库 (<span id="knx-cnt">0</span>)</b>
|
|
316
|
+
<span class="btns">
|
|
317
|
+
<button type="button" class="red-ui-button red-ui-button-small" id="download-tpl-btn"><i class="fa fa-download"></i> 模板</button>
|
|
318
|
+
<button type="button" class="red-ui-button red-ui-button-small" id="import-btn"><i class="fa fa-upload"></i> 导入</button>
|
|
319
|
+
<button type="button" class="red-ui-button red-ui-button-small" id="add-knx-btn"><i class="fa fa-plus"></i> 添加</button>
|
|
320
|
+
<button type="button" class="red-ui-button red-ui-button-small" id="clear-knx-btn"><i class="fa fa-trash"></i></button>
|
|
321
|
+
</span>
|
|
322
|
+
</div>
|
|
323
|
+
<div id="knx-list"><div class="tips">点击"导入"或"添加"管理KNX实体</div></div>
|
|
324
|
+
<div id="edit-panel" style="display:none;background:#fffde7;border:1px solid #ffc107;padding:10px;margin:5px 0;border-radius:4px;"></div>
|
|
325
|
+
<input type="hidden" id="knx-data">
|
|
326
|
+
|
|
327
|
+
<div class="sec">
|
|
328
|
+
<b><i class="fa fa-exchange"></i> 设备映射</b>
|
|
329
|
+
<span class="btns">
|
|
330
|
+
<button type="button" class="red-ui-button red-ui-button-small" id="add-map-btn"><i class="fa fa-plus"></i> 添加</button>
|
|
331
|
+
<button type="button" class="red-ui-button red-ui-button-small" id="clear-map-btn"><i class="fa fa-trash"></i> 清空</button>
|
|
332
|
+
</span>
|
|
333
|
+
</div>
|
|
334
|
+
<div id="map-list"><div class="tips">添加Mesh↔KNX映射</div></div>
|
|
335
|
+
|
|
336
|
+
<div id="import-modal" class="modal-overlay">
|
|
337
|
+
<div class="modal-box">
|
|
338
|
+
<h4>导入KNX实体</h4>
|
|
339
|
+
<p style="font-size:11px;margin:0 0 8px">Tab分隔: <code>名称 类型 命令地址 状态地址 扩展...</code>(无效行自动忽略)</p>
|
|
340
|
+
<textarea id="import-input" placeholder="玄关射灯 switch 1/1/28 1/2/28
|
|
341
|
+
客厅吊灯 light_cct 1/1/10 1/2/10 1/3/10 1/4/10
|
|
342
|
+
客厅布帘 cover 2/1/5 2/2/5 2/3/5"></textarea>
|
|
343
|
+
<div class="mbtns">
|
|
344
|
+
<button type="button" class="red-ui-button" id="import-cancel">取消</button>
|
|
345
|
+
<button type="button" class="red-ui-button red-ui-button-primary" id="import-confirm">导入</button>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
</script>
|
|
351
|
+
|
|
352
|
+
<script type="text/html" data-help-name="symi-knx-bridge">
|
|
353
|
+
<p>Symi Mesh与KNX双向同步桥接</p>
|
|
354
|
+
<h3>使用步骤</h3>
|
|
355
|
+
<ol>
|
|
356
|
+
<li>点击"下载模板"获取KNX实体导入格式</li>
|
|
357
|
+
<li>填写后点击"导入"</li>
|
|
358
|
+
<li>点击"添加映射",选择Mesh设备和KNX实体</li>
|
|
359
|
+
</ol>
|
|
360
|
+
<h3>设备类型</h3>
|
|
361
|
+
<ul>
|
|
362
|
+
<li><b>switch</b> - 开关 (命令+状态)</li>
|
|
363
|
+
<li><b>light_mono/cct/rgb/rgbcw</b> - 调光灯</li>
|
|
364
|
+
<li><b>cover</b> - 窗帘 (上下+位置+停止)</li>
|
|
365
|
+
<li><b>climate</b> - 空调</li>
|
|
366
|
+
<li><b>fresh_air/floor_heating</b> - 新风/地暖</li>
|
|
367
|
+
</ul>
|
|
368
|
+
</script>
|