node-red-contrib-symi-mesh 1.6.3 → 1.6.5
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 +30 -2
- package/lib/device-manager.js +2 -9
- package/lib/tcp-client.js +1 -1
- package/nodes/rs485-debug.html +51 -0
- package/nodes/rs485-debug.js +43 -0
- package/nodes/symi-485-bridge.html +29 -3
- package/nodes/symi-485-bridge.js +519 -44
- package/nodes/symi-485-config.js +0 -3
- package/nodes/symi-gateway.js +19 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1061,6 +1061,34 @@ node-red-contrib-symi-mesh/
|
|
|
1061
1061
|
|
|
1062
1062
|
## 更新日志
|
|
1063
1063
|
|
|
1064
|
+
### v1.6.5 (2025-12-06)
|
|
1065
|
+
- **杜亚窗帘协议**:原生支持杜亚窗帘协议(A6B6),2字节地址,自动CRC16计算
|
|
1066
|
+
- 帧格式:55 [地址高] [地址低] 03 [动作/位置] [CRC16低] [CRC16高]
|
|
1067
|
+
- 支持打开(01)、关闭(02)、暂停(03)、百分比(04+位置)
|
|
1068
|
+
- **窗帘控制智能判断**:根据当前位置判断方向
|
|
1069
|
+
- 位置>=50% + curtainStatus变化 → 发关闭码
|
|
1070
|
+
- 位置<50% + curtainStatus变化 → 发打开码
|
|
1071
|
+
- 暂停(curtainStatus=3)最高优先级
|
|
1072
|
+
- **窗帘百分比模式修复**:修复百分比控制后开/关命令失效的问题
|
|
1073
|
+
- 百分比控制时进入百分比模式(inPosMode)
|
|
1074
|
+
- 窗帘到位(curtainStatus=0)后自动退出百分比模式
|
|
1075
|
+
- 退出后开/关命令可正常发送
|
|
1076
|
+
- **发码防抖**:500ms内不重复发相同码,避免Mesh状态混乱
|
|
1077
|
+
- **设备类型过滤**:映射只响应对应类型的状态变化
|
|
1078
|
+
- **RS485调试增强**:新增协议测试发送功能
|
|
1079
|
+
- **初始化延迟**:20秒,避免部署时误发命令
|
|
1080
|
+
|
|
1081
|
+
### v1.6.4 (2025-12-05)
|
|
1082
|
+
- **代码优化**:移除未使用的 `three-in-one-detected` 事件监听器(死代码清理)
|
|
1083
|
+
- **TCP连接优化**:移除可能导致连接问题的keep-alive设置
|
|
1084
|
+
- **多从机支持修复**:修复只能配置从机ID1的问题,现在支持任意从机地址(1-255)
|
|
1085
|
+
- **映射数值类型修复**:确保address/meshChannel/rs485Channel字段为正确的数字类型
|
|
1086
|
+
|
|
1087
|
+
### v1.6.3 (2025-12-05)
|
|
1088
|
+
- **自定义协议模式**:支持任意RS485十六进制码双向匹配
|
|
1089
|
+
- **按键寄存器优化**:只处理按键寄存器(0x1031-0x1036),移除指示灯寄存器处理
|
|
1090
|
+
- **CRC校验正确**:发送帧与协议文档完全匹配
|
|
1091
|
+
|
|
1064
1092
|
### v1.6.2 (2025-12-05)
|
|
1065
1093
|
- **MQTT订阅修复**:修复闭包问题导致的设备MAC映射错误,确保HA实体可控
|
|
1066
1094
|
- **内存泄漏修复**:节点关闭时正确移除gateway事件监听器,防止内存累积
|
|
@@ -1080,8 +1108,8 @@ Copyright (c) 2025 SYMI 亖米
|
|
|
1080
1108
|
## 关于
|
|
1081
1109
|
|
|
1082
1110
|
**作者**: SYMI 亖米
|
|
1083
|
-
**版本**: 1.6.
|
|
1111
|
+
**版本**: 1.6.5
|
|
1084
1112
|
**协议**: 蓝牙MESH网关(初级版)串口协议V1.0
|
|
1085
|
-
**最后更新**: 2025-12-
|
|
1113
|
+
**最后更新**: 2025-12-06
|
|
1086
1114
|
**仓库**: https://github.com/symi-daguo/node-red-contrib-symi-mesh
|
|
1087
1115
|
**npm包**: https://www.npmjs.com/package/node-red-contrib-symi-mesh
|
package/lib/device-manager.js
CHANGED
|
@@ -100,16 +100,9 @@ class DeviceInfo {
|
|
|
100
100
|
break;
|
|
101
101
|
case 0x05:
|
|
102
102
|
// CURT_RUN_STATUS - 窗帘运行状态
|
|
103
|
-
// 0=到头/停止, 1=打开中, 2=关闭中, 3
|
|
103
|
+
// 0=到头/停止, 1=打开中, 2=关闭中, 3=暂停
|
|
104
104
|
if (parameters.length > 0) {
|
|
105
|
-
|
|
106
|
-
// 如果已经到头(status=0),忽略后续的运行中状态(1或2)
|
|
107
|
-
// 直到收到新的位置(0x06)或控制命令
|
|
108
|
-
if (this.state.curtainStatus === 0 && (newStatus === 1 || newStatus === 2)) {
|
|
109
|
-
// 窗帘已到头,忽略设备继续发送的运行状态
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
this.state.curtainStatus = newStatus;
|
|
105
|
+
this.state.curtainStatus = parameters[0];
|
|
113
106
|
}
|
|
114
107
|
break;
|
|
115
108
|
case 0x06:
|
package/lib/tcp-client.js
CHANGED
package/nodes/rs485-debug.html
CHANGED
|
@@ -116,6 +116,45 @@
|
|
|
116
116
|
}
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
// 发送测试
|
|
120
|
+
$('#btn-test-send').on('click', function() {
|
|
121
|
+
var hexInput = $('#test-hex-input').val().trim();
|
|
122
|
+
if (!hexInput) {
|
|
123
|
+
$('#test-send-result').html('<span style="color:orange;">请输入16进制数据</span>');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
var rs485ConfigId = $('#node-input-rs485Config').val();
|
|
127
|
+
if (!rs485ConfigId) {
|
|
128
|
+
$('#test-send-result').html('<span style="color:red;">请先选择RS485连接</span>');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
$('#btn-test-send').prop('disabled', true);
|
|
132
|
+
$('#test-send-result').html('<i class="fa fa-spinner fa-spin"></i> 发送中...');
|
|
133
|
+
$.ajax({
|
|
134
|
+
url: '/rs485-debug/test-send',
|
|
135
|
+
method: 'POST',
|
|
136
|
+
contentType: 'application/json',
|
|
137
|
+
data: JSON.stringify({ rs485ConfigId: rs485ConfigId, hex: hexInput }),
|
|
138
|
+
success: function(res) {
|
|
139
|
+
$('#test-send-result').html('<span style="color:green;">✓ 已发送: ' + res.hex + ' (' + res.bytes + '字节)</span>');
|
|
140
|
+
},
|
|
141
|
+
error: function(xhr) {
|
|
142
|
+
var msg = xhr.responseJSON ? xhr.responseJSON.error : '发送失败';
|
|
143
|
+
$('#test-send-result').html('<span style="color:red;">✗ ' + msg + '</span>');
|
|
144
|
+
},
|
|
145
|
+
complete: function() {
|
|
146
|
+
$('#btn-test-send').prop('disabled', false);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// 回车发送
|
|
152
|
+
$('#test-hex-input').on('keypress', function(e) {
|
|
153
|
+
if (e.which === 13) {
|
|
154
|
+
$('#btn-test-send').click();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
119
158
|
// 初始加载并启动自动刷新
|
|
120
159
|
loadHistory();
|
|
121
160
|
startAutoRefresh();
|
|
@@ -181,6 +220,18 @@
|
|
|
181
220
|
<input type="number" id="node-input-maxMessages" min="10" max="1000" style="width:70%">
|
|
182
221
|
</div>
|
|
183
222
|
|
|
223
|
+
<div class="debug-section">
|
|
224
|
+
<h4><i class="fa fa-paper-plane"></i> 发送测试</h4>
|
|
225
|
+
<div style="display:flex; gap:8px; align-items:center;">
|
|
226
|
+
<input type="text" id="test-hex-input" placeholder="输入16进制数据,如: 55 01 01 03 01 B9 00"
|
|
227
|
+
style="flex:1; font-family:monospace; padding:6px;">
|
|
228
|
+
<button type="button" id="btn-test-send" class="red-ui-button" style="min-width:80px;">
|
|
229
|
+
<i class="fa fa-send"></i> 发送
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
<div id="test-send-result" style="margin-top:6px; font-size:11px; color:#666;"></div>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
184
235
|
<div class="debug-section">
|
|
185
236
|
<h4><i class="fa fa-terminal"></i> 通信数据预览 <span id="debug-status" style="font-weight:normal;color:#888;font-size:11px;"></span></h4>
|
|
186
237
|
<div id="debug-history"></div>
|
package/nodes/rs485-debug.js
CHANGED
|
@@ -217,4 +217,47 @@ module.exports = function(RED) {
|
|
|
217
217
|
res.json({ success: false, error: '节点未找到' });
|
|
218
218
|
}
|
|
219
219
|
});
|
|
220
|
+
|
|
221
|
+
// API: 测试发送十六进制数据
|
|
222
|
+
RED.httpAdmin.post('/rs485-debug/test-send', function(req, res) {
|
|
223
|
+
const { rs485ConfigId, hex } = req.body || {};
|
|
224
|
+
|
|
225
|
+
if (!rs485ConfigId) {
|
|
226
|
+
return res.status(400).json({ error: '未指定RS485连接' });
|
|
227
|
+
}
|
|
228
|
+
if (!hex) {
|
|
229
|
+
return res.status(400).json({ error: '未指定发送数据' });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 解析16进制字符串
|
|
233
|
+
const hexClean = hex.replace(/[\s,]/g, '');
|
|
234
|
+
if (!/^[0-9A-Fa-f]+$/.test(hexClean)) {
|
|
235
|
+
return res.status(400).json({ error: '无效的16进制格式' });
|
|
236
|
+
}
|
|
237
|
+
if (hexClean.length % 2 !== 0) {
|
|
238
|
+
return res.status(400).json({ error: '16进制长度必须是偶数' });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const buffer = Buffer.from(hexClean, 'hex');
|
|
242
|
+
const formattedHex = buffer.toString('hex').toUpperCase().match(/.{2}/g).join(' ');
|
|
243
|
+
|
|
244
|
+
// 获取RS485配置节点
|
|
245
|
+
const rs485Config = RED.nodes.getNode(rs485ConfigId);
|
|
246
|
+
if (!rs485Config) {
|
|
247
|
+
return res.status(404).json({ error: 'RS485连接节点未找到,请先部署' });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!rs485Config.connected) {
|
|
251
|
+
return res.status(503).json({ error: 'RS485未连接' });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 发送数据
|
|
255
|
+
rs485Config.send(buffer)
|
|
256
|
+
.then(() => {
|
|
257
|
+
res.json({ success: true, hex: formattedHex, bytes: buffer.length });
|
|
258
|
+
})
|
|
259
|
+
.catch(err => {
|
|
260
|
+
res.status(500).json({ error: err.message });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
220
263
|
};
|
|
@@ -160,6 +160,18 @@
|
|
|
160
160
|
|
|
161
161
|
mappings.forEach(function(m, idx) {
|
|
162
162
|
var row = $('<div class="mapping-row" data-idx="' + idx + '" style="flex-wrap:wrap;"></div>');
|
|
163
|
+
// 杜亚窗帘使用2字节地址
|
|
164
|
+
var addrHtml = '';
|
|
165
|
+
if (m.brand === 'duya') {
|
|
166
|
+
addrHtml = '<div class="addr-col duya-addr" style="display:flex;gap:2px;">' +
|
|
167
|
+
'<input type="number" class="addr-high" value="' + (m.addrHigh || 1) + '" min="0" max="255" style="width:40px;" title="地址高字节" placeholder="高">' +
|
|
168
|
+
'<input type="number" class="addr-low" value="' + (m.addrLow || 1) + '" min="0" max="255" style="width:40px;" title="地址低字节" placeholder="低">' +
|
|
169
|
+
'</div>';
|
|
170
|
+
} else {
|
|
171
|
+
addrHtml = '<div class="addr-col normal-addr">' +
|
|
172
|
+
'<input type="number" class="addr-input" value="' + (m.address || 1) + '" min="1" max="255" title="Modbus地址">' +
|
|
173
|
+
'</div>';
|
|
174
|
+
}
|
|
163
175
|
row.html(
|
|
164
176
|
'<div class="mesh-col">' +
|
|
165
177
|
' <select class="mesh-select">' + getMeshOptions(m.meshMac) + '</select>' +
|
|
@@ -173,9 +185,7 @@
|
|
|
173
185
|
' <select class="device-select">' + getDeviceOptions(m.brand, m.device) + '</select>' +
|
|
174
186
|
' <span class="rs485-ch-wrap">' + getRS485ChannelOptions(m.brand, m.device, m.rs485Channel || 1) + '</span>' +
|
|
175
187
|
'</div>' +
|
|
176
|
-
|
|
177
|
-
' <input type="number" class="addr-input" value="' + (m.address || 1) + '" min="1" max="255" title="Modbus地址">' +
|
|
178
|
-
'</div>' +
|
|
188
|
+
addrHtml +
|
|
179
189
|
'<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>'
|
|
180
190
|
);
|
|
181
191
|
container.append(row);
|
|
@@ -203,6 +213,12 @@
|
|
|
203
213
|
mappings[idx].device = '';
|
|
204
214
|
mappings[idx].rs485Channel = 1;
|
|
205
215
|
mappings[idx].customCodes = {};
|
|
216
|
+
// 杜亚窗帘切换地址输入框
|
|
217
|
+
if (brandId === 'duya') {
|
|
218
|
+
mappings[idx].addrHigh = 1;
|
|
219
|
+
mappings[idx].addrLow = 1;
|
|
220
|
+
}
|
|
221
|
+
renderMappings(); // 重新渲染以更新地址输入框
|
|
206
222
|
});
|
|
207
223
|
|
|
208
224
|
container.find('.device-select').off('change').on('change', function() {
|
|
@@ -245,6 +261,16 @@
|
|
|
245
261
|
mappings[idx].address = parseInt($(this).val()) || 1;
|
|
246
262
|
});
|
|
247
263
|
|
|
264
|
+
// 杜亚2字节地址输入
|
|
265
|
+
container.find('.addr-high').off('change').on('change', function() {
|
|
266
|
+
var idx = $(this).closest('.mapping-row').data('idx');
|
|
267
|
+
mappings[idx].addrHigh = parseInt($(this).val()) || 1;
|
|
268
|
+
});
|
|
269
|
+
container.find('.addr-low').off('change').on('change', function() {
|
|
270
|
+
var idx = $(this).closest('.mapping-row').data('idx');
|
|
271
|
+
mappings[idx].addrLow = parseInt($(this).val()) || 1;
|
|
272
|
+
});
|
|
273
|
+
|
|
248
274
|
container.find('.btn-remove').off('click').on('click', function() {
|
|
249
275
|
var idx = $(this).closest('.mapping-row').data('idx');
|
|
250
276
|
mappings.splice(idx, 1);
|
package/nodes/symi-485-bridge.js
CHANGED
|
@@ -235,6 +235,22 @@ module.exports = function(RED) {
|
|
|
235
235
|
'scene': { name: '场景', type: 'scene', registers: { trigger: { address: 0x0000, type: 'holding' } } }
|
|
236
236
|
}
|
|
237
237
|
},
|
|
238
|
+
// ===== 杜亚窗帘协议 =====
|
|
239
|
+
// 帧格式: 55 [地址高] [地址低] 03 [数据] [CRC16高] [CRC16低]
|
|
240
|
+
// 数据: 01=打开, 02=关闭, 03=停止, 04+位置=百分比
|
|
241
|
+
// 地址: 2字节,如0101表示地址高=01,地址低=01
|
|
242
|
+
'duya': {
|
|
243
|
+
name: '杜亚窗帘',
|
|
244
|
+
protocol: 'duya', // 标记使用专用协议
|
|
245
|
+
twoByteAddress: true, // 标记使用2字节地址
|
|
246
|
+
devices: {
|
|
247
|
+
'curtain': {
|
|
248
|
+
name: '窗帘',
|
|
249
|
+
type: 'cover',
|
|
250
|
+
protocol: 'duya'
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
},
|
|
238
254
|
// ===== 自定义协议 - 用户可录入任意RS485码 =====
|
|
239
255
|
'custom': {
|
|
240
256
|
name: '自定义协议',
|
|
@@ -262,6 +278,71 @@ module.exports = function(RED) {
|
|
|
262
278
|
}
|
|
263
279
|
};
|
|
264
280
|
|
|
281
|
+
// ===== 杜亚协议CRC16计算 =====
|
|
282
|
+
// 杜亚使用CRC16-MODBUS算法,低字节在前
|
|
283
|
+
function calcA6B6CRC(buffer) {
|
|
284
|
+
let crc = 0xFFFF;
|
|
285
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
286
|
+
crc ^= buffer[i];
|
|
287
|
+
for (let j = 0; j < 8; j++) {
|
|
288
|
+
if (crc & 0x0001) {
|
|
289
|
+
crc = (crc >> 1) ^ 0xA001;
|
|
290
|
+
} else {
|
|
291
|
+
crc >>= 1;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// 返回低字节在前(小端序)
|
|
296
|
+
return [crc & 0xFF, (crc >> 8) & 0xFF];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 构建A6B6窗帘控制帧
|
|
300
|
+
// 地址格式: 0x0102 表示地址高=01, 地址低=02
|
|
301
|
+
function buildA6B6Frame(addrHigh, addrLow, action, position) {
|
|
302
|
+
let data;
|
|
303
|
+
if (action === 'position' && position !== undefined) {
|
|
304
|
+
// 百分比控制: 55 addrH addrL 03 04 [位置] CRC
|
|
305
|
+
data = Buffer.from([0x55, addrHigh, addrLow, 0x03, 0x04, position]);
|
|
306
|
+
} else {
|
|
307
|
+
// 动作控制: 55 addrH addrL 03 [动作] CRC
|
|
308
|
+
const actionCode = action === 'open' ? 0x01 : action === 'close' ? 0x02 : 0x03;
|
|
309
|
+
data = Buffer.from([0x55, addrHigh, addrLow, 0x03, actionCode]);
|
|
310
|
+
}
|
|
311
|
+
const crc = calcA6B6CRC(data);
|
|
312
|
+
return Buffer.concat([data, Buffer.from(crc)]);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 解析A6B6窗帘响应帧
|
|
316
|
+
function parseA6B6Frame(frame) {
|
|
317
|
+
if (frame.length < 7 || frame[0] !== 0x55) return null;
|
|
318
|
+
|
|
319
|
+
const addrHigh = frame[1];
|
|
320
|
+
const addrLow = frame[2];
|
|
321
|
+
const funcCode = frame[3];
|
|
322
|
+
|
|
323
|
+
if (funcCode !== 0x03) return null;
|
|
324
|
+
|
|
325
|
+
const dataType = frame[4];
|
|
326
|
+
let result = {
|
|
327
|
+
address: (addrHigh << 8) | addrLow,
|
|
328
|
+
addrHigh: addrHigh,
|
|
329
|
+
addrLow: addrLow
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
if (dataType === 0x01) {
|
|
333
|
+
result.action = 'open';
|
|
334
|
+
} else if (dataType === 0x02) {
|
|
335
|
+
result.action = 'close';
|
|
336
|
+
} else if (dataType === 0x03) {
|
|
337
|
+
result.action = 'stop';
|
|
338
|
+
} else if (dataType === 0x04 && frame.length >= 8) {
|
|
339
|
+
result.action = 'position';
|
|
340
|
+
result.position = frame[5];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
345
|
+
|
|
265
346
|
function SymiRS485BridgeNode(config) {
|
|
266
347
|
RED.nodes.createNode(this, config);
|
|
267
348
|
const node = this;
|
|
@@ -273,9 +354,33 @@ module.exports = function(RED) {
|
|
|
273
354
|
|
|
274
355
|
// 解析实体映射
|
|
275
356
|
try {
|
|
276
|
-
|
|
357
|
+
const rawMappings = JSON.parse(config.mappings || '[]');
|
|
358
|
+
// 确保所有数值字段是正确类型
|
|
359
|
+
node.mappings = rawMappings.map(m => {
|
|
360
|
+
const mapping = {
|
|
361
|
+
...m,
|
|
362
|
+
address: parseInt(m.address) || 1,
|
|
363
|
+
meshChannel: parseInt(m.meshChannel) || 1,
|
|
364
|
+
rs485Channel: parseInt(m.rs485Channel) || 1
|
|
365
|
+
};
|
|
366
|
+
// 杜亚窗帘使用2字节地址
|
|
367
|
+
if (m.brand === 'duya') {
|
|
368
|
+
mapping.addrHigh = parseInt(m.addrHigh) || parseInt(m.address) || 1;
|
|
369
|
+
mapping.addrLow = parseInt(m.addrLow) || parseInt(m.address) || 1;
|
|
370
|
+
}
|
|
371
|
+
return mapping;
|
|
372
|
+
});
|
|
373
|
+
// 打印所有映射配置便于调试
|
|
374
|
+
node.mappings.forEach((m, i) => {
|
|
375
|
+
if (m.brand === 'duya') {
|
|
376
|
+
node.log(`[映射${i+1}] Mesh: ${m.meshMac} <-> 杜亚窗帘: 地址${m.addrHigh.toString(16).padStart(2,'0')} ${m.addrLow.toString(16).padStart(2,'0')}`);
|
|
377
|
+
} else {
|
|
378
|
+
node.log(`[映射${i+1}] Mesh: ${m.meshMac} CH${m.meshChannel} <-> RS485: 从机${m.address} ${m.brand}/${m.device} CH${m.rs485Channel}`);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
277
381
|
} catch (e) {
|
|
278
382
|
node.mappings = [];
|
|
383
|
+
node.error(`映射配置解析失败: ${e.message}`);
|
|
279
384
|
}
|
|
280
385
|
|
|
281
386
|
if (!node.gateway) {
|
|
@@ -349,7 +454,8 @@ module.exports = function(RED) {
|
|
|
349
454
|
|
|
350
455
|
// Find mapping for RS485 device
|
|
351
456
|
node.findRS485Mapping = function(address) {
|
|
352
|
-
|
|
457
|
+
const addr = parseInt(address);
|
|
458
|
+
return node.mappings.find(m => m.address === addr);
|
|
353
459
|
};
|
|
354
460
|
|
|
355
461
|
// 获取映射的寄存器配置
|
|
@@ -360,34 +466,73 @@ module.exports = function(RED) {
|
|
|
360
466
|
return brand.devices[mapping.device].registers;
|
|
361
467
|
};
|
|
362
468
|
|
|
469
|
+
// 状态缓存 - 用于检测真正变化的开关
|
|
470
|
+
node.stateCache = {};
|
|
471
|
+
// 首次启动标记 - 跳过初始状态同步
|
|
472
|
+
node.initializing = true;
|
|
473
|
+
// 启动后延迟20秒再开始同步(Mesh网关需要15秒以上完成设备发现)
|
|
474
|
+
setTimeout(() => {
|
|
475
|
+
node.initializing = false;
|
|
476
|
+
node.log('[RS485 Bridge] 初始化完成,开始同步');
|
|
477
|
+
}, 20000);
|
|
478
|
+
|
|
363
479
|
// Mesh设备状态变化处理(事件驱动)
|
|
364
480
|
const handleMeshStateChange = (eventData) => {
|
|
365
481
|
if (node.syncLock) return;
|
|
482
|
+
if (node.initializing) return;
|
|
366
483
|
|
|
367
484
|
const mac = eventData.device.macAddress;
|
|
368
485
|
const state = eventData.state || {};
|
|
369
|
-
// channel可能是0-based或1-based,需要兼容处理
|
|
370
|
-
// Mesh事件中channel通常是0-based,UI配置的meshChannel是1-based
|
|
371
|
-
const eventChannel = state.channel !== undefined ? state.channel : -1;
|
|
372
486
|
|
|
373
|
-
|
|
487
|
+
// 状态缓存比较,只处理真正变化的状态
|
|
488
|
+
if (!node.stateCache[mac]) node.stateCache[mac] = {};
|
|
489
|
+
const cached = node.stateCache[mac];
|
|
490
|
+
const changed = {};
|
|
374
491
|
|
|
375
|
-
|
|
492
|
+
for (const [key, value] of Object.entries(state)) {
|
|
493
|
+
if (cached[key] !== value) {
|
|
494
|
+
changed[key] = value;
|
|
495
|
+
cached[key] = value;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (Object.keys(changed).length === 0) return; // 无变化
|
|
500
|
+
|
|
501
|
+
node.log(`[Mesh事件] MAC=${mac}, 变化: ${JSON.stringify(changed)}`);
|
|
502
|
+
|
|
503
|
+
// 遍历映射,只处理有对应变化的映射
|
|
376
504
|
for (const mapping of node.mappings) {
|
|
377
505
|
if (mapping.meshMac !== mac) continue;
|
|
378
506
|
|
|
379
|
-
// 通道匹配:meshChannel=0表示匹配所有,否则需要匹配具体通道
|
|
380
|
-
// UI的meshChannel是1-based,事件的channel可能是0-based
|
|
381
507
|
const configChannel = mapping.meshChannel || 1;
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
(eventChannel === configChannel - 1); // 0-based匹配
|
|
508
|
+
const switchKey = `switch_${configChannel}`;
|
|
509
|
+
const device = mapping.device || '';
|
|
385
510
|
|
|
386
|
-
|
|
511
|
+
// 根据映射设备类型检查是否有相关状态
|
|
512
|
+
const isSwitch = device.includes('switch') || device.includes('button');
|
|
513
|
+
const isCurtain = device.includes('curtain') || mapping.brand === 'duya';
|
|
514
|
+
const isAC = device.includes('ac') || device.includes('climate') || device.includes('thermostat');
|
|
515
|
+
|
|
516
|
+
// 只有对应类型的状态变化才触发对应类型的映射
|
|
517
|
+
const hasSwitchChange = isSwitch && changed[switchKey] !== undefined;
|
|
518
|
+
const hasCurtainChange = isCurtain && (
|
|
519
|
+
changed.curtainAction !== undefined ||
|
|
520
|
+
changed.curtainPosition !== undefined ||
|
|
521
|
+
changed.curtainStatus !== undefined
|
|
522
|
+
);
|
|
523
|
+
const hasACChange = isAC && (
|
|
524
|
+
changed.targetTemp !== undefined ||
|
|
525
|
+
changed.acMode !== undefined ||
|
|
526
|
+
changed.acFanSpeed !== undefined
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
if (!hasSwitchChange && !hasCurtainChange && !hasACChange) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
387
532
|
|
|
388
533
|
const registers = node.getRegistersForMapping(mapping);
|
|
389
534
|
|
|
390
|
-
node.log(`[Mesh->RS485] ${eventData.device.name}
|
|
535
|
+
node.log(`[Mesh->RS485] ${eventData.device.name} CH${configChannel} 变化: ${JSON.stringify(changed)}`);
|
|
391
536
|
|
|
392
537
|
// 输出调试信息到节点输出端口
|
|
393
538
|
node.send({
|
|
@@ -397,7 +542,7 @@ module.exports = function(RED) {
|
|
|
397
542
|
device: eventData.device.name,
|
|
398
543
|
mac: mac,
|
|
399
544
|
channel: configChannel,
|
|
400
|
-
state:
|
|
545
|
+
state: changed
|
|
401
546
|
},
|
|
402
547
|
timestamp: new Date().toISOString()
|
|
403
548
|
});
|
|
@@ -406,11 +551,74 @@ module.exports = function(RED) {
|
|
|
406
551
|
direction: 'mesh-to-modbus',
|
|
407
552
|
mapping: mapping,
|
|
408
553
|
registers: registers,
|
|
409
|
-
state:
|
|
554
|
+
state: changed,
|
|
410
555
|
timestamp: Date.now()
|
|
411
556
|
});
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// 窗帘控制命令处理(立即同步,不等状态反馈)
|
|
561
|
+
const handleCurtainControl = (eventData) => {
|
|
562
|
+
const mac = eventData.mac;
|
|
563
|
+
|
|
564
|
+
node.log(`[curtain-control事件] MAC=${mac}, action=${eventData.action}, position=${eventData.position}`);
|
|
565
|
+
|
|
566
|
+
// 查找杜亚窗帘映射(支持大小写和冒号格式)
|
|
567
|
+
const macNormalized = mac.toLowerCase().replace(/:/g, '');
|
|
568
|
+
for (const mapping of node.mappings) {
|
|
569
|
+
const mappingMacNormalized = (mapping.meshMac || '').toLowerCase().replace(/:/g, '');
|
|
570
|
+
if (mappingMacNormalized !== macNormalized || mapping.brand !== 'duya') continue;
|
|
571
|
+
|
|
572
|
+
node.log(`[curtain-control] 匹配到杜亚映射: ${mapping.meshMac}`);
|
|
573
|
+
|
|
574
|
+
const addrHigh = mapping.addrHigh || 1;
|
|
575
|
+
const addrLow = mapping.addrLow || 1;
|
|
412
576
|
|
|
413
|
-
|
|
577
|
+
let frame = null;
|
|
578
|
+
let actionName = '';
|
|
579
|
+
|
|
580
|
+
// 处理动作命令 (attrType=0x05)
|
|
581
|
+
if (eventData.action !== null) {
|
|
582
|
+
if (eventData.action === 1) {
|
|
583
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'open');
|
|
584
|
+
actionName = '打开';
|
|
585
|
+
} else if (eventData.action === 2) {
|
|
586
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'close');
|
|
587
|
+
actionName = '关闭';
|
|
588
|
+
} else if (eventData.action === 3) {
|
|
589
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'stop');
|
|
590
|
+
actionName = '暂停';
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// 处理位置命令 (attrType=0x06)
|
|
594
|
+
else if (eventData.position !== null) {
|
|
595
|
+
const pos = eventData.position;
|
|
596
|
+
if (pos >= 95) {
|
|
597
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'open');
|
|
598
|
+
actionName = '打开';
|
|
599
|
+
} else if (pos <= 5) {
|
|
600
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'close');
|
|
601
|
+
actionName = '关闭';
|
|
602
|
+
} else {
|
|
603
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'position', pos);
|
|
604
|
+
actionName = `位置${pos}%`;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (frame) {
|
|
609
|
+
// 立即发送,记录时间防止状态反馈重复发码
|
|
610
|
+
node.lastCurtainControlTime = Date.now(); // 专门用于curtain-control事件
|
|
611
|
+
node.lastMeshToRS485Time = Date.now();
|
|
612
|
+
if (!node.lastSentTime) node.lastSentTime = {};
|
|
613
|
+
node.lastSentTime[`duya_${mac}_${actionName}`] = Date.now();
|
|
614
|
+
|
|
615
|
+
node.sendRS485Frame(frame).then(() => {
|
|
616
|
+
const hexStr = frame.toString('hex').toUpperCase();
|
|
617
|
+
node.log(`[Mesh控制->杜亚] 窗帘 ${actionName}, 立即发送: ${hexStr.match(/.{2}/g).join(' ')}`);
|
|
618
|
+
}).catch(err => {
|
|
619
|
+
node.error(`[Mesh控制->杜亚] 发送失败: ${err.message}`);
|
|
620
|
+
});
|
|
621
|
+
}
|
|
414
622
|
}
|
|
415
623
|
};
|
|
416
624
|
|
|
@@ -501,6 +709,87 @@ module.exports = function(RED) {
|
|
|
501
709
|
// 记录发送时间(用于防死循环)
|
|
502
710
|
node.lastMeshToRS485Time = Date.now();
|
|
503
711
|
|
|
712
|
+
// ===== 杜亚窗帘协议模式 =====
|
|
713
|
+
if (mapping.brand === 'duya') {
|
|
714
|
+
const addrHigh = mapping.addrHigh || 1;
|
|
715
|
+
const addrLow = mapping.addrLow || 1;
|
|
716
|
+
|
|
717
|
+
// 缓存:记录 status 和 position
|
|
718
|
+
if (!node.curtainCache) node.curtainCache = {};
|
|
719
|
+
const cKey = `cc_${mapping.meshMac}`;
|
|
720
|
+
const cache = node.curtainCache[cKey] || { status: undefined, position: 50 };
|
|
721
|
+
|
|
722
|
+
const lastStatus = cache.status;
|
|
723
|
+
const currentStatus = state.curtainStatus;
|
|
724
|
+
|
|
725
|
+
// 【重要】先更新位置缓存,再判断方向
|
|
726
|
+
// 第一个事件通常同时包含 status 和 position
|
|
727
|
+
if (state.curtainPosition !== undefined) {
|
|
728
|
+
cache.position = state.curtainPosition;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// 判断方向时使用的位置:优先使用事件中的位置,否则使用缓存
|
|
732
|
+
const posForDirection = state.curtainPosition !== undefined ? state.curtainPosition : cache.position;
|
|
733
|
+
|
|
734
|
+
// 更新状态缓存
|
|
735
|
+
if (currentStatus !== undefined) {
|
|
736
|
+
cache.status = currentStatus;
|
|
737
|
+
}
|
|
738
|
+
node.curtainCache[cKey] = cache;
|
|
739
|
+
|
|
740
|
+
let frame = null;
|
|
741
|
+
let actionName = '';
|
|
742
|
+
|
|
743
|
+
const isRunning = currentStatus === 1 || currentStatus === 2;
|
|
744
|
+
|
|
745
|
+
// 1. 暂停(status=3) → 立即发送暂停码
|
|
746
|
+
if (currentStatus === 3) {
|
|
747
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'stop');
|
|
748
|
+
actionName = '暂停';
|
|
749
|
+
}
|
|
750
|
+
// 2. 运行状态(1/2):根据当前位置判断方向
|
|
751
|
+
// 位置>=50(接近全开)→ 即将关闭 → 发关闭码
|
|
752
|
+
// 位置<50(接近全关)→ 即将打开 → 发打开码
|
|
753
|
+
else if (isRunning) {
|
|
754
|
+
if (posForDirection >= 50) {
|
|
755
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'close');
|
|
756
|
+
actionName = '关闭';
|
|
757
|
+
} else {
|
|
758
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'open');
|
|
759
|
+
actionName = '打开';
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// 3. 只有位置变化(没有状态变化),中间位置发百分比码
|
|
763
|
+
else if (state.curtainPosition !== undefined &&
|
|
764
|
+
state.curtainStatus === undefined &&
|
|
765
|
+
state.curtainPosition > 5 && state.curtainPosition < 95) {
|
|
766
|
+
frame = buildA6B6Frame(addrHigh, addrLow, 'position', state.curtainPosition);
|
|
767
|
+
actionName = `位置${state.curtainPosition}%`;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (frame) {
|
|
771
|
+
// 全局防抖: 2秒内不重复发送开关码(过滤运行中的状态抖动)
|
|
772
|
+
if (!node.lastSentTime) node.lastSentTime = {};
|
|
773
|
+
const isOpenClose = (actionName === '打开' || actionName === '关闭');
|
|
774
|
+
const cacheKey = isOpenClose ? `duya_${mapping.meshMac}_openclose` : `duya_${mapping.meshMac}_${actionName}`;
|
|
775
|
+
const debounceTime = isOpenClose ? 2000 : 1500; // 开关码用2秒防抖
|
|
776
|
+
const now = Date.now();
|
|
777
|
+
const lastTime = node.lastSentTime[cacheKey] || 0;
|
|
778
|
+
|
|
779
|
+
if (now - lastTime < debounceTime) {
|
|
780
|
+
node.debug(`[Mesh->杜亚] 窗帘 ${actionName} ${debounceTime}ms内防抖跳过`);
|
|
781
|
+
} else {
|
|
782
|
+
node.lastSentTime[cacheKey] = now;
|
|
783
|
+
const hexStr = frame.toString('hex').toUpperCase();
|
|
784
|
+
await node.sendRS485Frame(frame);
|
|
785
|
+
node.log(`[Mesh->杜亚] 窗帘 ${actionName}, 位置${posForDirection}%, 发送: ${hexStr.match(/.{2}/g).join(' ')}`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
node.status({ fill: 'green', shape: 'dot', text: `杜亚同步 ${node.mappings.length}个` });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
504
793
|
// ===== 自定义协议模式 =====
|
|
505
794
|
if (mapping.brand === 'custom' && mapping.customCodes) {
|
|
506
795
|
const codes = mapping.customCodes;
|
|
@@ -521,17 +810,63 @@ module.exports = function(RED) {
|
|
|
521
810
|
}
|
|
522
811
|
// 窗帘类型
|
|
523
812
|
else if (mapping.device === 'custom_curtain') {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
813
|
+
node.log(`[Mesh->自定义] 窗帘状态: ${JSON.stringify(state)}`);
|
|
814
|
+
|
|
815
|
+
// 优先级:curtainAction > curtainStatus > curtainPosition
|
|
816
|
+
// 只发送一次,避免重复
|
|
817
|
+
let hexCode = null;
|
|
818
|
+
let actionName = '';
|
|
819
|
+
|
|
820
|
+
// 1. 优先检查动作命令
|
|
821
|
+
if (state.curtainAction !== undefined || state.action !== undefined) {
|
|
822
|
+
const action = state.curtainAction || state.action;
|
|
823
|
+
if (action === 1 || action === 'open') {
|
|
824
|
+
hexCode = codes.open;
|
|
825
|
+
actionName = '打开';
|
|
826
|
+
} else if (action === 2 || action === 'close') {
|
|
827
|
+
hexCode = codes.close;
|
|
828
|
+
actionName = '关闭';
|
|
829
|
+
} else if (action === 3 || action === 'stop') {
|
|
830
|
+
hexCode = codes.stop;
|
|
831
|
+
actionName = '停止';
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
// 2. 其次检查运行状态
|
|
835
|
+
else if (state.curtainStatus !== undefined) {
|
|
836
|
+
if (state.curtainStatus === 1) {
|
|
837
|
+
hexCode = codes.open;
|
|
838
|
+
actionName = '打开(运行中)';
|
|
839
|
+
} else if (state.curtainStatus === 2) {
|
|
840
|
+
hexCode = codes.close;
|
|
841
|
+
actionName = '关闭(运行中)';
|
|
842
|
+
} else if (state.curtainStatus === 0 && codes.stop) {
|
|
843
|
+
// 0=已停止,发送停止码
|
|
844
|
+
hexCode = codes.stop;
|
|
845
|
+
actionName = '停止';
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
// 3. 最后检查位置(仅在极端位置时)
|
|
849
|
+
else if (state.curtainPosition !== undefined) {
|
|
850
|
+
if (state.curtainPosition >= 95) {
|
|
851
|
+
hexCode = codes.open;
|
|
852
|
+
actionName = '打开(位置>=95)';
|
|
853
|
+
} else if (state.curtainPosition <= 5) {
|
|
854
|
+
hexCode = codes.close;
|
|
855
|
+
actionName = '关闭(位置<=5)';
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (hexCode) {
|
|
860
|
+
// 防抖:500ms内不重复发送相同命令
|
|
861
|
+
const cacheKey = `curtain_${mapping.meshMac}_${hexCode}`;
|
|
862
|
+
const now = Date.now();
|
|
863
|
+
if (node.lastSentTime && node.lastSentTime[cacheKey] && now - node.lastSentTime[cacheKey] < 500) {
|
|
864
|
+
node.debug(`[Mesh->自定义] 窗帘 ${actionName} 防抖跳过`);
|
|
865
|
+
} else {
|
|
866
|
+
if (!node.lastSentTime) node.lastSentTime = {};
|
|
867
|
+
node.lastSentTime[cacheKey] = now;
|
|
868
|
+
await node.sendCustomCode(hexCode);
|
|
869
|
+
node.log(`[Mesh->自定义] 窗帘 ${actionName}, 发送: ${hexCode}`);
|
|
535
870
|
}
|
|
536
871
|
}
|
|
537
872
|
}
|
|
@@ -597,17 +932,53 @@ module.exports = function(RED) {
|
|
|
597
932
|
node.log(`[Mesh->RS485] 从机${mapping.address} 按键${rs485Channel}: ${value ? '开' : '关'}`);
|
|
598
933
|
}
|
|
599
934
|
}
|
|
935
|
+
// 空调开关
|
|
936
|
+
else if (meshKey === 'acSwitch' && registers && registers.switch) {
|
|
937
|
+
const writeValue = value ? (registers.switch.on !== undefined ? registers.switch.on : 1) :
|
|
938
|
+
(registers.switch.off !== undefined ? registers.switch.off : 0);
|
|
939
|
+
await node.writeModbusRegister(mapping.address, registers.switch, writeValue);
|
|
940
|
+
node.log(`[Mesh->RS485] 空调开关: ${value ? '开' : '关'}`);
|
|
941
|
+
}
|
|
942
|
+
// 目标温度
|
|
600
943
|
else if ((meshKey === 'targetTemp' || meshKey === 'acTargetTemp') && registers && registers.targetTemp) {
|
|
601
944
|
await node.writeModbusRegister(mapping.address, registers.targetTemp, value);
|
|
602
|
-
node.
|
|
945
|
+
node.log(`[Mesh->RS485] 目标温度: ${value}°C`);
|
|
603
946
|
}
|
|
947
|
+
// 空调模式 - Mesh值需要转换为RS485值
|
|
948
|
+
// Mesh: 0=制冷, 1=制热, 2=送风, 3=除湿
|
|
949
|
+
// A5B5: 1=制热, 2=制冷, 4=送风, 8=除湿
|
|
604
950
|
else if (meshKey === 'acMode' && registers && registers.mode) {
|
|
605
|
-
|
|
606
|
-
|
|
951
|
+
// 反向映射:从Mesh模式名找到RS485寄存器值
|
|
952
|
+
let rs485Value = value;
|
|
953
|
+
if (registers.mode.map) {
|
|
954
|
+
const modeNames = { 0: 'cool', 1: 'heat', 2: 'fan', 3: 'dry' };
|
|
955
|
+
const meshModeName = modeNames[value] || value;
|
|
956
|
+
const found = Object.entries(registers.mode.map).find(([k, v]) => v === meshModeName);
|
|
957
|
+
if (found) {
|
|
958
|
+
rs485Value = parseInt(found[0]);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
await node.writeModbusRegister(mapping.address, registers.mode, rs485Value);
|
|
962
|
+
node.log(`[Mesh->RS485] 空调模式: ${value} -> RS485值${rs485Value}`);
|
|
607
963
|
}
|
|
964
|
+
// 风速 - Mesh值需要转换为RS485值
|
|
965
|
+
// Mesh: 0=自动, 1=低, 2=中, 3=高
|
|
966
|
+
// A5B5: 1=低风, 2=中风, 3=高风
|
|
608
967
|
else if (meshKey === 'acFanSpeed' && registers && registers.fanSpeed) {
|
|
609
|
-
|
|
610
|
-
|
|
968
|
+
let rs485Value = value;
|
|
969
|
+
if (registers.fanSpeed.map) {
|
|
970
|
+
const speedNames = { 0: 'auto', 1: 'low', 2: 'medium', 3: 'high' };
|
|
971
|
+
const meshSpeedName = speedNames[value] || value;
|
|
972
|
+
const found = Object.entries(registers.fanSpeed.map).find(([k, v]) => v === meshSpeedName);
|
|
973
|
+
if (found) {
|
|
974
|
+
rs485Value = parseInt(found[0]);
|
|
975
|
+
}
|
|
976
|
+
} else {
|
|
977
|
+
// 默认直接使用Mesh值
|
|
978
|
+
rs485Value = value;
|
|
979
|
+
}
|
|
980
|
+
await node.writeModbusRegister(mapping.address, registers.fanSpeed, rs485Value);
|
|
981
|
+
node.log(`[Mesh->RS485] 空调风速: ${value} -> RS485值${rs485Value}`);
|
|
611
982
|
}
|
|
612
983
|
else if (meshKey === 'brightness' && registers && registers.brightness) {
|
|
613
984
|
await node.writeModbusRegister(mapping.address, registers.brightness, value);
|
|
@@ -646,6 +1017,30 @@ module.exports = function(RED) {
|
|
|
646
1017
|
|
|
647
1018
|
const channel = mapping.meshChannel || 1;
|
|
648
1019
|
|
|
1020
|
+
// ===== 杜亚窗帘协议模式 =====
|
|
1021
|
+
if (cmd.duyaMode || mapping.brand === 'duya') {
|
|
1022
|
+
node.log(`[杜亚->Mesh] 设备${mapping.meshMac}, 状态: ${JSON.stringify(state)}`);
|
|
1023
|
+
|
|
1024
|
+
try {
|
|
1025
|
+
if (state.curtainAction !== undefined || state.action !== undefined) {
|
|
1026
|
+
const action = state.curtainAction || (state.action === 'open' ? 1 : state.action === 'close' ? 2 : 3);
|
|
1027
|
+
const param = Buffer.from([action]);
|
|
1028
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x05, param);
|
|
1029
|
+
node.log(`[杜亚->Mesh] 窗帘动作: ${action === 1 ? '打开' : action === 2 ? '关闭' : '停止'}`);
|
|
1030
|
+
} else if (state.curtainPosition !== undefined || state.position !== undefined) {
|
|
1031
|
+
const pos = state.curtainPosition || state.position;
|
|
1032
|
+
const param = Buffer.from([pos]);
|
|
1033
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x06, param);
|
|
1034
|
+
node.log(`[杜亚->Mesh] 窗帘位置: ${pos}%`);
|
|
1035
|
+
}
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
node.error(`[杜亚->Mesh] 写入失败: ${err.message}`);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
node.status({ fill: 'blue', shape: 'dot', text: `杜亚同步 ${node.mappings.length}个` });
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
649
1044
|
// ===== 自定义协议模式 =====
|
|
650
1045
|
if (customMode || mapping.brand === 'custom') {
|
|
651
1046
|
node.log(`[自定义->Mesh] 设备${mapping.meshMac}, 通道${channel}, 状态: ${JSON.stringify(state)}`);
|
|
@@ -661,14 +1056,16 @@ module.exports = function(RED) {
|
|
|
661
1056
|
}
|
|
662
1057
|
// 窗帘类型
|
|
663
1058
|
else if (key === 'action' || key === 'position') {
|
|
664
|
-
|
|
1059
|
+
// 窗帘动作: 1=打开, 2=关闭, 3=停止
|
|
1060
|
+
let action = 0x03; // 停止
|
|
665
1061
|
if (value === 'open') action = 0x01; // 打开
|
|
666
1062
|
else if (value === 'close') action = 0x02; // 关闭
|
|
667
|
-
else if (value === 'stop') action =
|
|
1063
|
+
else if (value === 'stop') action = 0x03; // 停止
|
|
668
1064
|
|
|
669
1065
|
const param = Buffer.from([action]);
|
|
670
|
-
|
|
671
|
-
node.
|
|
1066
|
+
// 0x05是窗帘动作控制属性
|
|
1067
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x05, param);
|
|
1068
|
+
node.log(`[自定义->Mesh] 窗帘: ${value} (动作码${action})`);
|
|
672
1069
|
}
|
|
673
1070
|
} catch (err) {
|
|
674
1071
|
node.error(`Mesh写入失败: ${key}=${value}, ${err.message}`);
|
|
@@ -698,22 +1095,51 @@ module.exports = function(RED) {
|
|
|
698
1095
|
else if (key.startsWith('led')) {
|
|
699
1096
|
node.debug(`[RS485] 指示灯${key}: ${value}`);
|
|
700
1097
|
}
|
|
1098
|
+
// 空调开关 - 0x1B是空调开关属性
|
|
1099
|
+
else if (key === 'acSwitch' || (key === 'switch' && mapping.device && mapping.device.includes('ac'))) {
|
|
1100
|
+
const param = Buffer.from([value ? 1 : 0]);
|
|
1101
|
+
await node.gateway.sendControl(meshDevice.networkAddress, 0x1B, param);
|
|
1102
|
+
node.log(`[RS485->Mesh] 空调开关: ${value ? '开' : '关'}`);
|
|
1103
|
+
}
|
|
1104
|
+
// 目标温度 - 0x1C是目标温度属性
|
|
701
1105
|
else if (key === 'targetTemp') {
|
|
702
1106
|
const param = Buffer.from([Math.round(value)]);
|
|
703
1107
|
await node.gateway.sendControl(meshDevice.networkAddress, 0x1C, param);
|
|
704
|
-
node.
|
|
1108
|
+
node.log(`[RS485->Mesh] 目标温度: ${value}°C`);
|
|
705
1109
|
}
|
|
1110
|
+
// 空调模式 - 0x16是模式属性
|
|
1111
|
+
// RS485: 1=制热, 2=制冷, 4=送风, 8=除湿
|
|
1112
|
+
// Mesh: 0=制冷, 1=制热, 2=送风, 3=除湿
|
|
706
1113
|
else if (key === 'mode') {
|
|
707
|
-
|
|
708
|
-
|
|
1114
|
+
// 从RS485值或字符串转换为Mesh值
|
|
1115
|
+
let meshMode = 0;
|
|
1116
|
+
if (typeof value === 'string') {
|
|
1117
|
+
const modeMap = { 'cool': 0, 'heat': 1, 'fan': 2, 'dry': 3 };
|
|
1118
|
+
meshMode = modeMap[value] !== undefined ? modeMap[value] : 0;
|
|
1119
|
+
} else {
|
|
1120
|
+
// RS485数值转Mesh值
|
|
1121
|
+
const rs485ToMesh = { 1: 1, 2: 0, 4: 2, 8: 3 }; // 1=heat->1, 2=cool->0, 4=fan->2, 8=dry->3
|
|
1122
|
+
meshMode = rs485ToMesh[value] !== undefined ? rs485ToMesh[value] : 0;
|
|
1123
|
+
}
|
|
1124
|
+
const param = Buffer.from([meshMode]);
|
|
709
1125
|
await node.gateway.sendControl(meshDevice.networkAddress, 0x16, param);
|
|
710
|
-
node.
|
|
1126
|
+
node.log(`[RS485->Mesh] 空调模式: RS485值${value} -> Mesh值${meshMode}`);
|
|
711
1127
|
}
|
|
1128
|
+
// 风速 - 0x1D是风速属性
|
|
1129
|
+
// RS485: 1=低风, 2=中风, 3=高风
|
|
1130
|
+
// Mesh: 0=自动, 1=低, 2=中, 3=高
|
|
712
1131
|
else if (key === 'fanSpeed') {
|
|
713
|
-
|
|
714
|
-
|
|
1132
|
+
let meshSpeed = 0;
|
|
1133
|
+
if (typeof value === 'string') {
|
|
1134
|
+
const speedMap = { 'low': 1, 'medium': 2, 'high': 3, 'auto': 0 };
|
|
1135
|
+
meshSpeed = speedMap[value] !== undefined ? speedMap[value] : 0;
|
|
1136
|
+
} else {
|
|
1137
|
+
// RS485数值直接对应Mesh值(1=低, 2=中, 3=高)
|
|
1138
|
+
meshSpeed = value;
|
|
1139
|
+
}
|
|
1140
|
+
const param = Buffer.from([meshSpeed]);
|
|
715
1141
|
await node.gateway.sendControl(meshDevice.networkAddress, 0x1D, param);
|
|
716
|
-
node.
|
|
1142
|
+
node.log(`[RS485->Mesh] 空调风速: RS485值${value} -> Mesh值${meshSpeed}`);
|
|
717
1143
|
}
|
|
718
1144
|
else if (key === 'brightness') {
|
|
719
1145
|
const param = Buffer.from([Math.round(value)]);
|
|
@@ -815,6 +1241,52 @@ module.exports = function(RED) {
|
|
|
815
1241
|
const hexStr = frame.toString('hex').toUpperCase();
|
|
816
1242
|
const hexFormatted = hexStr.match(/.{1,2}/g)?.join(' ') || '';
|
|
817
1243
|
|
|
1244
|
+
// ===== 杜亚窗帘协议检测 =====
|
|
1245
|
+
if (frame[0] === 0x55 && frame.length >= 7) {
|
|
1246
|
+
const duyaData = parseA6B6Frame(frame);
|
|
1247
|
+
if (duyaData) {
|
|
1248
|
+
// 查找匹配的杜亚映射
|
|
1249
|
+
for (const mapping of node.mappings) {
|
|
1250
|
+
if (mapping.brand !== 'duya') continue;
|
|
1251
|
+
|
|
1252
|
+
// 检查2字节地址是否匹配
|
|
1253
|
+
const mapAddrHigh = mapping.addrHigh || 1;
|
|
1254
|
+
const mapAddrLow = mapping.addrLow || 1;
|
|
1255
|
+
|
|
1256
|
+
if (duyaData.addrHigh === mapAddrHigh && duyaData.addrLow === mapAddrLow) {
|
|
1257
|
+
// 防死循环: 1秒内忽略响应
|
|
1258
|
+
if (node.lastMeshToRS485Time && Date.now() - node.lastMeshToRS485Time < 1000) {
|
|
1259
|
+
node.debug(`[防循环] 忽略杜亚响应: ${hexFormatted}`);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
node.log(`[杜亚->Mesh] 窗帘响应: ${duyaData.action}, 帧: ${hexFormatted}`);
|
|
1264
|
+
|
|
1265
|
+
// 构建Mesh状态
|
|
1266
|
+
let meshState = {};
|
|
1267
|
+
if (duyaData.action === 'open') {
|
|
1268
|
+
meshState = { action: 'open', curtainAction: 1 };
|
|
1269
|
+
} else if (duyaData.action === 'close') {
|
|
1270
|
+
meshState = { action: 'close', curtainAction: 2 };
|
|
1271
|
+
} else if (duyaData.action === 'stop') {
|
|
1272
|
+
meshState = { action: 'stop', curtainAction: 3 };
|
|
1273
|
+
} else if (duyaData.action === 'position') {
|
|
1274
|
+
meshState = { position: duyaData.position, curtainPosition: duyaData.position };
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
node.queueCommand({
|
|
1278
|
+
direction: 'modbus-to-mesh',
|
|
1279
|
+
mapping: mapping,
|
|
1280
|
+
state: meshState,
|
|
1281
|
+
duyaMode: true,
|
|
1282
|
+
timestamp: Date.now()
|
|
1283
|
+
});
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
818
1290
|
// 首先检查自定义码匹配(遍历所有映射)
|
|
819
1291
|
for (const mapping of node.mappings) {
|
|
820
1292
|
if (mapping.brand === 'custom' && mapping.customCodes) {
|
|
@@ -998,6 +1470,7 @@ module.exports = function(RED) {
|
|
|
998
1470
|
// 事件监听 - Mesh网关共享,无冲突
|
|
999
1471
|
node.gateway.on('device-list-complete', init);
|
|
1000
1472
|
node.gateway.on('device-state-changed', handleMeshStateChange);
|
|
1473
|
+
node.gateway.on('curtain-control', handleCurtainControl); // 窗帘控制立即同步
|
|
1001
1474
|
|
|
1002
1475
|
if (node.gateway.deviceListComplete) {
|
|
1003
1476
|
init();
|
|
@@ -1083,6 +1556,7 @@ module.exports = function(RED) {
|
|
|
1083
1556
|
// 移除Mesh网关事件监听器
|
|
1084
1557
|
node.gateway.removeListener('device-list-complete', init);
|
|
1085
1558
|
node.gateway.removeListener('device-state-changed', handleMeshStateChange);
|
|
1559
|
+
node.gateway.removeListener('curtain-control', handleCurtainControl);
|
|
1086
1560
|
|
|
1087
1561
|
// 移除RS485配置节点事件监听器
|
|
1088
1562
|
if (node.rs485Config && node._rs485Handlers) {
|
|
@@ -1163,4 +1637,5 @@ module.exports = function(RED) {
|
|
|
1163
1637
|
res.json([]);
|
|
1164
1638
|
}
|
|
1165
1639
|
});
|
|
1640
|
+
|
|
1166
1641
|
};
|
package/nodes/symi-485-config.js
CHANGED
|
@@ -114,9 +114,6 @@ module.exports = function(RED) {
|
|
|
114
114
|
|
|
115
115
|
node.client.on('connect', () => {
|
|
116
116
|
node.connected = true;
|
|
117
|
-
// 启用TCP keep-alive,防止连接超时
|
|
118
|
-
node.client.setKeepAlive(true, 30000); // 30秒心跳
|
|
119
|
-
node.client.setNoDelay(true); // 禁用Nagle算法,立即发送
|
|
120
117
|
node.warn(`[RS485] TCP已连接: ${node.host}:${node.port}`);
|
|
121
118
|
node.emit('connected');
|
|
122
119
|
});
|
package/nodes/symi-gateway.js
CHANGED
|
@@ -34,39 +34,7 @@ module.exports = function(RED) {
|
|
|
34
34
|
|
|
35
35
|
this.log(`Initializing Symi Gateway: ${this.connectionType === 'tcp' ? `${this.host}:${this.port}` : this.serialPort}`);
|
|
36
36
|
|
|
37
|
-
//
|
|
38
|
-
this.deviceManager.on('three-in-one-detected', async (device) => {
|
|
39
|
-
this.log(`三合一设备被识别,开始查询所有状态: ${device.name}`);
|
|
40
|
-
try {
|
|
41
|
-
// 查询环境温湿度(优先)
|
|
42
|
-
await this.sendControl(device.networkAddress, 0x16, Buffer.from([]));
|
|
43
|
-
await new Promise(r => setTimeout(r, 100));
|
|
44
|
-
await this.sendControl(device.networkAddress, 0x17, Buffer.from([]));
|
|
45
|
-
await new Promise(r => setTimeout(r, 100));
|
|
46
|
-
// 查询空调状态
|
|
47
|
-
await this.sendControl(device.networkAddress, 0x1B, Buffer.from([]));
|
|
48
|
-
await new Promise(r => setTimeout(r, 100));
|
|
49
|
-
await this.sendControl(device.networkAddress, 0x1C, Buffer.from([]));
|
|
50
|
-
await new Promise(r => setTimeout(r, 100));
|
|
51
|
-
await this.sendControl(device.networkAddress, 0x1D, Buffer.from([]));
|
|
52
|
-
await new Promise(r => setTimeout(r, 100));
|
|
53
|
-
// 查询新风状态
|
|
54
|
-
await this.sendControl(device.networkAddress, 0x68, Buffer.from([]));
|
|
55
|
-
await new Promise(r => setTimeout(r, 100));
|
|
56
|
-
await this.sendControl(device.networkAddress, 0x69, Buffer.from([]));
|
|
57
|
-
await new Promise(r => setTimeout(r, 100));
|
|
58
|
-
await this.sendControl(device.networkAddress, 0x6A, Buffer.from([]));
|
|
59
|
-
await new Promise(r => setTimeout(r, 100));
|
|
60
|
-
// 查询地暖状态
|
|
61
|
-
await this.sendControl(device.networkAddress, 0x6B, Buffer.from([]));
|
|
62
|
-
await new Promise(r => setTimeout(r, 100));
|
|
63
|
-
await this.sendControl(device.networkAddress, 0x6C, Buffer.from([]));
|
|
64
|
-
await new Promise(r => setTimeout(r, 100));
|
|
65
|
-
this.log(`三合一设备状态查询完成`);
|
|
66
|
-
} catch (error) {
|
|
67
|
-
this.warn(`三合一设备状态查询失败: ${error.message}`);
|
|
68
|
-
}
|
|
69
|
-
});
|
|
37
|
+
// 三合一设备检测已在 queryAllDeviceStates 中实现,无需额外事件监听
|
|
70
38
|
|
|
71
39
|
this.connect();
|
|
72
40
|
|
|
@@ -556,7 +524,24 @@ module.exports = function(RED) {
|
|
|
556
524
|
|
|
557
525
|
// 其他控制直接发送
|
|
558
526
|
const frame = this.protocolHandler.buildDeviceControlFrame(networkAddr, attrType, param);
|
|
559
|
-
|
|
527
|
+
const result = await this.client.sendFrame(frame, 1);
|
|
528
|
+
|
|
529
|
+
// 发送窗帘控制命令事件(用于RS485桥接立即同步)
|
|
530
|
+
// 0x05=窗帘动作(1=开,2=关,3=停), 0x06=窗帘位置
|
|
531
|
+
if (attrType === 0x05 || attrType === 0x06) {
|
|
532
|
+
const device = this.deviceManager.getDeviceByAddress(networkAddr);
|
|
533
|
+
if (device) {
|
|
534
|
+
this.emit('curtain-control', {
|
|
535
|
+
device: device,
|
|
536
|
+
mac: device.macAddress,
|
|
537
|
+
attrType: attrType,
|
|
538
|
+
action: attrType === 0x05 ? param[0] : null, // 1=开,2=关,3=停
|
|
539
|
+
position: attrType === 0x06 ? param[0] : null
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return result;
|
|
560
545
|
};
|
|
561
546
|
|
|
562
547
|
SymiGatewayNode.prototype.sendScene = async function(sceneId) {
|