node-red-contrib-symi-mesh 1.6.5 → 1.6.6
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 +219 -27
- package/lib/device-manager.js +30 -3
- package/lib/mqtt-helper.js +3 -3
- package/lib/tcp-client.js +29 -13
- package/nodes/symi-485-bridge.html +15 -3
- package/nodes/symi-485-bridge.js +747 -206
- package/nodes/symi-485-config.js +54 -4
- package/nodes/symi-device.js +7 -7
- package/nodes/symi-gateway.js +86 -14
- package/nodes/symi-mqtt.js +22 -15
- package/package.json +2 -2
package/nodes/symi-485-config.js
CHANGED
|
@@ -126,7 +126,12 @@ module.exports = function(RED) {
|
|
|
126
126
|
});
|
|
127
127
|
|
|
128
128
|
node.client.on('error', (err) => {
|
|
129
|
-
|
|
129
|
+
// AggregateError特殊处理(Node.js 18+的IPv4/IPv6连接失败)
|
|
130
|
+
if (err.name === 'AggregateError' || err.errors) {
|
|
131
|
+
node.warn(`RS485 TCP连接失败: 无法连接到 ${node.host}:${node.port}`);
|
|
132
|
+
} else {
|
|
133
|
+
node.error(`RS485 TCP错误: ${err.message}`);
|
|
134
|
+
}
|
|
130
135
|
node.emit('error', err);
|
|
131
136
|
});
|
|
132
137
|
|
|
@@ -146,7 +151,12 @@ module.exports = function(RED) {
|
|
|
146
151
|
}
|
|
147
152
|
});
|
|
148
153
|
|
|
149
|
-
|
|
154
|
+
// 使用try-catch包装connect,防止AggregateError导致崩溃
|
|
155
|
+
try {
|
|
156
|
+
node.client.connect(node.port, node.host);
|
|
157
|
+
} catch (connectErr) {
|
|
158
|
+
node.error(`RS485 TCP连接异常: ${connectErr.message}`);
|
|
159
|
+
}
|
|
150
160
|
}
|
|
151
161
|
} catch (err) {
|
|
152
162
|
node.error(`RS485连接失败: ${err.message}`);
|
|
@@ -168,7 +178,7 @@ module.exports = function(RED) {
|
|
|
168
178
|
node.connected = false;
|
|
169
179
|
};
|
|
170
180
|
|
|
171
|
-
//
|
|
181
|
+
// 处理接收数据(支持Modbus RTU和杜亚协议)
|
|
172
182
|
node.handleData = function(data) {
|
|
173
183
|
node.receiveBuffer = Buffer.concat([node.receiveBuffer, data]);
|
|
174
184
|
|
|
@@ -178,8 +188,39 @@ module.exports = function(RED) {
|
|
|
178
188
|
node.warn('接收缓冲区溢出,已截断');
|
|
179
189
|
}
|
|
180
190
|
|
|
181
|
-
//
|
|
191
|
+
// 最小帧长度为4字节
|
|
182
192
|
while (node.receiveBuffer.length >= 4) {
|
|
193
|
+
// ===== 杜亚协议检测 (55开头) =====
|
|
194
|
+
if (node.receiveBuffer[0] === 0x55 && node.receiveBuffer.length >= 7) {
|
|
195
|
+
// 杜亚窗帘协议:55 [地址高] [地址低] 03 [数据] [CRC16低] [CRC16高]
|
|
196
|
+
// 固定7字节(基本命令)或8字节(带百分比)
|
|
197
|
+
const funcCode = node.receiveBuffer[3];
|
|
198
|
+
let duyaLen = 7;
|
|
199
|
+
if (funcCode === 0x03 && node.receiveBuffer.length >= 7) {
|
|
200
|
+
const dataType = node.receiveBuffer[4];
|
|
201
|
+
if (dataType === 0x04) {
|
|
202
|
+
duyaLen = 8; // 百分比命令多一个字节
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (node.receiveBuffer.length >= duyaLen) {
|
|
207
|
+
const frame = node.receiveBuffer.subarray(0, duyaLen);
|
|
208
|
+
node.receiveBuffer = node.receiveBuffer.subarray(duyaLen);
|
|
209
|
+
|
|
210
|
+
// 验证杜亚CRC16
|
|
211
|
+
if (node.validateDuyaCRC(frame)) {
|
|
212
|
+
node.emit('frame', frame);
|
|
213
|
+
} else {
|
|
214
|
+
node.debug(`杜亚CRC校验失败: ${frame.toString('hex').toUpperCase()}`);
|
|
215
|
+
// CRC失败也尝试emit,让上层处理
|
|
216
|
+
node.emit('frame', frame);
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ===== 标准Modbus RTU协议 =====
|
|
183
224
|
const frameLen = node.getFrameLength(node.receiveBuffer);
|
|
184
225
|
if (frameLen === 0 || node.receiveBuffer.length < frameLen) break;
|
|
185
226
|
|
|
@@ -191,6 +232,15 @@ module.exports = function(RED) {
|
|
|
191
232
|
}
|
|
192
233
|
}
|
|
193
234
|
};
|
|
235
|
+
|
|
236
|
+
// 验证杜亚CRC16
|
|
237
|
+
node.validateDuyaCRC = function(frame) {
|
|
238
|
+
if (frame.length < 7) return false;
|
|
239
|
+
const data = frame.subarray(0, frame.length - 2);
|
|
240
|
+
const receivedCRC = frame.readUInt16LE(frame.length - 2);
|
|
241
|
+
const calculatedCRC = node.calculateCRC16(data);
|
|
242
|
+
return receivedCRC === calculatedCRC;
|
|
243
|
+
};
|
|
194
244
|
|
|
195
245
|
// 获取帧长度
|
|
196
246
|
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
|
@@ -7,6 +7,34 @@ const SerialClient = require('../lib/serial-client');
|
|
|
7
7
|
const { DeviceManager } = require('../lib/device-manager');
|
|
8
8
|
const { ProtocolHandler, parseStatusEvent, OP_RESP_DEVICE_LIST } = require('../lib/protocol');
|
|
9
9
|
|
|
10
|
+
// 全局异常处理 - 防止AggregateError等网络错误导致Node-RED崩溃
|
|
11
|
+
// 只在模块首次加载时设置一次
|
|
12
|
+
if (!global._symiGatewayErrorHandlerSet) {
|
|
13
|
+
global._symiGatewayErrorHandlerSet = true;
|
|
14
|
+
|
|
15
|
+
process.on('uncaughtException', (err) => {
|
|
16
|
+
// 只处理网络相关的错误,其他错误继续抛出
|
|
17
|
+
if (err.name === 'AggregateError' ||
|
|
18
|
+
(err.code && ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENETUNREACH', 'EHOSTUNREACH'].includes(err.code))) {
|
|
19
|
+
console.error('[symi-gateway] 网络异常已捕获,避免崩溃:', err.message || err);
|
|
20
|
+
// 不重新抛出,防止崩溃
|
|
21
|
+
} else {
|
|
22
|
+
// 非网络错误,继续原有行为
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
28
|
+
// 检查是否是网络相关的Promise rejection
|
|
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默认处理
|
|
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
|
};
|
package/nodes/symi-mqtt.js
CHANGED
|
@@ -258,7 +258,7 @@ module.exports = function(RED) {
|
|
|
258
258
|
SymiMQTTNode.prototype.publishAllDiscovery = function(devices, forceUpdate = false) {
|
|
259
259
|
const node = this;
|
|
260
260
|
|
|
261
|
-
const supportedTypes = [1, 2, 3, 4, 5, 8, 9, 10, 0x18, 39];
|
|
261
|
+
const supportedTypes = [1, 2, 3, 4, 5, 8, 9, 10, 11, 0x18, 39];
|
|
262
262
|
|
|
263
263
|
devices.forEach(device => {
|
|
264
264
|
const macClean = device.macAddress.replace(/:/g, '').toLowerCase();
|
|
@@ -641,23 +641,24 @@ module.exports = function(RED) {
|
|
|
641
641
|
|
|
642
642
|
case 0x05:
|
|
643
643
|
// 窗帘运行状态反馈 (CURT_RUN_STATUS)
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
644
|
+
// 【兼容两种协议】:
|
|
645
|
+
// 米家协议: 0=打开中, 1=关闭中, 2=停止
|
|
646
|
+
// 小程序协议: 1=打开, 2=关闭, 3=停止
|
|
647
|
+
// 使用curtainAction(由device-manager解析)来发布状态
|
|
648
|
+
if (state.curtainAction !== undefined) {
|
|
648
649
|
publishes.push({
|
|
649
650
|
topic: `symi_mesh/${macClean}/cover/state`,
|
|
650
|
-
payload:
|
|
651
|
+
payload: state.curtainAction
|
|
651
652
|
});
|
|
652
|
-
node.debug(`发布窗帘运行状态: ${
|
|
653
|
+
node.debug(`发布窗帘运行状态: ${state.curtainAction} (原始值=${state.curtainStatus}, 协议=${state.curtainProtocol || 'unknown'})`);
|
|
653
654
|
|
|
654
|
-
//
|
|
655
|
-
if (state.
|
|
655
|
+
// 停止时也发布position,确保HA显示正确位置
|
|
656
|
+
if (state.curtainAction === 'stopped' && state.curtainPosition !== undefined) {
|
|
656
657
|
publishes.push({
|
|
657
658
|
topic: `symi_mesh/${macClean}/cover/position`,
|
|
658
659
|
payload: state.curtainPosition.toString()
|
|
659
660
|
});
|
|
660
|
-
node.debug(
|
|
661
|
+
node.debug(`窗帘停止,同时发布位置: ${state.curtainPosition}%`);
|
|
661
662
|
}
|
|
662
663
|
}
|
|
663
664
|
break;
|
|
@@ -672,14 +673,13 @@ module.exports = function(RED) {
|
|
|
672
673
|
node.debug(`发布窗帘位置: ${state.curtainPosition}%`);
|
|
673
674
|
|
|
674
675
|
// 同时发布运行状态(确保HA正确显示)
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
const curtainState = curtainStates[state.curtainStatus] || 'stopped';
|
|
676
|
+
// 使用curtainAction(由device-manager解析,兼容米家和小程序协议)
|
|
677
|
+
if (state.curtainAction !== undefined) {
|
|
678
678
|
publishes.push({
|
|
679
679
|
topic: `symi_mesh/${macClean}/cover/state`,
|
|
680
|
-
payload:
|
|
680
|
+
payload: state.curtainAction
|
|
681
681
|
});
|
|
682
|
-
node.debug(`同时发布窗帘状态: ${
|
|
682
|
+
node.debug(`同时发布窗帘状态: ${state.curtainAction}`);
|
|
683
683
|
}
|
|
684
684
|
}
|
|
685
685
|
break;
|
|
@@ -1312,6 +1312,13 @@ module.exports = function(RED) {
|
|
|
1312
1312
|
const action = actions[payload];
|
|
1313
1313
|
if (action) {
|
|
1314
1314
|
commands.push({ attrType: 0x05, param: Buffer.from([action]) });
|
|
1315
|
+
|
|
1316
|
+
// 发出窗帘控制事件,通知485桥用户的真实意图
|
|
1317
|
+
this.emit('curtain-control', {
|
|
1318
|
+
mac: device.macAddress,
|
|
1319
|
+
action: payload // 'OPEN', 'CLOSE', 'STOP'
|
|
1320
|
+
});
|
|
1321
|
+
this.debug(`[MQTT] 窗帘控制命令: ${payload}, MAC=${device.macAddress}`);
|
|
1315
1322
|
}
|
|
1316
1323
|
|
|
1317
1324
|
} else if (topic.includes('/position/set')) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-symi-mesh",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.6",
|
|
4
4
|
"description": "Node-RED节点集合,用于通过TCP/串口连接Symi蓝牙Mesh网关,支持Home Assistant MQTT Discovery自动发现和云端数据同步",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"axios": "^1.7.9",
|
|
42
|
-
"mqtt": "^5.
|
|
42
|
+
"mqtt": "^5.10.0",
|
|
43
43
|
"serialport": "^12.0.0"
|
|
44
44
|
},
|
|
45
45
|
"engines": {
|