node-red-contrib-symi-mesh 1.6.5 → 1.6.7
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 +313 -329
- package/examples/knx-sync-example.json +48 -410
- package/lib/device-manager.js +30 -3
- package/lib/mqtt-helper.js +3 -3
- package/lib/tcp-client.js +33 -13
- package/nodes/symi-485-bridge.html +15 -3
- package/nodes/symi-485-bridge.js +747 -206
- package/nodes/symi-485-config.js +90 -5
- package/nodes/symi-device.js +7 -7
- package/nodes/symi-gateway.js +86 -14
- package/nodes/symi-knx-bridge.html +368 -0
- package/nodes/symi-knx-bridge.js +1065 -0
- package/nodes/symi-mqtt.js +74 -25
- package/package.json +5 -4
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
|
|
|
@@ -126,7 +157,12 @@ module.exports = function(RED) {
|
|
|
126
157
|
});
|
|
127
158
|
|
|
128
159
|
node.client.on('error', (err) => {
|
|
129
|
-
|
|
160
|
+
// AggregateError特殊处理(Node.js 18+的IPv4/IPv6连接失败)
|
|
161
|
+
if (err.name === 'AggregateError' || err.errors) {
|
|
162
|
+
node.warn(`RS485 TCP连接失败: 无法连接到 ${node.host}:${node.port}`);
|
|
163
|
+
} else {
|
|
164
|
+
node.error(`RS485 TCP错误: ${err.message}`);
|
|
165
|
+
}
|
|
130
166
|
node.emit('error', err);
|
|
131
167
|
});
|
|
132
168
|
|
|
@@ -146,7 +182,16 @@ module.exports = function(RED) {
|
|
|
146
182
|
}
|
|
147
183
|
});
|
|
148
184
|
|
|
149
|
-
|
|
185
|
+
// 使用family:4强制IPv4,避免Node.js 18+ Happy Eyeballs导致AggregateError
|
|
186
|
+
try {
|
|
187
|
+
node.client.connect({
|
|
188
|
+
port: node.port,
|
|
189
|
+
host: node.host,
|
|
190
|
+
family: 4 // 强制IPv4,避免IPv6连接失败导致AggregateError
|
|
191
|
+
});
|
|
192
|
+
} catch (connectErr) {
|
|
193
|
+
node.error(`RS485 TCP连接异常: ${connectErr.message}`);
|
|
194
|
+
}
|
|
150
195
|
}
|
|
151
196
|
} catch (err) {
|
|
152
197
|
node.error(`RS485连接失败: ${err.message}`);
|
|
@@ -168,7 +213,7 @@ module.exports = function(RED) {
|
|
|
168
213
|
node.connected = false;
|
|
169
214
|
};
|
|
170
215
|
|
|
171
|
-
//
|
|
216
|
+
// 处理接收数据(支持Modbus RTU和杜亚协议)
|
|
172
217
|
node.handleData = function(data) {
|
|
173
218
|
node.receiveBuffer = Buffer.concat([node.receiveBuffer, data]);
|
|
174
219
|
|
|
@@ -178,8 +223,39 @@ module.exports = function(RED) {
|
|
|
178
223
|
node.warn('接收缓冲区溢出,已截断');
|
|
179
224
|
}
|
|
180
225
|
|
|
181
|
-
//
|
|
226
|
+
// 最小帧长度为4字节
|
|
182
227
|
while (node.receiveBuffer.length >= 4) {
|
|
228
|
+
// ===== 杜亚协议检测 (55开头) =====
|
|
229
|
+
if (node.receiveBuffer[0] === 0x55 && node.receiveBuffer.length >= 7) {
|
|
230
|
+
// 杜亚窗帘协议:55 [地址高] [地址低] 03 [数据] [CRC16低] [CRC16高]
|
|
231
|
+
// 固定7字节(基本命令)或8字节(带百分比)
|
|
232
|
+
const funcCode = node.receiveBuffer[3];
|
|
233
|
+
let duyaLen = 7;
|
|
234
|
+
if (funcCode === 0x03 && node.receiveBuffer.length >= 7) {
|
|
235
|
+
const dataType = node.receiveBuffer[4];
|
|
236
|
+
if (dataType === 0x04) {
|
|
237
|
+
duyaLen = 8; // 百分比命令多一个字节
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (node.receiveBuffer.length >= duyaLen) {
|
|
242
|
+
const frame = node.receiveBuffer.subarray(0, duyaLen);
|
|
243
|
+
node.receiveBuffer = node.receiveBuffer.subarray(duyaLen);
|
|
244
|
+
|
|
245
|
+
// 验证杜亚CRC16
|
|
246
|
+
if (node.validateDuyaCRC(frame)) {
|
|
247
|
+
node.emit('frame', frame);
|
|
248
|
+
} else {
|
|
249
|
+
node.debug(`杜亚CRC校验失败: ${frame.toString('hex').toUpperCase()}`);
|
|
250
|
+
// CRC失败也尝试emit,让上层处理
|
|
251
|
+
node.emit('frame', frame);
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ===== 标准Modbus RTU协议 =====
|
|
183
259
|
const frameLen = node.getFrameLength(node.receiveBuffer);
|
|
184
260
|
if (frameLen === 0 || node.receiveBuffer.length < frameLen) break;
|
|
185
261
|
|
|
@@ -191,6 +267,15 @@ module.exports = function(RED) {
|
|
|
191
267
|
}
|
|
192
268
|
}
|
|
193
269
|
};
|
|
270
|
+
|
|
271
|
+
// 验证杜亚CRC16
|
|
272
|
+
node.validateDuyaCRC = function(frame) {
|
|
273
|
+
if (frame.length < 7) return false;
|
|
274
|
+
const data = frame.subarray(0, frame.length - 2);
|
|
275
|
+
const receivedCRC = frame.readUInt16LE(frame.length - 2);
|
|
276
|
+
const calculatedCRC = node.calculateCRC16(data);
|
|
277
|
+
return receivedCRC === calculatedCRC;
|
|
278
|
+
};
|
|
194
279
|
|
|
195
280
|
// 获取帧长度
|
|
196
281
|
node.getFrameLength = function(buffer) {
|
package/nodes/symi-device.js
CHANGED
|
@@ -171,13 +171,13 @@ module.exports = function(RED) {
|
|
|
171
171
|
command = { attrType: 0x04, param: Buffer.from([colorTemp]) };
|
|
172
172
|
|
|
173
173
|
} else if (msg.command === 'rgb' && typeof payload === 'object') {
|
|
174
|
-
// 五色调光:RGB+WW+CW,参数范围0-
|
|
175
|
-
const r = Math.max(0, Math.min(
|
|
176
|
-
const g = Math.max(0, Math.min(
|
|
177
|
-
const b = Math.max(0, Math.min(
|
|
178
|
-
const ww = Math.max(0, Math.min(
|
|
179
|
-
const cw = Math.max(0, Math.min(
|
|
180
|
-
command = { attrType:
|
|
174
|
+
// 五色调光:RGB+WW+CW,参数范围0-255
|
|
175
|
+
const r = Math.max(0, Math.min(255, parseInt(payload.r || 0)));
|
|
176
|
+
const g = Math.max(0, Math.min(255, parseInt(payload.g || 0)));
|
|
177
|
+
const b = Math.max(0, Math.min(255, parseInt(payload.b || 0)));
|
|
178
|
+
const ww = Math.max(0, Math.min(255, parseInt(payload.ww || 0)));
|
|
179
|
+
const cw = Math.max(0, Math.min(255, parseInt(payload.cw || 0)));
|
|
180
|
+
command = { attrType: 0x4C, param: Buffer.from([r, g, b, ww, cw]) };
|
|
181
181
|
|
|
182
182
|
} else if (typeof payload === 'boolean' || typeof payload === 'string') {
|
|
183
183
|
const value = typeof payload === 'boolean' ? payload :
|
package/nodes/symi-gateway.js
CHANGED
|
@@ -2,11 +2,39 @@
|
|
|
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
|
|
|
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;
|
|
19
|
+
|
|
20
|
+
process.on('uncaughtException', (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;
|
|
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);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
process.on('unhandledRejection', (reason) => {
|
|
34
|
+
console.error('[symi] Promise rejection:', reason?.message || reason);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
10
38
|
module.exports = function(RED) {
|
|
11
39
|
function SymiGatewayNode(config) {
|
|
12
40
|
RED.nodes.createNode(this, config);
|
|
@@ -126,17 +154,22 @@ module.exports = function(RED) {
|
|
|
126
154
|
this.parseDeviceListFrame(frame);
|
|
127
155
|
} else if (frame.isDeviceStatusEvent()) {
|
|
128
156
|
// 将状态事件加入队列处理,限制队列大小防止内存泄漏
|
|
157
|
+
// 同时记录接收时间,用于判断是否在0xB0之后
|
|
158
|
+
const frameWithTime = { frame, receiveTime: Date.now() };
|
|
129
159
|
if (this.stateEventQueue.length < this.maxQueueSize) {
|
|
130
|
-
this.stateEventQueue.push(
|
|
160
|
+
this.stateEventQueue.push(frameWithTime);
|
|
131
161
|
this.processStateEventQueue();
|
|
132
162
|
} else {
|
|
133
163
|
this.debug(`状态事件队列已满(${this.maxQueueSize}),丢弃旧事件`);
|
|
134
164
|
this.stateEventQueue.shift(); // 移除最旧的事件
|
|
135
|
-
this.stateEventQueue.push(
|
|
165
|
+
this.stateEventQueue.push(frameWithTime);
|
|
136
166
|
}
|
|
137
167
|
} else if (frame.opcode === 0xB0) {
|
|
138
|
-
// 控制命令响应
|
|
139
|
-
|
|
168
|
+
// 控制命令响应 - 记录时间戳,用于标记后续帧为用户控制
|
|
169
|
+
// 规则:53 B0 后面紧跟的第一个窗帘帧就是真实用户控制
|
|
170
|
+
// 注意:必须用时间戳而不是标记,因为队列中可能有旧帧等待处理
|
|
171
|
+
this.lastB0Time = Date.now();
|
|
172
|
+
this.log(`[控制响应] 0xB0: ${frameHex},记录时间戳`)
|
|
140
173
|
} else if (frame.opcode === 0xB4) {
|
|
141
174
|
// 场景控制响应
|
|
142
175
|
if (frame.status === 0) {
|
|
@@ -168,7 +201,9 @@ module.exports = function(RED) {
|
|
|
168
201
|
this.isProcessingStateEvent = true;
|
|
169
202
|
|
|
170
203
|
while (this.stateEventQueue.length > 0) {
|
|
171
|
-
const
|
|
204
|
+
const frameWithTime = this.stateEventQueue.shift();
|
|
205
|
+
const frame = frameWithTime.frame;
|
|
206
|
+
const receiveTime = frameWithTime.receiveTime;
|
|
172
207
|
|
|
173
208
|
try {
|
|
174
209
|
const event = parseStatusEvent(frame);
|
|
@@ -233,12 +268,44 @@ module.exports = function(RED) {
|
|
|
233
268
|
// 只在非查询状态期间才发送state-changed事件到MQTT
|
|
234
269
|
// 查询状态期间的事件只用于更新设备状态,不触发MQTT发布
|
|
235
270
|
if (device && !this.isQueryingStates) {
|
|
271
|
+
// 【兼容两种协议】判断用户控制:
|
|
272
|
+
// 小程序协议: subOpcode=0x05 (NODE_ACK) = 设备确认执行命令
|
|
273
|
+
// 米家协议: subOpcode=0x06 (NODE_STATUS) + status=0/1 = 用户操作
|
|
274
|
+
//
|
|
275
|
+
// 米家协议特征:status=0(打开中)或status=1(关闭中)只会在用户操作时出现
|
|
276
|
+
// 电机反馈通常是status=2(停止)或位置变化(attrType=0x06)
|
|
277
|
+
const isCurtainEvent = (event.attrType === 0x05 || event.attrType === 0x06);
|
|
278
|
+
let isUserControl = false;
|
|
279
|
+
|
|
280
|
+
if (isCurtainEvent) {
|
|
281
|
+
if (event.subOpcode === 0x05) {
|
|
282
|
+
// NODE_ACK: 小程序协议,设备确认执行命令
|
|
283
|
+
isUserControl = true;
|
|
284
|
+
} else if (event.subOpcode === 0x06) {
|
|
285
|
+
// NODE_STATUS: 米家协议
|
|
286
|
+
if (event.attrType === 0x05) {
|
|
287
|
+
// CURT_RUN_STATUS: 状态变化
|
|
288
|
+
// status=0(打开中), status=1(关闭中), status=2(暂停) 都是用户控制
|
|
289
|
+
const status = event.parameters && event.parameters.length > 0 ? event.parameters[0] : null;
|
|
290
|
+
if (status === 0 || status === 1 || status === 2) {
|
|
291
|
+
isUserControl = true;
|
|
292
|
+
}
|
|
293
|
+
} else if (event.attrType === 0x06) {
|
|
294
|
+
// CURT_RUN_PER_POS: 位置变化
|
|
295
|
+
// 位置变化也需要同步,标记为用户控制
|
|
296
|
+
isUserControl = true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
236
301
|
this.emit('device-state-changed', {
|
|
237
302
|
device: device,
|
|
238
303
|
attrType: event.attrType,
|
|
239
304
|
parameters: event.parameters,
|
|
240
305
|
state: device.state,
|
|
241
|
-
|
|
306
|
+
subOpcode: event.subOpcode,
|
|
307
|
+
isUserControl: isUserControl, // 0xB0后500ms内的第一个窗帘帧
|
|
308
|
+
isSceneExecution: this.sceneExecutionInProgress
|
|
242
309
|
});
|
|
243
310
|
}
|
|
244
311
|
|
|
@@ -299,14 +366,19 @@ module.exports = function(RED) {
|
|
|
299
366
|
|
|
300
367
|
// 在后台异步查询设备状态和确认三合一设备
|
|
301
368
|
setTimeout(async () => {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
369
|
+
try {
|
|
370
|
+
this.log('开始后台查询设备状态...');
|
|
371
|
+
this.isQueryingStates = true;
|
|
372
|
+
await this.queryAllDeviceStates();
|
|
373
|
+
this.isQueryingStates = false;
|
|
374
|
+
this.log('后台状态查询完成');
|
|
375
|
+
|
|
376
|
+
// 状态查询完成后,如果有设备类型变化(如三合一确认),重新发布MQTT Discovery
|
|
377
|
+
this.emit('device-states-synced', this.deviceManager.getAllDevices());
|
|
378
|
+
} catch (err) {
|
|
379
|
+
this.isQueryingStates = false;
|
|
380
|
+
this.error(`后台查询设备状态失败: ${err.message}`);
|
|
381
|
+
}
|
|
310
382
|
}, 500);
|
|
311
383
|
}
|
|
312
384
|
};
|