node-red-contrib-symi-mesh 1.2.3

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.
@@ -0,0 +1,510 @@
1
+ /**
2
+ * Symi Gateway Protocol Implementation
3
+ * Converted from Python to JavaScript
4
+ */
5
+
6
+ const PROTOCOL_HEADER = 0x53;
7
+ const MIN_FRAME_LENGTH = 4;
8
+
9
+ // Operation codes
10
+ const OP_READ_DEVICE_LIST = 0x12;
11
+ const OP_DEVICE_CONTROL = 0x30;
12
+ const OP_TRANSPARENT_CONTROL = 0x40;
13
+ const OP_EVENT_NODE_STATUS = 0x80;
14
+ const OP_EVENT_TRANSPARENT_MSG = 0xC0;
15
+ const OP_RESP_DEVICE_LIST = 0x92;
16
+ const OP_RESP_DEVICE_CONTROL = 0xB0;
17
+ const OP_SCENE_CONTROL = 0x31;
18
+ const OP_DEVICE_STATUS_QUERY = 0x32;
19
+
20
+ class ProtocolFrame {
21
+ constructor(header, opcode, length, payload, checksum, status = null) {
22
+ this.header = header;
23
+ this.opcode = opcode;
24
+ this.length = length;
25
+ this.payload = payload;
26
+ this.checksum = checksum;
27
+ this.status = status;
28
+ }
29
+
30
+ isResponse() {
31
+ return this.opcode >= 0x81;
32
+ }
33
+
34
+ isEvent() {
35
+ return this.opcode === OP_EVENT_NODE_STATUS ||
36
+ this.opcode === OP_EVENT_TRANSPARENT_MSG ||
37
+ (this.opcode === 0x90 && [0x02, 0x03, 0x04, 0x05, 0x06].includes(this.status));
38
+ }
39
+
40
+ isDeviceStatusEvent() {
41
+ return this.opcode === OP_EVENT_NODE_STATUS;
42
+ }
43
+
44
+ isTransparentEvent() {
45
+ return this.opcode === OP_EVENT_TRANSPARENT_MSG;
46
+ }
47
+ }
48
+
49
+ class ProtocolHandler {
50
+ constructor() {
51
+ this.buffer = Buffer.alloc(0);
52
+ this.lastDataTime = 0;
53
+ this.bufferTimeout = 10000; // 10 seconds
54
+ }
55
+
56
+ /**
57
+ * 构建开关状态值
58
+ * @param {number} channels - 开关路数 (1-6)
59
+ * @param {number} targetChannel - 目标路数 (1-6)
60
+ * @param {boolean} targetState - 目标状态 (true=开, false=关)
61
+ * @param {number|null} currentState - 当前状态值,null时使用默认全关状态
62
+ * @returns {number|Buffer} - 1-4路返回number,6路返回Buffer(2字节)
63
+ */
64
+ buildSwitchState(channels, targetChannel, targetState, currentState = null) {
65
+ // 验证参数
66
+ if (channels < 1 || channels > 6) {
67
+ throw new Error(`Invalid channels count: ${channels}, must be 1-6`);
68
+ }
69
+ if (targetChannel < 1 || targetChannel > channels) {
70
+ throw new Error(`Invalid target channel: ${targetChannel}, must be 1-${channels}`);
71
+ }
72
+
73
+ // 单路开关直接返回状态
74
+ if (channels === 1) {
75
+ return targetState ? 0x02 : 0x01;
76
+ }
77
+
78
+ // 多路开关状态组合
79
+ let stateValue;
80
+
81
+ if (channels <= 4) {
82
+ // 1-4路开关使用1字节,默认全关状态
83
+ const defaultStates = {
84
+ 2: 0x05, // 01 01 (2路全关)
85
+ 3: 0x15, // 01 01 01 (3路全关)
86
+ 4: 0x55 // 01 01 01 01 (4路全关)
87
+ };
88
+ stateValue = currentState !== null ? currentState : defaultStates[channels];
89
+
90
+ // 计算目标路的位位置 (每路2位)
91
+ const bitPos = (targetChannel - 1) * 2;
92
+ const mask = ~(0x03 << bitPos); // 清除目标位
93
+ const newBits = (targetState ? 0x02 : 0x01) << bitPos; // 新状态位
94
+
95
+ stateValue = (stateValue & mask) | newBits;
96
+ return stateValue;
97
+ } else if (channels === 6) {
98
+ // 6路开关,2字节状态,小端序
99
+ const defaultState = 0x5555; // 全关状态
100
+ stateValue = currentState !== null ? currentState : defaultState;
101
+
102
+ // 6路开关状态组合
103
+ const bitPos = (targetChannel - 1) * 2;
104
+ const mask = ~(0x03 << bitPos);
105
+ const newBits = (targetState ? 0x02 : 0x01) << bitPos;
106
+
107
+ stateValue = (stateValue & mask) | newBits;
108
+
109
+ // 返回小端序Buffer
110
+ return Buffer.from([stateValue & 0xFF, (stateValue >> 8) & 0xFF]);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 解析开关状态值
116
+ * @param {number|Buffer} stateValue - 状态值
117
+ * @param {number} channels - 开关路数
118
+ * @returns {Array<boolean>} - 每路的开关状态数组
119
+ */
120
+ parseSwitchState(stateValue, channels) {
121
+ let value;
122
+ if (Buffer.isBuffer(stateValue)) {
123
+ // 6路开关,小端序解析
124
+ value = stateValue[0] | (stateValue[1] << 8);
125
+ } else {
126
+ value = stateValue;
127
+ }
128
+
129
+ const states = [];
130
+ for (let i = 0; i < channels; i++) {
131
+ const bitPos = i * 2;
132
+ const bits = (value >> bitPos) & 0x03;
133
+ states.push(bits === 0x02); // 0x02=开, 0x01=关
134
+ }
135
+ return states;
136
+ }
137
+
138
+ calculateChecksum(data) {
139
+ let checksum = 0;
140
+ for (let i = 0; i < data.length; i++) {
141
+ checksum ^= data[i];
142
+ }
143
+ return checksum & 0xFF;
144
+ }
145
+
146
+ buildFrame(opcode, data = Buffer.alloc(0)) {
147
+ const header = PROTOCOL_HEADER;
148
+ const length = data.length;
149
+
150
+ // Build frame without checksum
151
+ const frameWithoutChecksum = Buffer.concat([
152
+ Buffer.from([header, opcode, length]),
153
+ data
154
+ ]);
155
+
156
+ // Calculate checksum
157
+ const checksum = this.calculateChecksum(frameWithoutChecksum);
158
+
159
+ // Build complete frame
160
+ return Buffer.concat([frameWithoutChecksum, Buffer.from([checksum])]);
161
+ }
162
+
163
+ parseFrame(data) {
164
+ if (data.length < MIN_FRAME_LENGTH) {
165
+ return null;
166
+ }
167
+
168
+ const header = data[0];
169
+ if (header !== PROTOCOL_HEADER) {
170
+ return null;
171
+ }
172
+
173
+ const opcode = data[1];
174
+
175
+ // 0x80事件和响应都使用响应格式:53 [opcode] [status/sub_opcode] [length] [data] [checksum]
176
+ if (opcode >= 0x80) {
177
+ if (data.length < 5) {
178
+ return null;
179
+ }
180
+
181
+ const status = data[2];
182
+ const paramLength = data[3];
183
+ const expectedLength = 5 + paramLength;
184
+
185
+ if (data.length !== expectedLength) {
186
+ return null;
187
+ }
188
+
189
+ const payload = data.slice(4, 4 + paramLength);
190
+ const checksum = data[4 + paramLength];
191
+
192
+ // Verify checksum
193
+ const calculatedChecksum = this.calculateChecksum(data.slice(0, -1));
194
+ if (checksum !== calculatedChecksum) {
195
+ return null;
196
+ }
197
+
198
+ return new ProtocolFrame(header, opcode, paramLength, payload, checksum, status);
199
+ } else { // Send format
200
+ const paramLength = data[2];
201
+ const expectedLength = 4 + paramLength;
202
+
203
+ if (data.length !== expectedLength) {
204
+ return null;
205
+ }
206
+
207
+ const payload = data.slice(3, 3 + paramLength);
208
+ const checksum = data[3 + paramLength];
209
+
210
+ const calculatedChecksum = this.calculateChecksum(data.slice(0, -1));
211
+ if (checksum !== calculatedChecksum) {
212
+ return null;
213
+ }
214
+
215
+ return new ProtocolFrame(header, opcode, paramLength, payload, checksum);
216
+ }
217
+ }
218
+
219
+ addData(data) {
220
+ const currentTime = Date.now();
221
+
222
+ // Check buffer timeout
223
+ if (this.buffer.length > 0 && (currentTime - this.lastDataTime) > this.bufferTimeout) {
224
+ this.buffer = Buffer.alloc(0);
225
+ }
226
+
227
+ this.buffer = Buffer.concat([this.buffer, data]);
228
+ this.lastDataTime = currentTime;
229
+ const frames = [];
230
+
231
+ while (this.buffer.length >= MIN_FRAME_LENGTH) {
232
+ // Look for frame header
233
+ let headerIndex = -1;
234
+ for (let i = 0; i < this.buffer.length; i++) {
235
+ if (this.buffer[i] === PROTOCOL_HEADER) {
236
+ headerIndex = i;
237
+ break;
238
+ }
239
+ }
240
+
241
+ if (headerIndex === -1) {
242
+ if (this.buffer.length > 2) {
243
+ this.buffer = this.buffer.slice(-2);
244
+ } else {
245
+ this.buffer = Buffer.alloc(0);
246
+ }
247
+ break;
248
+ }
249
+
250
+ if (headerIndex > 0) {
251
+ this.buffer = this.buffer.slice(headerIndex);
252
+ }
253
+
254
+ if (this.buffer.length < 3) {
255
+ break;
256
+ }
257
+
258
+ const opcode = this.buffer[1];
259
+ let totalFrameLength;
260
+
261
+ // 0x80和0xC0事件使用响应格式(5字节头部)
262
+ // 0x81及以上的响应也使用响应格式(5字节头部)
263
+ if (opcode >= 0x80) {
264
+ if (this.buffer.length < 4) {
265
+ break;
266
+ }
267
+ const paramLength = this.buffer[3];
268
+ totalFrameLength = 5 + paramLength;
269
+ } else {
270
+ // 发送格式(4字节头部)
271
+ const paramLength = this.buffer[2];
272
+ totalFrameLength = 4 + paramLength;
273
+ }
274
+
275
+ if (totalFrameLength > 1024) {
276
+ this.buffer = this.buffer.slice(1);
277
+ continue;
278
+ }
279
+
280
+ if (this.buffer.length < totalFrameLength) {
281
+ break;
282
+ }
283
+
284
+ const frameData = this.buffer.slice(0, totalFrameLength);
285
+ const frame = this.parseFrame(frameData);
286
+
287
+ if (frame) {
288
+ frames.push(frame);
289
+ }
290
+
291
+ this.buffer = this.buffer.slice(totalFrameLength);
292
+ }
293
+
294
+ return frames;
295
+ }
296
+
297
+ clearBuffer() {
298
+ this.buffer = Buffer.alloc(0);
299
+ }
300
+
301
+ buildReadDeviceListFrame() {
302
+ return this.buildFrame(OP_READ_DEVICE_LIST);
303
+ }
304
+
305
+ buildDeviceControlFrame(networkAddr, attrType, param = Buffer.alloc(0)) {
306
+ // 地址使用小端序(低字节在前)
307
+ const addrLow = networkAddr & 0xFF;
308
+ const addrHigh = (networkAddr >> 8) & 0xFF;
309
+
310
+ let data;
311
+
312
+ if (attrType === 0x02) { // Switch control
313
+ // 开关控制需要正确的状态组合
314
+ // param格式: [channels, targetChannel, targetState, currentState(可选)]
315
+ // 或者直接传入计算好的状态值: [stateValue] 或 [stateLow, stateHigh]
316
+
317
+ if (param.length >= 3 && param[0] <= 6 && param[1] <= 6 && (param[2] === 0 || param[2] === 1)) {
318
+ // 使用状态组合算法 - 检查前3个参数是否符合状态组合格式
319
+ const channels = param[0];
320
+ const targetChannel = param[1];
321
+ const targetState = param[2] === 1;
322
+ const currentState = param.length > 3 ? param[3] : null;
323
+
324
+ const stateValue = this.buildSwitchState(channels, targetChannel, targetState, currentState);
325
+
326
+ if (Buffer.isBuffer(stateValue)) {
327
+ // 6路开关,2字节状态
328
+ data = Buffer.from([addrLow, addrHigh, 0x02, stateValue[0], stateValue[1]]);
329
+ } else {
330
+ // 1-4路开关,1字节状态
331
+ data = Buffer.from([addrLow, addrHigh, 0x02, stateValue]);
332
+ }
333
+ } else if (param.length === 2) {
334
+ // 6路开关直接状态值 (2字节)
335
+ data = Buffer.from([addrLow, addrHigh, 0x02, param[0], param[1]]);
336
+ } else {
337
+ // 1-4路开关直接状态值 (1字节)
338
+ const stateValue = param.length > 0 ? param[0] : 0x01;
339
+ data = Buffer.from([addrLow, addrHigh, 0x02, stateValue]);
340
+ }
341
+ } else if (attrType === 0x03 || attrType === 0x04) { // Brightness/Color temp
342
+ // 亮度和色温控制,参数范围0-100
343
+ const value = param.length > 0 ? Math.max(0, Math.min(100, param[0])) : 0;
344
+ data = Buffer.from([addrLow, addrHigh, attrType, value]);
345
+ } else if (attrType === 0x05) { // Curtain action
346
+ // 窗帘动作控制: 1=打开, 2=关闭, 3=停止
347
+ const action = param.length > 0 ? Math.max(1, Math.min(3, param[0])) : 1;
348
+ data = Buffer.from([addrLow, addrHigh, 0x05, action]);
349
+ } else if (attrType === 0x06) { // Curtain position
350
+ // 窗帘位置控制: 0-100百分比, 0xFF=未知
351
+ const position = param.length > 0 ? Math.max(0, Math.min(100, param[0])) : 0;
352
+ data = Buffer.from([addrLow, addrHigh, 0x06, position]);
353
+ } else if (attrType === 0x4C) { // RGB control (五色调光)
354
+ // 根据HA集成验证:五色调光RGB控制使用0x4C,5字节数据 [R, G, B, WW, CW]
355
+ // RGB范围0-255,WW和CW范围0-255
356
+ if (param.length >= 5) {
357
+ const rgbData = Buffer.from([
358
+ Math.max(0, Math.min(255, param[0])), // R: 0-255
359
+ Math.max(0, Math.min(255, param[1])), // G: 0-255
360
+ Math.max(0, Math.min(255, param[2])), // B: 0-255
361
+ Math.max(0, Math.min(255, param[3])), // WW: 0-255
362
+ Math.max(0, Math.min(255, param[4])) // CW: 0-255
363
+ ]);
364
+ data = Buffer.concat([Buffer.from([addrLow, addrHigh, 0x4C]), rgbData]);
365
+ } else if (param.length >= 3) {
366
+ // 只有RGB,没有WW和CW
367
+ const rgbData = Buffer.from([
368
+ Math.max(0, Math.min(255, param[0])),
369
+ Math.max(0, Math.min(255, param[1])),
370
+ Math.max(0, Math.min(255, param[2])),
371
+ 0, // WW默认0
372
+ 0 // CW默认0
373
+ ]);
374
+ data = Buffer.concat([Buffer.from([addrLow, addrHigh, 0x4C]), rgbData]);
375
+ } else {
376
+ data = Buffer.concat([Buffer.from([addrLow, addrHigh, 0x4C]), Buffer.from([0, 0, 0, 0, 0])]);
377
+ }
378
+ } else if (attrType === 0x1B) { // Climate temp (TMPC_TEMP)
379
+ // 根据协议文档3.5.1.12:1Byte 设置温度(16-30°C)
380
+ // 注意:这是直接温度值,不是温度*100!
381
+ const temp = param.length > 0 ? Math.max(16, Math.min(30, param[0])) : 20;
382
+ data = Buffer.from([addrLow, addrHigh, attrType, temp]);
383
+ } else if (attrType === 0x1C) { // Climate fan
384
+ const fanSpeed = param.length > 0 ? Math.max(1, Math.min(4, param[0])) : 1;
385
+ data = Buffer.from([addrLow, addrHigh, attrType, fanSpeed]);
386
+ } else if (attrType === 0x1D) { // Climate mode
387
+ const mode = param.length > 0 ? Math.max(1, Math.min(4, param[0])) : 1;
388
+ data = Buffer.from([addrLow, addrHigh, attrType, mode]);
389
+ } else if (attrType === 0x94) { // 三合一设备控制
390
+ // param格式: [subType, command, value]
391
+ // subType: 1=空调, 2=新风, 3=地暖
392
+ // command: 控制类型(开关、温度、模式等)
393
+ if (param.length >= 3) {
394
+ const subType = param[0];
395
+ const command = param[1];
396
+ const value = param[2];
397
+
398
+ // 构建三合一控制数据包
399
+ // 这里需要根据实际设备协议调整
400
+ const controlData = Buffer.from([subType, command, value]);
401
+ data = Buffer.concat([Buffer.from([addrLow, addrHigh, attrType]), controlData]);
402
+ } else {
403
+ data = Buffer.concat([Buffer.from([addrLow, addrHigh, attrType]), param]);
404
+ }
405
+ } else {
406
+ data = Buffer.concat([Buffer.from([addrLow, addrHigh, attrType]), param]);
407
+ }
408
+
409
+ return this.buildFrame(OP_DEVICE_CONTROL, data);
410
+ }
411
+
412
+ buildSceneControlFrame(sceneId) {
413
+ return this.buildFrame(OP_SCENE_CONTROL, Buffer.from([sceneId]));
414
+ }
415
+
416
+ buildDeviceStatusQueryFrame(networkAddr, msgType = 0x00) {
417
+ // 地址使用小端序(低字节在前)
418
+ const addrLow = networkAddr & 0xFF;
419
+ const addrHigh = (networkAddr >> 8) & 0xFF;
420
+
421
+ if (msgType === 0x00) {
422
+ return this.buildFrame(OP_DEVICE_STATUS_QUERY, Buffer.from([addrLow, addrHigh]));
423
+ } else {
424
+ return this.buildFrame(OP_DEVICE_STATUS_QUERY, Buffer.from([addrLow, addrHigh, msgType]));
425
+ }
426
+ }
427
+
428
+ /**
429
+ * 构建开关控制帧(便捷方法)
430
+ * @param {number} networkAddr - 网络地址
431
+ * @param {number} channels - 开关路数 (1-6)
432
+ * @param {number} targetChannel - 目标路数 (1-6)
433
+ * @param {boolean} targetState - 目标状态 (true=开, false=关)
434
+ * @param {number|null} currentState - 当前状态值,null时需要先查询
435
+ * @returns {Buffer} - 控制帧数据
436
+ */
437
+ buildSwitchControlFrame(networkAddr, channels, targetChannel, targetState, currentState = null) {
438
+ const param = Buffer.from([channels, targetChannel, targetState ? 1 : 0]);
439
+ if (currentState !== null) {
440
+ const paramWithState = Buffer.concat([param, Buffer.from([currentState])]);
441
+ return this.buildDeviceControlFrame(networkAddr, 0x02, paramWithState);
442
+ } else {
443
+ return this.buildDeviceControlFrame(networkAddr, 0x02, param);
444
+ }
445
+ }
446
+ }
447
+
448
+ function parseStatusEvent(frame) {
449
+ if (!frame.isEvent() || frame.opcode !== OP_EVENT_NODE_STATUS) {
450
+ throw new Error('Not a status event frame');
451
+ }
452
+
453
+ if (frame.payload.length < 3) {
454
+ throw new Error('Status event payload too short');
455
+ }
456
+
457
+ // 0x80事件格式: 53 80 [status/sub_opcode] [length] [payload] [checksum]
458
+ // payload部分: [addr_low] [addr_high] [msg_type] [data...]
459
+ // 地址使用小端序(低字节在前)
460
+ //
461
+ // 例: 53 80 05 04 9A 01 02 15 5E
462
+ // opcode=80, status=05(sub_opcode), length=04
463
+ // payload=[9A 01 02 15](4字节)
464
+ // 9A 01=地址(小端序)=0x019A, 02=消息类型, 15=参数
465
+ const addrLow = frame.payload[0];
466
+ const addrHigh = frame.payload[1];
467
+ const networkAddress = addrLow | (addrHigh << 8);
468
+ const attrType = frame.payload[2];
469
+ const parameters = frame.payload.length > 3 ? frame.payload.slice(3) : Buffer.alloc(0);
470
+
471
+ return {
472
+ networkAddress,
473
+ attrType,
474
+ parameters,
475
+ subOpcode: frame.status
476
+ };
477
+ }
478
+
479
+ function parseTransparentEvent(frame) {
480
+ if (!frame.isEvent() || frame.opcode !== OP_EVENT_TRANSPARENT_MSG) {
481
+ throw new Error('Not a transparent event frame');
482
+ }
483
+
484
+ if (frame.payload.length < 2) {
485
+ throw new Error('Transparent event payload too short');
486
+ }
487
+
488
+ const networkAddress = (frame.payload[0] << 8) | frame.payload[1];
489
+ const transparentData = frame.payload.length > 2 ? frame.payload.slice(2) : Buffer.alloc(0);
490
+
491
+ return {
492
+ networkAddress,
493
+ transparentData
494
+ };
495
+ }
496
+
497
+ module.exports = {
498
+ ProtocolHandler,
499
+ ProtocolFrame,
500
+ parseStatusEvent,
501
+ parseTransparentEvent,
502
+ PROTOCOL_HEADER,
503
+ OP_READ_DEVICE_LIST,
504
+ OP_DEVICE_CONTROL,
505
+ OP_EVENT_NODE_STATUS,
506
+ OP_RESP_DEVICE_LIST,
507
+ OP_SCENE_CONTROL,
508
+ OP_DEVICE_STATUS_QUERY
509
+ };
510
+