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

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.
@@ -159,7 +159,7 @@ module.exports = function(RED) {
159
159
  node.client.on('error', (err) => {
160
160
  // AggregateError特殊处理(Node.js 18+的IPv4/IPv6连接失败)
161
161
  if (err.name === 'AggregateError' || err.errors) {
162
- node.warn(`RS485 TCP连接失败: 无法连接到 ${node.host}:${node.port}`);
162
+ node.debug(`RS485 TCP连接失败: 无法连接到 ${node.host}:${node.port}`);
163
163
  } else {
164
164
  node.error(`RS485 TCP错误: ${err.message}`);
165
165
  }
@@ -213,18 +213,20 @@ module.exports = function(RED) {
213
213
  node.connected = false;
214
214
  };
215
215
 
216
- // 处理接收数据(支持Modbus RTU和杜亚协议)
216
+ // 处理接收数据(支持Modbus RTU、杜亚协议和自定义码)
217
217
  node.handleData = function(data) {
218
+ const hexIn = data.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
219
+ node.debug(`[RS485 RX] 收到数据: ${hexIn} (${data.length}字节)`);
218
220
  node.receiveBuffer = Buffer.concat([node.receiveBuffer, data]);
219
221
 
220
222
  // 防止缓冲区过大(最大8KB)
221
223
  if (node.receiveBuffer.length > 8192) {
222
224
  node.receiveBuffer = node.receiveBuffer.subarray(-2048);
223
- node.warn('接收缓冲区溢出,已截断');
225
+ node.debug('接收缓冲区溢出,已截断');
224
226
  }
225
227
 
226
- // 最小帧长度为4字节
227
- while (node.receiveBuffer.length >= 4) {
228
+ // 最小帧长度为2字节
229
+ while (node.receiveBuffer.length >= 2) {
228
230
  // ===== 杜亚协议检测 (55开头) =====
229
231
  if (node.receiveBuffer[0] === 0x55 && node.receiveBuffer.length >= 7) {
230
232
  // 杜亚窗帘协议:55 [地址高] [地址低] 03 [数据] [CRC16低] [CRC16高]
@@ -255,16 +257,52 @@ module.exports = function(RED) {
255
257
  break;
256
258
  }
257
259
 
260
+ // ===== SYMI协议检测 (7E开头7D结尾) =====
261
+ if (node.receiveBuffer[0] === 0x7E) {
262
+ // 查找结束符7D
263
+ let endIdx = -1;
264
+ for (let i = 1; i < node.receiveBuffer.length; i++) {
265
+ if (node.receiveBuffer[i] === 0x7D) {
266
+ endIdx = i;
267
+ break;
268
+ }
269
+ }
270
+ if (endIdx > 0) {
271
+ const frame = node.receiveBuffer.subarray(0, endIdx + 1);
272
+ node.receiveBuffer = node.receiveBuffer.subarray(endIdx + 1);
273
+ node.emit('frame', frame);
274
+ continue;
275
+ }
276
+ // 等待更多数据
277
+ break;
278
+ }
279
+
258
280
  // ===== 标准Modbus RTU协议 =====
259
281
  const frameLen = node.getFrameLength(node.receiveBuffer);
260
- if (frameLen === 0 || node.receiveBuffer.length < frameLen) break;
261
-
262
- const frame = node.receiveBuffer.subarray(0, frameLen);
263
- node.receiveBuffer = node.receiveBuffer.subarray(frameLen);
282
+ if (frameLen > 0 && node.receiveBuffer.length >= frameLen) {
283
+ const frame = node.receiveBuffer.subarray(0, frameLen);
284
+ node.receiveBuffer = node.receiveBuffer.subarray(frameLen);
285
+
286
+ if (node.validateCRC(frame)) {
287
+ node.emit('frame', frame);
288
+ }
289
+ continue;
290
+ }
264
291
 
265
- if (node.validateCRC(frame)) {
266
- node.emit('frame', frame);
292
+ // 无法识别为标准协议,启动超时处理(用于自定义码)
293
+ if (!node._frameTimeout) {
294
+ node._frameTimeout = setTimeout(() => {
295
+ node._frameTimeout = null;
296
+ if (node.receiveBuffer.length > 0) {
297
+ const frame = Buffer.from(node.receiveBuffer);
298
+ const hexStr = frame.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
299
+ node.debug(`[RS485] 自定义帧超时发出: ${hexStr} (${frame.length}字节)`);
300
+ node.receiveBuffer = Buffer.alloc(0);
301
+ node.emit('frame', frame);
302
+ }
303
+ }, 50);
267
304
  }
305
+ break;
268
306
  }
269
307
  };
270
308
 
@@ -18,6 +18,8 @@
18
18
  inputs: 1,
19
19
  outputs: 1,
20
20
  icon: 'font-awesome/fa-cloud-download',
21
+ align: 'left',
22
+ paletteLabel: '云端同步',
21
23
  label: function() {
22
24
  return this.name || '云端同步';
23
25
  },
@@ -15,13 +15,15 @@
15
15
  },
16
16
  inputs: 1,
17
17
  outputs: 1,
18
- icon: 'bridge.png',
18
+ icon: 'font-awesome/fa-cube',
19
+ align: 'left',
20
+ paletteLabel: '设备控制',
19
21
  label: function() {
20
22
  if (this.name) return this.name;
21
23
  if (this.deviceMac && this.channel > 1) {
22
- return 'Symi设备-' + this.channel + '路';
24
+ return '设备控制-' + this.channel + '路';
23
25
  }
24
- return 'Symi设备';
26
+ return '设备控制';
25
27
  },
26
28
  labelStyle: function() {
27
29
  return this.name ? 'node_label_italic' : '';
@@ -53,6 +53,13 @@ module.exports = function(RED) {
53
53
  this.deviceListComplete = false;
54
54
  this.isQueryingStates = false; // 标记是否正在查询状态
55
55
 
56
+ // 转发deviceManager的device-state-changed事件(用于0x1C fanMode等直接触发的事件)
57
+ this.deviceManager.on('device-state-changed', (eventData) => {
58
+ if (!this.isQueryingStates) {
59
+ this.emit('device-state-changed', eventData);
60
+ }
61
+ });
62
+
56
63
  // 状态事件处理队列 - 确保场景执行后的大量状态更新能被正确处理
57
64
  this.stateEventQueue = [];
58
65
  this.maxQueueSize = 100; // 最大队列大小,防止内存泄漏
@@ -169,7 +176,7 @@ module.exports = function(RED) {
169
176
  // 规则:53 B0 后面紧跟的第一个窗帘帧就是真实用户控制
170
177
  // 注意:必须用时间戳而不是标记,因为队列中可能有旧帧等待处理
171
178
  this.lastB0Time = Date.now();
172
- this.log(`[控制响应] 0xB0: ${frameHex},记录时间戳`)
179
+ this.debug(`[控制响应] 0xB0: ${frameHex},记录时间戳`)
173
180
  } else if (frame.opcode === 0xB4) {
174
181
  // 场景控制响应
175
182
  if (frame.status === 0) {
@@ -403,7 +410,7 @@ module.exports = function(RED) {
403
410
  // 6-8路开关使用0x45,1-4路使用0x02
404
411
  const msgType = (device.channels >= 6) ? 0x45 : 0x02;
405
412
  queryAttrs = [msgType];
406
- this.log(`查询开关设备: ${device.name} (地址=0x${device.networkAddress.toString(16).toUpperCase()}, 路数=${device.channels}, msgType=0x${msgType.toString(16).toUpperCase()})`);
413
+ this.debug(`查询开关设备: ${device.name} (地址=0x${device.networkAddress.toString(16).toUpperCase()}, 路数=${device.channels}, msgType=0x${msgType.toString(16).toUpperCase()})`);
407
414
  } else if (device.deviceType === 9) {
408
415
  queryAttrs = [0x02, 0x0E]; // 插卡取电:开关状态、插卡状态
409
416
  } else if (device.deviceType === 4 || device.deviceType === 0x18) {
@@ -487,7 +494,7 @@ module.exports = function(RED) {
487
494
  const frame = this.protocolHandler.buildDeviceControlFrame(device.networkAddress, 0x10, Buffer.from([0x01]));
488
495
  await this.client.sendFrame(frame, 2);
489
496
  await this.sleep(50);
490
- this.log(`已启用设备${device.name}的状态上报`);
497
+ this.debug(`已启用设备${device.name}的状态上报`);
491
498
  } catch(e) {
492
499
  this.error(`启用设备${device.name}状态上报失败: ${e.message}`);
493
500
  }
@@ -11,9 +11,10 @@
11
11
  inputs: 1,
12
12
  outputs: 2,
13
13
  outputLabels: ['KNX输出', '调试信息'],
14
- icon: 'bridge.svg',
15
- label: function() { return this.name || 'KNX Bridge'; },
14
+ icon: 'font-awesome/fa-sitemap',
15
+ align: 'left',
16
16
  paletteLabel: 'KNX桥接',
17
+ label: function() { return this.name || 'KNX桥接'; },
17
18
  oneditprepare: function() {
18
19
  const node = this;
19
20
  let mappings = [], devices = [], knxEntities = [];
@@ -463,7 +463,7 @@ module.exports = function(RED) {
463
463
  node.queueCommand = function(cmd) {
464
464
  // 队列大小限制
465
465
  if (node.commandQueue.length >= MAX_QUEUE_SIZE) {
466
- node.warn(`[KNX Bridge] 命令队列已满(${MAX_QUEUE_SIZE}),丢弃最旧命令`);
466
+ node.debug(`[KNX Bridge] 命令队列已满(${MAX_QUEUE_SIZE}),丢弃最旧命令`);
467
467
  node.commandQueue.shift();
468
468
  }
469
469
 
@@ -766,7 +766,7 @@ module.exports = function(RED) {
766
766
  }
767
767
 
768
768
  if (!meshDevice) {
769
- node.warn(`[KNX->Mesh] 未找到Mesh设备: ${meshMac}`);
769
+ node.debug(`[KNX->Mesh] 未找到Mesh设备: ${meshMac}`);
770
770
  return;
771
771
  }
772
772
 
@@ -1032,7 +1032,7 @@ module.exports = function(RED) {
1032
1032
  // 收集所有已映射的唯一设备地址
1033
1033
  const mappedAddresses = new Set();
1034
1034
  for (const mapping of node.mappings) {
1035
- const mac = node.normalizeMac(mapping.meshMac);
1035
+ const mac = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
1036
1036
  const device = node.gateway.deviceManager.getDeviceByMac(mac);
1037
1037
  if (device && device.networkAddress) {
1038
1038
  mappedAddresses.add(device.networkAddress);
@@ -11,9 +11,10 @@
11
11
  inputs: 1,
12
12
  outputs: 2,
13
13
  outputLabels: ['KNX输出', '调试信息'],
14
- icon: 'bridge.svg',
15
- label: function() { return this.name || 'KNX-HA桥接'; },
16
- paletteLabel: 'KNX-HA桥接',
14
+ icon: 'font-awesome/fa-home',
15
+ align: 'left',
16
+ paletteLabel: 'KNX HA桥接',
17
+ label: function() { return this.name || 'KNX HA桥接'; },
17
18
  oneditprepare: function() {
18
19
  const node = this;
19
20
  let mappings = [], haEntities = [], knxEntities = [];
@@ -318,7 +318,7 @@ module.exports = function(RED) {
318
318
  node.recordSyncTime('knx-to-ha', key);
319
319
 
320
320
  if (!node.haServer || !node.haServer.credentials) {
321
- node.warn('[KNX->HA] HA服务器未配置');
321
+ node.debug('[KNX->HA] HA服务器未配置');
322
322
  return;
323
323
  }
324
324
 
@@ -328,7 +328,7 @@ module.exports = function(RED) {
328
328
  const token = node.haServer.credentials.access_token;
329
329
 
330
330
  if (!token) {
331
- node.warn('[KNX->HA] HA访问令牌未配置');
331
+ node.debug('[KNX->HA] HA访问令牌未配置');
332
332
  return;
333
333
  }
334
334
 
@@ -1342,11 +1342,27 @@ module.exports = function(RED) {
1342
1342
 
1343
1343
  } else if (topic.includes('/fan_mode/set')) {
1344
1344
  // 温控器/三合一空调风速 (0x1C协议: 1=高, 2=中, 3=低, 4=自动)
1345
+ node.log(`[MQTT] 收到风速控制: topic=${topic}, payload=${payload}`);
1345
1346
  const fans = { 'high': 1, 'medium': 2, 'low': 3, 'auto': 4 };
1346
1347
  const fan = fans[payload];
1348
+ node.log(`[MQTT] 风速映射: ${payload} -> ${fan}`);
1347
1349
  if (fan !== undefined) {
1348
1350
  commands.push({ attrType: 0x1C, param: Buffer.from([fan]) });
1349
- this.debug(`[MQTT解析] 空调风速命令: 0x1C=0x${fan.toString(16).toUpperCase()} (${payload})`);
1351
+ node.log(`[MQTT解析] 空调风速命令: 0x1C=0x${fan.toString(16).toUpperCase()} (${payload})`);
1352
+
1353
+ // 立即触发状态变化事件,通知RS485桥接节点
1354
+ if (device && node.gateway && node.gateway.deviceManager) {
1355
+ node.log(`[MQTT] 触发风速状态变化事件: fanMode=${fan}`);
1356
+ device.state.fanMode = fan;
1357
+ node.gateway.deviceManager.emit('stateChange', {
1358
+ device: device,
1359
+ state: { fanMode: fan },
1360
+ attrType: 0x1C,
1361
+ isUserControl: true
1362
+ });
1363
+ } else {
1364
+ node.warn(`[MQTT] 无法触发事件: device=${!!device}, gateway=${!!node.gateway}, deviceManager=${!!node.gateway?.deviceManager}`);
1365
+ }
1350
1366
  }
1351
1367
 
1352
1368
  } else if (topic.endsWith('/cover/set')) {
@@ -0,0 +1,361 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('symi-rs485-sync', {
3
+ category: 'Symi Mesh',
4
+ color: '#C7E9B0',
5
+ defaults: {
6
+ name: { value: '' },
7
+ rs485ConfigA: { value: '', type: 'symi-485-config', required: true },
8
+ rs485ConfigB: { value: '', type: 'symi-485-config', required: true },
9
+ mappings: { value: '[]' },
10
+ pollInterval: { value: 1000 },
11
+ enablePolling: { value: true }
12
+ },
13
+ inputs: 1,
14
+ outputs: 1,
15
+ icon: 'font-awesome/fa-random',
16
+ align: 'left',
17
+ paletteLabel: 'RS485同步',
18
+ label: function() {
19
+ if (this.name) return this.name;
20
+ try {
21
+ var m = JSON.parse(this.mappings || '[]');
22
+ if (m.length > 0) return 'RS485同步(' + m.length + '组)';
23
+ } catch(e) {}
24
+ return 'RS485同步';
25
+ },
26
+ oneditprepare: function() {
27
+ // 设置编辑面板更宽
28
+ var panel = $('#dialog-form').parent();
29
+ if (panel.length) {
30
+ panel.css('min-width', '700px');
31
+ }
32
+
33
+ var node = this;
34
+ var mappings = [];
35
+ try { mappings = JSON.parse(node.mappings || '[]'); } catch(e) { mappings = []; }
36
+
37
+ var container = $('#mapping-container');
38
+
39
+ // 协议选项
40
+ var protocolOptions = {
41
+ 'zhonghong': '中弘VRF',
42
+ 'symi_climate': 'SYMI空调面板',
43
+ 'custom': '自定义码'
44
+ };
45
+
46
+ function renderMappings() {
47
+ container.empty();
48
+ mappings.forEach(function(m, idx) {
49
+ var row = $('<div class="mapping-row" data-idx="' + idx + '"></div>');
50
+
51
+ // 主行
52
+ var mainRow = $('<div class="mapping-main"></div>');
53
+
54
+ // A侧配置
55
+ var colA = $('<div class="side-col side-a"></div>');
56
+ colA.append('<label>A侧协议:</label>');
57
+ var selectA = $('<select class="protocol-a"></select>');
58
+ for (var k in protocolOptions) {
59
+ selectA.append('<option value="' + k + '"' + (m.protocolA === k ? ' selected' : '') + '>' + protocolOptions[k] + '</option>');
60
+ }
61
+ colA.append(selectA);
62
+
63
+ // B侧配置
64
+ var colB = $('<div class="side-col side-b"></div>');
65
+ colB.append('<label>B侧协议:</label>');
66
+ var selectB = $('<select class="protocol-b"></select>');
67
+ for (var k in protocolOptions) {
68
+ selectB.append('<option value="' + k + '"' + (m.protocolB === k ? ' selected' : '') + '>' + protocolOptions[k] + '</option>');
69
+ }
70
+ colB.append(selectB);
71
+
72
+ // 操作按钮
73
+ var actions = $('<div class="actions-col"></div>');
74
+ actions.append('<button type="button" class="red-ui-button red-ui-button-small toggle-detail-btn" title="展开/折叠"><i class="fa fa-cog"></i></button>');
75
+ actions.append('<button type="button" class="red-ui-button red-ui-button-small del-btn" title="删除"><i class="fa fa-trash"></i></button>');
76
+
77
+ mainRow.append(colA).append('<span class="sync-icon">⇄</span>').append(colB).append(actions);
78
+ row.append(mainRow);
79
+
80
+ // 详细配置区域(默认折叠)
81
+ var detailRow = $('<div class="detail-row" style="display:' + (m.showDetail ? 'block' : 'none') + ';"></div>');
82
+
83
+ // A侧详细配置
84
+ var detailA = $('<div class="detail-side detail-a"><h5>A侧参数</h5></div>');
85
+ if (m.protocolA === 'zhonghong') {
86
+ detailA.append(renderZhonghongConfig('a', m.configA || {}));
87
+ } else if (m.protocolA === 'symi_climate') {
88
+ detailA.append(renderSymiClimateConfig('a', m.configA || {}));
89
+ } else {
90
+ detailA.append(renderCustomConfig('a', m.configA || {}));
91
+ }
92
+
93
+ // B侧详细配置
94
+ var detailB = $('<div class="detail-side detail-b"><h5>B侧参数</h5></div>');
95
+ if (m.protocolB === 'zhonghong') {
96
+ detailB.append(renderZhonghongConfig('b', m.configB || {}));
97
+ } else if (m.protocolB === 'symi_climate') {
98
+ detailB.append(renderSymiClimateConfig('b', m.configB || {}));
99
+ } else {
100
+ detailB.append(renderCustomConfig('b', m.configB || {}));
101
+ }
102
+
103
+ detailRow.append(detailA).append(detailB);
104
+ row.append(detailRow);
105
+
106
+ container.append(row);
107
+ });
108
+ bindEvents();
109
+ }
110
+
111
+ function renderZhonghongConfig(side, cfg) {
112
+ return '<div class="config-group">' +
113
+ '<div class="config-item"><label>外机地址:</label><input type="number" class="zh-outdoor-' + side + '" value="' + (cfg.outdoorAddr !== undefined ? cfg.outdoorAddr : 1) + '" min="0" max="255"></div>' +
114
+ '<div class="config-item"><label>内机地址:</label><input type="number" class="zh-indoor-' + side + '" value="' + (cfg.indoorAddr !== undefined ? cfg.indoorAddr : 0) + '" min="0" max="255"></div>' +
115
+ '</div>';
116
+ }
117
+
118
+ function renderSymiClimateConfig(side, cfg) {
119
+ return '<div class="config-group">' +
120
+ '<div class="config-item"><label>设备地址:</label><input type="number" class="symi-addr-' + side + '" value="' + (cfg.address || 1) + '" min="1" max="255"></div>' +
121
+ '</div>';
122
+ }
123
+
124
+ function renderCustomConfig(side, cfg) {
125
+ return '<div class="config-group custom-codes">' +
126
+ '<div class="config-item full"><label>开机发码:</label><input type="text" class="custom-on-' + side + '" value="' + (cfg.sendOn || '') + '" placeholder="十六进制"></div>' +
127
+ '<div class="config-item full"><label>关机发码:</label><input type="text" class="custom-off-' + side + '" value="' + (cfg.sendOff || '') + '" placeholder="十六进制"></div>' +
128
+ '<div class="config-item full"><label>开机收码:</label><input type="text" class="custom-recv-on-' + side + '" value="' + (cfg.recvOn || '') + '" placeholder="十六进制"></div>' +
129
+ '<div class="config-item full"><label>关机收码:</label><input type="text" class="custom-recv-off-' + side + '" value="' + (cfg.recvOff || '') + '" placeholder="十六进制"></div>' +
130
+ '</div>';
131
+ }
132
+
133
+ function bindEvents() {
134
+ // 删除按钮
135
+ container.find('.del-btn').off('click').on('click', function() {
136
+ var idx = $(this).closest('.mapping-row').data('idx');
137
+ mappings.splice(idx, 1);
138
+ renderMappings();
139
+ });
140
+
141
+ // 展开/折叠按钮
142
+ container.find('.toggle-detail-btn').off('click').on('click', function() {
143
+ var row = $(this).closest('.mapping-row');
144
+ var idx = row.data('idx');
145
+ var detailRow = row.find('.detail-row');
146
+ var isVisible = detailRow.is(':visible');
147
+ detailRow.toggle();
148
+ mappings[idx].showDetail = !isVisible;
149
+ });
150
+
151
+ // 协议选择变化
152
+ container.find('.protocol-a, .protocol-b').off('change').on('change', function() {
153
+ var row = $(this).closest('.mapping-row');
154
+ var idx = row.data('idx');
155
+ mappings[idx].protocolA = row.find('.protocol-a').val();
156
+ mappings[idx].protocolB = row.find('.protocol-b').val();
157
+ mappings[idx].showDetail = true;
158
+ renderMappings();
159
+ });
160
+
161
+ // 配置输入变化
162
+ container.find('input').off('change').on('change', function() {
163
+ var row = $(this).closest('.mapping-row');
164
+ var idx = row.data('idx');
165
+ saveConfigFromRow(row, idx);
166
+ });
167
+ }
168
+
169
+ function saveConfigFromRow(row, idx) {
170
+ var m = mappings[idx];
171
+ m.configA = m.configA || {};
172
+ m.configB = m.configB || {};
173
+
174
+ // A侧配置
175
+ if (m.protocolA === 'zhonghong') {
176
+ m.configA.outdoorAddr = parseInt(row.find('.zh-outdoor-a').val()) || 1;
177
+ m.configA.indoorAddr = parseInt(row.find('.zh-indoor-a').val()) || 1;
178
+ } else if (m.protocolA === 'symi_climate') {
179
+ m.configA.address = parseInt(row.find('.symi-addr-a').val()) || 1;
180
+ } else {
181
+ m.configA.sendOn = row.find('.custom-on-a').val();
182
+ m.configA.sendOff = row.find('.custom-off-a').val();
183
+ m.configA.recvOn = row.find('.custom-recv-on-a').val();
184
+ m.configA.recvOff = row.find('.custom-recv-off-a').val();
185
+ }
186
+
187
+ // B侧配置
188
+ if (m.protocolB === 'zhonghong') {
189
+ m.configB.outdoorAddr = parseInt(row.find('.zh-outdoor-b').val()) || 1;
190
+ m.configB.indoorAddr = parseInt(row.find('.zh-indoor-b').val()) || 1;
191
+ } else if (m.protocolB === 'symi_climate') {
192
+ m.configB.address = parseInt(row.find('.symi-addr-b').val()) || 1;
193
+ } else {
194
+ m.configB.sendOn = row.find('.custom-on-b').val();
195
+ m.configB.sendOff = row.find('.custom-off-b').val();
196
+ m.configB.recvOn = row.find('.custom-recv-on-b').val();
197
+ m.configB.recvOff = row.find('.custom-recv-off-b').val();
198
+ }
199
+ }
200
+
201
+ // 添加映射按钮
202
+ $('#add-mapping-btn').on('click', function() {
203
+ mappings.push({
204
+ protocolA: 'zhonghong',
205
+ protocolB: 'symi_climate',
206
+ configA: { outdoorAddr: 1, indoorAddr: 0 },
207
+ configB: { address: 1 },
208
+ showDetail: true
209
+ });
210
+ renderMappings();
211
+ });
212
+
213
+ renderMappings();
214
+
215
+ // 保存前收集所有配置
216
+ this.on('save', function() {
217
+ container.find('.mapping-row').each(function() {
218
+ var idx = $(this).data('idx');
219
+ saveConfigFromRow($(this), idx);
220
+ });
221
+ });
222
+ },
223
+ oneditsave: function() {
224
+ var mappings = [];
225
+ var container = $('#mapping-container');
226
+ container.find('.mapping-row').each(function() {
227
+ var row = $(this);
228
+ var idx = row.data('idx');
229
+ var m = {
230
+ protocolA: row.find('.protocol-a').val(),
231
+ protocolB: row.find('.protocol-b').val(),
232
+ configA: {},
233
+ configB: {}
234
+ };
235
+
236
+ // A侧配置
237
+ if (m.protocolA === 'zhonghong') {
238
+ m.configA.outdoorAddr = parseInt(row.find('.zh-outdoor-a').val()) || 1;
239
+ m.configA.indoorAddr = parseInt(row.find('.zh-indoor-a').val()) || 1;
240
+ } else if (m.protocolA === 'symi_climate') {
241
+ m.configA.address = parseInt(row.find('.symi-addr-a').val()) || 1;
242
+ } else {
243
+ m.configA.sendOn = row.find('.custom-on-a').val();
244
+ m.configA.sendOff = row.find('.custom-off-a').val();
245
+ m.configA.recvOn = row.find('.custom-recv-on-a').val();
246
+ m.configA.recvOff = row.find('.custom-recv-off-a').val();
247
+ }
248
+
249
+ // B侧配置
250
+ if (m.protocolB === 'zhonghong') {
251
+ m.configB.outdoorAddr = parseInt(row.find('.zh-outdoor-b').val()) || 1;
252
+ m.configB.indoorAddr = parseInt(row.find('.zh-indoor-b').val()) || 1;
253
+ } else if (m.protocolB === 'symi_climate') {
254
+ m.configB.address = parseInt(row.find('.symi-addr-b').val()) || 1;
255
+ } else {
256
+ m.configB.sendOn = row.find('.custom-on-b').val();
257
+ m.configB.sendOff = row.find('.custom-off-b').val();
258
+ m.configB.recvOn = row.find('.custom-recv-on-b').val();
259
+ m.configB.recvOff = row.find('.custom-recv-off-b').val();
260
+ }
261
+
262
+ mappings.push(m);
263
+ });
264
+ this.mappings = JSON.stringify(mappings);
265
+ }
266
+ });
267
+ </script>
268
+
269
+ <script type="text/html" data-template-name="symi-rs485-sync">
270
+ <style>
271
+ #dialog-form { min-width: 650px; }
272
+ .mapping-row { background: #fff; border: 1px solid #ddd; border-radius: 6px; margin-bottom: 10px; padding: 12px; }
273
+ .mapping-main { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
274
+ .side-col { display: flex; align-items: center; gap: 8px; min-width: 200px; }
275
+ .side-col label { font-weight: bold; white-space: nowrap; font-size: 13px; }
276
+ .side-col select { min-width: 120px; padding: 4px 8px; }
277
+ .sync-icon { font-size: 20px; color: #666; padding: 0 10px; }
278
+ .actions-col { display: flex; gap: 6px; margin-left: auto; }
279
+ .detail-row { margin-top: 12px; padding-top: 12px; border-top: 1px dashed #ccc; display: flex; gap: 20px; flex-wrap: wrap; }
280
+ .detail-side { flex: 1; min-width: 280px; background: #f5f5f5; padding: 12px; border-radius: 6px; }
281
+ .detail-side h5 { margin: 0 0 12px 0; color: #555; font-size: 13px; font-weight: bold; }
282
+ .config-group { display: flex; flex-wrap: wrap; gap: 10px; }
283
+ .config-item { display: flex; align-items: center; gap: 6px; }
284
+ .config-item.full { width: 100%; }
285
+ .config-item label { font-size: 12px; white-space: nowrap; min-width: 70px; color: #666; }
286
+ .config-item input[type="number"] { width: 70px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; }
287
+ .config-item input[type="text"] { width: 180px; padding: 5px 8px; border: 1px solid #ccc; border-radius: 4px; }
288
+ .custom-codes .config-item input { width: 100%; }
289
+ #add-mapping-btn { margin-top: 12px; }
290
+ #mapping-container { max-height: 400px; overflow-y: auto; }
291
+ </style>
292
+
293
+ <div class="form-row">
294
+ <label for="node-input-name"><i class="fa fa-tag"></i> 名称</label>
295
+ <input type="text" id="node-input-name" placeholder="RS485同步">
296
+ </div>
297
+
298
+ <div class="form-row">
299
+ <label for="node-input-rs485ConfigA"><i class="fa fa-plug"></i> RS485连接A</label>
300
+ <input type="text" id="node-input-rs485ConfigA">
301
+ </div>
302
+
303
+ <div class="form-row">
304
+ <label for="node-input-rs485ConfigB"><i class="fa fa-plug"></i> RS485连接B</label>
305
+ <input type="text" id="node-input-rs485ConfigB">
306
+ </div>
307
+
308
+ <div class="form-row">
309
+ <label for="node-input-enablePolling"><i class="fa fa-refresh"></i> 轮询设置</label>
310
+ <input type="checkbox" id="node-input-enablePolling" style="width:auto; margin-right:10px;">
311
+ <span>启用轮询</span>
312
+ <span style="margin-left:20px;">间隔:</span>
313
+ <input type="number" id="node-input-pollInterval" min="500" max="60000" step="100" style="width:80px; margin-left:5px;"> ms
314
+ </div>
315
+
316
+ <div class="form-row">
317
+ <label style="width:100%;"><i class="fa fa-list"></i> 协议映射</label>
318
+ </div>
319
+
320
+ <div id="mapping-container"></div>
321
+
322
+ <button type="button" id="add-mapping-btn" class="red-ui-button"><i class="fa fa-plus"></i> 添加映射</button>
323
+ </script>
324
+
325
+ <script type="text/html" data-help-name="symi-rs485-sync">
326
+ <p>RS485协议同步节点 - 实现两种不同RS485协议之间的双向数据同步</p>
327
+
328
+ <h3>功能</h3>
329
+ <ul>
330
+ <li>支持中弘VRF网关协议</li>
331
+ <li>支持SYMI空调面板协议</li>
332
+ <li>支持自定义码协议</li>
333
+ <li>双向状态同步,防止循环</li>
334
+ </ul>
335
+
336
+ <h3>配置说明</h3>
337
+ <dl>
338
+ <dt>RS485连接A/B</dt>
339
+ <dd>分别选择两个不同的RS485配置节点,用于连接两种不同的设备</dd>
340
+
341
+ <dt>协议映射</dt>
342
+ <dd>配置A侧设备与B侧设备之间的对应关系</dd>
343
+ </dl>
344
+
345
+ <h3>中弘VRF参数</h3>
346
+ <ul>
347
+ <li><b>外机地址</b>: VRF外机地址</li>
348
+ <li><b>内机地址</b>: VRF内机地址</li>
349
+ </ul>
350
+
351
+ <h3>轮询设置</h3>
352
+ <ul>
353
+ <li><b>启用轮询</b>: 开启后自动轮询中弘VRF设备状态</li>
354
+ <li><b>轮询间隔</b>: 轮询周期(默认1000ms)</li>
355
+ </ul>
356
+
357
+ <h3>SYMI空调面板参数</h3>
358
+ <ul>
359
+ <li><b>设备地址</b>: SYMI空调面板的Modbus地址</li>
360
+ </ul>
361
+ </script>