node-red-contrib-symi-mesh 1.7.1 → 1.7.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.
- package/README.md +274 -20
- package/examples/knx-sync-example.json +122 -58
- package/examples/rs485-sync-example.json +76 -0
- package/lib/device-manager.js +96 -51
- package/lib/serial-client.js +23 -4
- package/nodes/rs485-debug.html +2 -1
- package/nodes/symi-485-bridge.html +233 -32
- package/nodes/symi-485-bridge.js +874 -98
- package/nodes/symi-485-config.html +44 -21
- package/nodes/symi-485-config.js +49 -11
- package/nodes/symi-cloud-sync.html +2 -0
- package/nodes/symi-device.html +5 -3
- package/nodes/symi-gateway.html +49 -1
- package/nodes/symi-gateway.js +43 -3
- package/nodes/symi-knx-bridge.html +3 -2
- package/nodes/symi-knx-bridge.js +3 -3
- package/nodes/symi-knx-ha-bridge.html +4 -3
- package/nodes/symi-knx-ha-bridge.js +2 -2
- package/nodes/symi-mqtt-brand.html +75 -0
- package/nodes/symi-mqtt-brand.js +238 -0
- package/nodes/symi-mqtt-sync.html +381 -0
- package/nodes/symi-mqtt-sync.js +473 -0
- package/nodes/symi-rs485-sync.html +361 -0
- package/nodes/symi-rs485-sync.js +765 -0
- package/package.json +5 -2
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
function SymiRS485SyncNode(config) {
|
|
5
|
+
RED.nodes.createNode(this, config);
|
|
6
|
+
const node = this;
|
|
7
|
+
|
|
8
|
+
node.name = config.name;
|
|
9
|
+
node.rs485ConfigA = RED.nodes.getNode(config.rs485ConfigA);
|
|
10
|
+
node.rs485ConfigB = RED.nodes.getNode(config.rs485ConfigB);
|
|
11
|
+
|
|
12
|
+
// 解析映射配置
|
|
13
|
+
try {
|
|
14
|
+
node.mappings = JSON.parse(config.mappings || '[]');
|
|
15
|
+
} catch(e) {
|
|
16
|
+
node.mappings = [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 轮询配置
|
|
20
|
+
node.enablePolling = config.enablePolling !== false;
|
|
21
|
+
node.pollInterval = parseInt(config.pollInterval) || 1000;
|
|
22
|
+
node._pollTimer = null;
|
|
23
|
+
node._pollIndex = 0;
|
|
24
|
+
|
|
25
|
+
// 防抖时间戳,防止循环同步
|
|
26
|
+
node._lastSyncA = {};
|
|
27
|
+
node._lastSyncB = {};
|
|
28
|
+
const DEBOUNCE_MS = 2000;
|
|
29
|
+
|
|
30
|
+
// 状态缓存
|
|
31
|
+
node._stateCache = {};
|
|
32
|
+
|
|
33
|
+
if (!node.rs485ConfigA || !node.rs485ConfigB) {
|
|
34
|
+
node.status({ fill: 'red', shape: 'ring', text: '未配置RS485连接' });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (node.rs485ConfigA.id === node.rs485ConfigB.id) {
|
|
39
|
+
node.status({ fill: 'red', shape: 'ring', text: 'A/B不能是同一连接' });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
node.status({ fill: 'yellow', shape: 'dot', text: '初始化...' });
|
|
44
|
+
|
|
45
|
+
// 计算校验和(中弘协议)
|
|
46
|
+
function checksum(data) {
|
|
47
|
+
let sum = 0;
|
|
48
|
+
for (let i = 0; i < data.length; i++) {
|
|
49
|
+
sum += data[i];
|
|
50
|
+
}
|
|
51
|
+
return sum & 0xFF;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 中弘VRF协议常量
|
|
55
|
+
const ZH_FUNC = {
|
|
56
|
+
CTRL_SWITCH: 0x31,
|
|
57
|
+
CTRL_TEMPERATURE: 0x32,
|
|
58
|
+
CTRL_MODE: 0x33,
|
|
59
|
+
CTRL_FAN_MODE: 0x34,
|
|
60
|
+
QUERY: 0x50
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const ZH_MODE = {
|
|
64
|
+
COOL: 0x01,
|
|
65
|
+
DRY: 0x02,
|
|
66
|
+
FAN: 0x04,
|
|
67
|
+
HEAT: 0x08
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const ZH_FAN = {
|
|
71
|
+
HIGH: 0x01,
|
|
72
|
+
MEDIUM: 0x02,
|
|
73
|
+
LOW: 0x04
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// 获取配置值,支持0值
|
|
77
|
+
function getAddr(val, defaultVal) {
|
|
78
|
+
return val !== undefined ? val : defaultVal;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 生成中弘VRF控制命令
|
|
82
|
+
function buildZhonghongCmd(cfg, action, value) {
|
|
83
|
+
const cmd = [
|
|
84
|
+
getAddr(cfg.slaveAddr, 1),
|
|
85
|
+
action,
|
|
86
|
+
value,
|
|
87
|
+
0x01,
|
|
88
|
+
getAddr(cfg.outdoorAddr, 1),
|
|
89
|
+
getAddr(cfg.indoorAddr, 0) // 内机地址支持0
|
|
90
|
+
];
|
|
91
|
+
cmd.push(checksum(cmd));
|
|
92
|
+
return Buffer.from(cmd);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 生成中弘VRF查询命令
|
|
96
|
+
function buildZhonghongQueryCmd(cfg) {
|
|
97
|
+
const cmd = [
|
|
98
|
+
getAddr(cfg.slaveAddr, 1),
|
|
99
|
+
ZH_FUNC.QUERY,
|
|
100
|
+
0x01,
|
|
101
|
+
0x01,
|
|
102
|
+
getAddr(cfg.outdoorAddr, 1),
|
|
103
|
+
getAddr(cfg.indoorAddr, 0) // 内机地址支持0
|
|
104
|
+
];
|
|
105
|
+
cmd.push(checksum(cmd));
|
|
106
|
+
return Buffer.from(cmd);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 解析中弘VRF响应 - 支持批量返回多个内机状态
|
|
110
|
+
function parseZhonghongResponse(data, cfg) {
|
|
111
|
+
if (data.length < 7) return null;
|
|
112
|
+
|
|
113
|
+
const slaveAddr = data[0];
|
|
114
|
+
const expectedSlaveAddr = getAddr(cfg.slaveAddr, 1);
|
|
115
|
+
if (slaveAddr !== expectedSlaveAddr) return null;
|
|
116
|
+
|
|
117
|
+
const func = data[1];
|
|
118
|
+
|
|
119
|
+
// 查询响应: [从机] [0x50] [funcValue] [num] + [内机数据*num] + [校验和]
|
|
120
|
+
// 每个内机数据10字节: [外机] [内机] [开关] [温度] [模式] [风速] [当前温度] [x] [x] [x]
|
|
121
|
+
if (func === ZH_FUNC.QUERY) {
|
|
122
|
+
const funcValue = data[2];
|
|
123
|
+
if (funcValue !== 0x01) return null;
|
|
124
|
+
|
|
125
|
+
const num = data[3];
|
|
126
|
+
if (num < 1) return null;
|
|
127
|
+
|
|
128
|
+
const expectedLength = 4 + num * 10 + 1;
|
|
129
|
+
if (data.length < expectedLength) return null;
|
|
130
|
+
|
|
131
|
+
// 解析所有内机状态
|
|
132
|
+
const climates = [];
|
|
133
|
+
for (let i = 0; i < num; i++) {
|
|
134
|
+
const offset = 4 + i * 10;
|
|
135
|
+
climates.push({
|
|
136
|
+
outdoorAddr: data[offset],
|
|
137
|
+
indoorAddr: data[offset + 1],
|
|
138
|
+
power: data[offset + 2] === 0x01,
|
|
139
|
+
targetTemp: data[offset + 3],
|
|
140
|
+
mode: data[offset + 4],
|
|
141
|
+
fanMode: data[offset + 5],
|
|
142
|
+
currentTemp: data[offset + 6]
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 查找匹配的内机
|
|
147
|
+
const expectedOutdoor = getAddr(cfg.outdoorAddr, 1);
|
|
148
|
+
const expectedIndoor = getAddr(cfg.indoorAddr, 0);
|
|
149
|
+
|
|
150
|
+
const matched = climates.find(c =>
|
|
151
|
+
c.outdoorAddr === expectedOutdoor && c.indoorAddr === expectedIndoor
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
if (matched) {
|
|
155
|
+
return {
|
|
156
|
+
type: 'status',
|
|
157
|
+
power: matched.power,
|
|
158
|
+
targetTemp: matched.targetTemp,
|
|
159
|
+
currentTemp: matched.currentTemp,
|
|
160
|
+
mode: matched.mode,
|
|
161
|
+
fanMode: matched.fanMode
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 返回所有内机状态用于调试
|
|
166
|
+
return {
|
|
167
|
+
type: 'batch_status',
|
|
168
|
+
climates: climates
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 控制响应
|
|
173
|
+
if (func >= ZH_FUNC.CTRL_SWITCH && func <= ZH_FUNC.CTRL_FAN_MODE) {
|
|
174
|
+
return {
|
|
175
|
+
type: 'control_ack',
|
|
176
|
+
func: func,
|
|
177
|
+
value: data[2]
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ===== SYMI空调面板协议 (0x60功能码, 主动上报) =====
|
|
185
|
+
// 帧格式: [地址] [0x60] [开关] [温度] [模式] [风速] [x] [x] [x] [校验和]
|
|
186
|
+
const SYMI_FUNC = 0x60;
|
|
187
|
+
|
|
188
|
+
// SYMI模式值 -> 中弘模式值
|
|
189
|
+
const SYMI_TO_ZH_MODE = {
|
|
190
|
+
0x01: 0x01, // 制冷 -> 制冷
|
|
191
|
+
0x02: 0x02, // 除湿 -> 除湿
|
|
192
|
+
0x04: 0x04, // 送风 -> 送风
|
|
193
|
+
0x08: 0x08 // 制热 -> 制热
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// 中弘模式值 -> SYMI模式值
|
|
197
|
+
const ZH_TO_SYMI_MODE = {
|
|
198
|
+
0x01: 0x01, // 制冷
|
|
199
|
+
0x02: 0x02, // 除湿
|
|
200
|
+
0x04: 0x04, // 送风
|
|
201
|
+
0x08: 0x08 // 制热
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// SYMI风速值 -> 中弘风速值
|
|
205
|
+
const SYMI_TO_ZH_FAN = {
|
|
206
|
+
0x01: 0x04, // 低风 -> 低风
|
|
207
|
+
0x02: 0x02, // 中风 -> 中风
|
|
208
|
+
0x04: 0x01 // 高风 -> 高风
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// 中弘风速值 -> SYMI风速值
|
|
212
|
+
const ZH_TO_SYMI_FAN = {
|
|
213
|
+
0x01: 0x04, // 高风
|
|
214
|
+
0x02: 0x02, // 中风
|
|
215
|
+
0x04: 0x01 // 低风
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// 计算SYMI校验和
|
|
219
|
+
function symiChecksum(data) {
|
|
220
|
+
let sum = 0;
|
|
221
|
+
for (let i = 0; i < data.length; i++) {
|
|
222
|
+
sum += data[i];
|
|
223
|
+
}
|
|
224
|
+
return sum & 0xFF;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 生成SYMI空调面板控制命令
|
|
228
|
+
function buildSymiClimateCmd(cfg, opCode, value) {
|
|
229
|
+
const addr = cfg.address || 1;
|
|
230
|
+
// 获取当前缓存的状态
|
|
231
|
+
const cacheKey = `symi_${addr}`;
|
|
232
|
+
const cached = node._stateCache[cacheKey] || { power: 0, temp: 24, mode: 0x01, fan: 0x01 };
|
|
233
|
+
|
|
234
|
+
let power = cached.power;
|
|
235
|
+
let temp = cached.temp;
|
|
236
|
+
let mode = cached.mode;
|
|
237
|
+
let fan = cached.fan;
|
|
238
|
+
|
|
239
|
+
// 根据操作码更新对应值
|
|
240
|
+
if (opCode === 'power') {
|
|
241
|
+
power = value ? 0x01 : 0x00;
|
|
242
|
+
} else if (opCode === 'temp') {
|
|
243
|
+
temp = value;
|
|
244
|
+
} else if (opCode === 'mode') {
|
|
245
|
+
mode = ZH_TO_SYMI_MODE[value] || value;
|
|
246
|
+
} else if (opCode === 'fan') {
|
|
247
|
+
fan = ZH_TO_SYMI_FAN[value] || value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const cmd = Buffer.alloc(10);
|
|
251
|
+
cmd[0] = addr;
|
|
252
|
+
cmd[1] = SYMI_FUNC;
|
|
253
|
+
cmd[2] = power;
|
|
254
|
+
cmd[3] = temp;
|
|
255
|
+
cmd[4] = mode;
|
|
256
|
+
cmd[5] = fan;
|
|
257
|
+
cmd[6] = 0x01;
|
|
258
|
+
cmd[7] = 0x01;
|
|
259
|
+
cmd[8] = 0x00;
|
|
260
|
+
cmd[9] = symiChecksum(cmd.subarray(0, 9));
|
|
261
|
+
return cmd;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 解析SYMI空调面板上报帧
|
|
265
|
+
function parseSymiClimateFrame(data, cfg) {
|
|
266
|
+
if (data.length < 10) return null;
|
|
267
|
+
|
|
268
|
+
const addr = data[0];
|
|
269
|
+
const func = data[1];
|
|
270
|
+
|
|
271
|
+
if (func !== SYMI_FUNC) return null;
|
|
272
|
+
if (addr !== (cfg.address || 1)) return null;
|
|
273
|
+
|
|
274
|
+
// 验证校验和
|
|
275
|
+
const expectedChecksum = symiChecksum(data.subarray(0, 9));
|
|
276
|
+
if (data[9] !== expectedChecksum) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const power = data[2];
|
|
281
|
+
const targetTemp = data[3];
|
|
282
|
+
const mode = data[4];
|
|
283
|
+
const fanMode = data[5];
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
type: 'status',
|
|
287
|
+
power: power === 0x01,
|
|
288
|
+
targetTemp: targetTemp,
|
|
289
|
+
mode: mode,
|
|
290
|
+
fanMode: fanMode
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 同步状态:从A到B
|
|
295
|
+
function syncAtoB(mapping, state) {
|
|
296
|
+
const key = JSON.stringify({ a: mapping.configA, b: mapping.configB });
|
|
297
|
+
const now = Date.now();
|
|
298
|
+
|
|
299
|
+
if (node._lastSyncB[key] && (now - node._lastSyncB[key]) < DEBOUNCE_MS) {
|
|
300
|
+
node.debug(`[A->B] 防抖跳过`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
node._lastSyncA[key] = now;
|
|
305
|
+
|
|
306
|
+
node.log(`[A->B] 同步状态: ${JSON.stringify(state)}`);
|
|
307
|
+
|
|
308
|
+
// 根据B侧协议生成命令
|
|
309
|
+
let cmd = null;
|
|
310
|
+
if (mapping.protocolB === 'zhonghong') {
|
|
311
|
+
if (state.power !== undefined) {
|
|
312
|
+
cmd = buildZhonghongCmd(mapping.configB, ZH_FUNC.CTRL_SWITCH, state.power ? 0x01 : 0x00);
|
|
313
|
+
} else if (state.targetTemp !== undefined) {
|
|
314
|
+
cmd = buildZhonghongCmd(mapping.configB, ZH_FUNC.CTRL_TEMPERATURE, state.targetTemp);
|
|
315
|
+
} else if (state.mode !== undefined) {
|
|
316
|
+
cmd = buildZhonghongCmd(mapping.configB, ZH_FUNC.CTRL_MODE, state.mode);
|
|
317
|
+
} else if (state.fanMode !== undefined) {
|
|
318
|
+
cmd = buildZhonghongCmd(mapping.configB, ZH_FUNC.CTRL_FAN_MODE, state.fanMode);
|
|
319
|
+
}
|
|
320
|
+
} else if (mapping.protocolB === 'symi_climate') {
|
|
321
|
+
// SYMI空调面板协议
|
|
322
|
+
if (state.power !== undefined) {
|
|
323
|
+
cmd = buildSymiClimateCmd(mapping.configB, 'power', state.power);
|
|
324
|
+
} else if (state.targetTemp !== undefined) {
|
|
325
|
+
cmd = buildSymiClimateCmd(mapping.configB, 'temp', state.targetTemp);
|
|
326
|
+
} else if (state.mode !== undefined) {
|
|
327
|
+
cmd = buildSymiClimateCmd(mapping.configB, 'mode', state.mode);
|
|
328
|
+
} else if (state.fanMode !== undefined) {
|
|
329
|
+
cmd = buildSymiClimateCmd(mapping.configB, 'fan', state.fanMode);
|
|
330
|
+
}
|
|
331
|
+
} else if (mapping.protocolB === 'custom') {
|
|
332
|
+
const codes = mapping.configB;
|
|
333
|
+
if (state.power === true && codes.sendOn) {
|
|
334
|
+
cmd = Buffer.from(codes.sendOn.replace(/\s/g, ''), 'hex');
|
|
335
|
+
} else if (state.power === false && codes.sendOff) {
|
|
336
|
+
cmd = Buffer.from(codes.sendOff.replace(/\s/g, ''), 'hex');
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (cmd && node.rs485ConfigB) {
|
|
341
|
+
node.rs485ConfigB.send(cmd).then(() => {
|
|
342
|
+
const hexStr = cmd.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
|
|
343
|
+
node.log(`[A->B] 已发送到B: ${hexStr}`);
|
|
344
|
+
}).catch(err => {
|
|
345
|
+
node.error(`[A->B] 发送失败: ${err.message}`);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 同步状态:从B到A
|
|
351
|
+
function syncBtoA(mapping, state) {
|
|
352
|
+
const key = JSON.stringify({ a: mapping.configA, b: mapping.configB });
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
|
|
355
|
+
if (node._lastSyncA[key] && (now - node._lastSyncA[key]) < DEBOUNCE_MS) {
|
|
356
|
+
node.debug(`[B->A] 防抖跳过`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
node._lastSyncB[key] = now;
|
|
361
|
+
|
|
362
|
+
node.log(`[B->A] 同步状态: ${JSON.stringify(state)}`);
|
|
363
|
+
|
|
364
|
+
// 根据A侧协议生成命令
|
|
365
|
+
let cmd = null;
|
|
366
|
+
if (mapping.protocolA === 'zhonghong') {
|
|
367
|
+
if (state.power !== undefined) {
|
|
368
|
+
cmd = buildZhonghongCmd(mapping.configA, ZH_FUNC.CTRL_SWITCH, state.power ? 0x01 : 0x00);
|
|
369
|
+
} else if (state.targetTemp !== undefined) {
|
|
370
|
+
cmd = buildZhonghongCmd(mapping.configA, ZH_FUNC.CTRL_TEMPERATURE, state.targetTemp);
|
|
371
|
+
} else if (state.mode !== undefined) {
|
|
372
|
+
cmd = buildZhonghongCmd(mapping.configA, ZH_FUNC.CTRL_MODE, state.mode);
|
|
373
|
+
} else if (state.fanMode !== undefined) {
|
|
374
|
+
cmd = buildZhonghongCmd(mapping.configA, ZH_FUNC.CTRL_FAN_MODE, state.fanMode);
|
|
375
|
+
}
|
|
376
|
+
} else if (mapping.protocolA === 'symi_climate') {
|
|
377
|
+
// SYMI空调面板协议
|
|
378
|
+
if (state.power !== undefined) {
|
|
379
|
+
cmd = buildSymiClimateCmd(mapping.configA, 'power', state.power);
|
|
380
|
+
} else if (state.targetTemp !== undefined) {
|
|
381
|
+
cmd = buildSymiClimateCmd(mapping.configA, 'temp', state.targetTemp);
|
|
382
|
+
} else if (state.mode !== undefined) {
|
|
383
|
+
cmd = buildSymiClimateCmd(mapping.configA, 'mode', state.mode);
|
|
384
|
+
} else if (state.fanMode !== undefined) {
|
|
385
|
+
cmd = buildSymiClimateCmd(mapping.configA, 'fan', state.fanMode);
|
|
386
|
+
}
|
|
387
|
+
} else if (mapping.protocolA === 'custom') {
|
|
388
|
+
const codes = mapping.configA;
|
|
389
|
+
if (state.power === true && codes.sendOn) {
|
|
390
|
+
cmd = Buffer.from(codes.sendOn.replace(/\s/g, ''), 'hex');
|
|
391
|
+
} else if (state.power === false && codes.sendOff) {
|
|
392
|
+
cmd = Buffer.from(codes.sendOff.replace(/\s/g, ''), 'hex');
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (cmd && node.rs485ConfigA) {
|
|
397
|
+
node.rs485ConfigA.send(cmd).then(() => {
|
|
398
|
+
const hexStr = cmd.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
|
|
399
|
+
node.log(`[B->A] 已发送到A: ${hexStr}`);
|
|
400
|
+
}).catch(err => {
|
|
401
|
+
node.error(`[B->A] 发送失败: ${err.message}`);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 处理A侧接收到的帧
|
|
407
|
+
function handleFrameA(frame) {
|
|
408
|
+
const hexStr = frame.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
|
|
409
|
+
node.debug(`[A侧收到] ${hexStr}`);
|
|
410
|
+
|
|
411
|
+
// 先尝试解析为中弘VRF批量响应
|
|
412
|
+
if (frame.length >= 15 && frame[1] === ZH_FUNC.QUERY) {
|
|
413
|
+
const slaveAddr = frame[0];
|
|
414
|
+
const funcValue = frame[2];
|
|
415
|
+
const num = frame[3];
|
|
416
|
+
|
|
417
|
+
if (funcValue === 0x01 && num >= 1) {
|
|
418
|
+
const expectedLength = 4 + num * 10 + 1;
|
|
419
|
+
if (frame.length >= expectedLength) {
|
|
420
|
+
// 解析所有内机状态
|
|
421
|
+
const climates = [];
|
|
422
|
+
for (let i = 0; i < num; i++) {
|
|
423
|
+
const offset = 4 + i * 10;
|
|
424
|
+
climates.push({
|
|
425
|
+
outdoorAddr: frame[offset],
|
|
426
|
+
indoorAddr: frame[offset + 1],
|
|
427
|
+
power: frame[offset + 2] === 0x01,
|
|
428
|
+
targetTemp: frame[offset + 3],
|
|
429
|
+
mode: frame[offset + 4],
|
|
430
|
+
fanMode: frame[offset + 5],
|
|
431
|
+
currentTemp: frame[offset + 6]
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
node.log(`[A侧VRF] 收到${num}个内机状态`);
|
|
436
|
+
|
|
437
|
+
// 为每个内机查找对应的映射并同步
|
|
438
|
+
for (const climate of climates) {
|
|
439
|
+
for (const mapping of node.mappings) {
|
|
440
|
+
if (mapping.protocolA === 'zhonghong') {
|
|
441
|
+
const cfg = mapping.configA;
|
|
442
|
+
const expectedOutdoor = getAddr(cfg.outdoorAddr, 1);
|
|
443
|
+
const expectedIndoor = getAddr(cfg.indoorAddr, 0);
|
|
444
|
+
|
|
445
|
+
if (climate.outdoorAddr === expectedOutdoor && climate.indoorAddr === expectedIndoor) {
|
|
446
|
+
const state = {
|
|
447
|
+
type: 'status',
|
|
448
|
+
power: climate.power,
|
|
449
|
+
targetTemp: climate.targetTemp,
|
|
450
|
+
currentTemp: climate.currentTemp,
|
|
451
|
+
mode: climate.mode,
|
|
452
|
+
fanMode: climate.fanMode
|
|
453
|
+
};
|
|
454
|
+
node.log(`[A->B] 内机${climate.outdoorAddr}-${climate.indoorAddr}: 开=${state.power}, 温=${state.targetTemp}, 模式=${state.mode}`);
|
|
455
|
+
syncAtoB(mapping, state);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 常规逐个映射处理
|
|
466
|
+
for (const mapping of node.mappings) {
|
|
467
|
+
let state = null;
|
|
468
|
+
|
|
469
|
+
if (mapping.protocolA === 'zhonghong') {
|
|
470
|
+
state = parseZhonghongResponse(frame, mapping.configA);
|
|
471
|
+
} else if (mapping.protocolA === 'symi_climate') {
|
|
472
|
+
state = parseSymiClimateFrame(frame, mapping.configA);
|
|
473
|
+
if (state) {
|
|
474
|
+
const cacheKey = `symi_${mapping.configA.address || 1}`;
|
|
475
|
+
node._stateCache[cacheKey] = {
|
|
476
|
+
power: state.power ? 1 : 0,
|
|
477
|
+
temp: state.targetTemp,
|
|
478
|
+
mode: state.mode,
|
|
479
|
+
fan: state.fanMode
|
|
480
|
+
};
|
|
481
|
+
state.mode = SYMI_TO_ZH_MODE[state.mode] || state.mode;
|
|
482
|
+
state.fanMode = SYMI_TO_ZH_FAN[state.fanMode] || state.fanMode;
|
|
483
|
+
}
|
|
484
|
+
} else if (mapping.protocolA === 'custom') {
|
|
485
|
+
const codes = mapping.configA;
|
|
486
|
+
const hexNoSpace = hexStr.replace(/\s/g, '');
|
|
487
|
+
const recvOn = (codes.recvOn || '').replace(/\s/g, '').toUpperCase();
|
|
488
|
+
const recvOff = (codes.recvOff || '').replace(/\s/g, '').toUpperCase();
|
|
489
|
+
|
|
490
|
+
if (recvOn && hexNoSpace.includes(recvOn)) {
|
|
491
|
+
state = { type: 'status', power: true };
|
|
492
|
+
} else if (recvOff && hexNoSpace.includes(recvOff)) {
|
|
493
|
+
state = { type: 'status', power: false };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (state && state.type === 'status') {
|
|
498
|
+
syncAtoB(mapping, state);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// 处理B侧接收到的帧
|
|
504
|
+
function handleFrameB(frame) {
|
|
505
|
+
const hexStr = frame.toString('hex').toUpperCase().match(/.{1,2}/g)?.join(' ') || '';
|
|
506
|
+
node.debug(`[B侧收到] ${hexStr}`);
|
|
507
|
+
|
|
508
|
+
// 先尝试解析为中弘VRF批量响应
|
|
509
|
+
if (frame.length >= 15 && frame[1] === ZH_FUNC.QUERY) {
|
|
510
|
+
const funcValue = frame[2];
|
|
511
|
+
const num = frame[3];
|
|
512
|
+
|
|
513
|
+
if (funcValue === 0x01 && num >= 1) {
|
|
514
|
+
const expectedLength = 4 + num * 10 + 1;
|
|
515
|
+
if (frame.length >= expectedLength) {
|
|
516
|
+
const climates = [];
|
|
517
|
+
for (let i = 0; i < num; i++) {
|
|
518
|
+
const offset = 4 + i * 10;
|
|
519
|
+
climates.push({
|
|
520
|
+
outdoorAddr: frame[offset],
|
|
521
|
+
indoorAddr: frame[offset + 1],
|
|
522
|
+
power: frame[offset + 2] === 0x01,
|
|
523
|
+
targetTemp: frame[offset + 3],
|
|
524
|
+
mode: frame[offset + 4],
|
|
525
|
+
fanMode: frame[offset + 5],
|
|
526
|
+
currentTemp: frame[offset + 6]
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
node.log(`[B侧VRF] 收到${num}个内机状态`);
|
|
531
|
+
|
|
532
|
+
for (const climate of climates) {
|
|
533
|
+
for (const mapping of node.mappings) {
|
|
534
|
+
if (mapping.protocolB === 'zhonghong') {
|
|
535
|
+
const cfg = mapping.configB;
|
|
536
|
+
const expectedOutdoor = getAddr(cfg.outdoorAddr, 1);
|
|
537
|
+
const expectedIndoor = getAddr(cfg.indoorAddr, 0);
|
|
538
|
+
|
|
539
|
+
if (climate.outdoorAddr === expectedOutdoor && climate.indoorAddr === expectedIndoor) {
|
|
540
|
+
const state = {
|
|
541
|
+
type: 'status',
|
|
542
|
+
power: climate.power,
|
|
543
|
+
targetTemp: climate.targetTemp,
|
|
544
|
+
currentTemp: climate.currentTemp,
|
|
545
|
+
mode: climate.mode,
|
|
546
|
+
fanMode: climate.fanMode
|
|
547
|
+
};
|
|
548
|
+
syncBtoA(mapping, state);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// 常规逐个映射处理
|
|
559
|
+
for (const mapping of node.mappings) {
|
|
560
|
+
let state = null;
|
|
561
|
+
|
|
562
|
+
if (mapping.protocolB === 'zhonghong') {
|
|
563
|
+
state = parseZhonghongResponse(frame, mapping.configB);
|
|
564
|
+
} else if (mapping.protocolB === 'symi_climate') {
|
|
565
|
+
state = parseSymiClimateFrame(frame, mapping.configB);
|
|
566
|
+
if (state) {
|
|
567
|
+
const cacheKey = `symi_${mapping.configB.address || 1}`;
|
|
568
|
+
node._stateCache[cacheKey] = {
|
|
569
|
+
power: state.power ? 1 : 0,
|
|
570
|
+
temp: state.targetTemp,
|
|
571
|
+
mode: state.mode,
|
|
572
|
+
fan: state.fanMode
|
|
573
|
+
};
|
|
574
|
+
state.mode = SYMI_TO_ZH_MODE[state.mode] || state.mode;
|
|
575
|
+
state.fanMode = SYMI_TO_ZH_FAN[state.fanMode] || state.fanMode;
|
|
576
|
+
}
|
|
577
|
+
} else if (mapping.protocolB === 'custom') {
|
|
578
|
+
const codes = mapping.configB;
|
|
579
|
+
const hexNoSpace = hexStr.replace(/\s/g, '');
|
|
580
|
+
const recvOn = (codes.recvOn || '').replace(/\s/g, '').toUpperCase();
|
|
581
|
+
const recvOff = (codes.recvOff || '').replace(/\s/g, '').toUpperCase();
|
|
582
|
+
|
|
583
|
+
if (recvOn && hexNoSpace.includes(recvOn)) {
|
|
584
|
+
state = { type: 'status', power: true };
|
|
585
|
+
} else if (recvOff && hexNoSpace.includes(recvOff)) {
|
|
586
|
+
state = { type: 'status', power: false };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (state && state.type === 'status') {
|
|
591
|
+
syncBtoA(mapping, state);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 事件处理函数
|
|
597
|
+
const onFrameA = (frame) => handleFrameA(frame);
|
|
598
|
+
const onFrameB = (frame) => handleFrameB(frame);
|
|
599
|
+
|
|
600
|
+
const onConnectedA = () => {
|
|
601
|
+
node.log('[RS485 A] 已连接');
|
|
602
|
+
updateStatus();
|
|
603
|
+
checkAndStartPolling();
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const onConnectedB = () => {
|
|
607
|
+
node.log('[RS485 B] 已连接');
|
|
608
|
+
updateStatus();
|
|
609
|
+
checkAndStartPolling();
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const onDisconnectedA = () => {
|
|
613
|
+
node.log('[RS485 A] 已断开');
|
|
614
|
+
updateStatus();
|
|
615
|
+
stopPolling();
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const onDisconnectedB = () => {
|
|
619
|
+
node.log('[RS485 B] 已断开');
|
|
620
|
+
updateStatus();
|
|
621
|
+
stopPolling();
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
// 检查连接状态并启动轮询
|
|
625
|
+
function checkAndStartPolling() {
|
|
626
|
+
const connA = node.rs485ConfigA && node.rs485ConfigA._connected;
|
|
627
|
+
const connB = node.rs485ConfigB && node.rs485ConfigB._connected;
|
|
628
|
+
if (connA && connB && node.enablePolling && !node._pollTimer) {
|
|
629
|
+
startPolling();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// 启动轮询 - 只查询唯一的VRF外机,因为一次响应返回所有内机状态
|
|
634
|
+
function startPolling() {
|
|
635
|
+
if (node._pollTimer) return;
|
|
636
|
+
if (!node.enablePolling) return;
|
|
637
|
+
|
|
638
|
+
// 收集所有需要轮询的VRF配置,按外机地址去重
|
|
639
|
+
const vrfConfigs = [];
|
|
640
|
+
const seen = new Set();
|
|
641
|
+
|
|
642
|
+
for (const mapping of node.mappings) {
|
|
643
|
+
let cfg, rs485Config, side;
|
|
644
|
+
if (mapping.protocolA === 'zhonghong') {
|
|
645
|
+
cfg = mapping.configA;
|
|
646
|
+
rs485Config = node.rs485ConfigA;
|
|
647
|
+
side = 'A';
|
|
648
|
+
} else if (mapping.protocolB === 'zhonghong') {
|
|
649
|
+
cfg = mapping.configB;
|
|
650
|
+
rs485Config = node.rs485ConfigB;
|
|
651
|
+
side = 'B';
|
|
652
|
+
} else {
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// 用从机地址+外机地址作为唯一键(同一外机下的所有内机共享一次查询)
|
|
657
|
+
const key = `${side}_${getAddr(cfg.slaveAddr, 1)}_${getAddr(cfg.outdoorAddr, 1)}`;
|
|
658
|
+
if (!seen.has(key)) {
|
|
659
|
+
seen.add(key);
|
|
660
|
+
vrfConfigs.push({ cfg, rs485Config, side });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (vrfConfigs.length === 0) return;
|
|
665
|
+
|
|
666
|
+
node._vrfConfigs = vrfConfigs;
|
|
667
|
+
node._pollIndex = 0;
|
|
668
|
+
node._pollTimer = setInterval(pollNextVRF, node.pollInterval);
|
|
669
|
+
node.log(`[RS485 Sync] 启动轮询,间隔${node.pollInterval}ms,${vrfConfigs.length}个VRF外机,${node.mappings.length}组映射`);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// 停止轮询
|
|
673
|
+
function stopPolling() {
|
|
674
|
+
if (node._pollTimer) {
|
|
675
|
+
clearInterval(node._pollTimer);
|
|
676
|
+
node._pollTimer = null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// 轮询函数 - 每次查询一个VRF外机,响应会包含该外机下所有内机状态
|
|
681
|
+
function pollNextVRF() {
|
|
682
|
+
if (!node._vrfConfigs || node._vrfConfigs.length === 0) return;
|
|
683
|
+
|
|
684
|
+
const { cfg, rs485Config, side } = node._vrfConfigs[node._pollIndex % node._vrfConfigs.length];
|
|
685
|
+
node._pollIndex++;
|
|
686
|
+
|
|
687
|
+
const cmd = buildZhonghongQueryCmd(cfg);
|
|
688
|
+
rs485Config.send(cmd).catch(() => {});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function updateStatus() {
|
|
692
|
+
const connA = node.rs485ConfigA && node.rs485ConfigA.connected;
|
|
693
|
+
const connB = node.rs485ConfigB && node.rs485ConfigB.connected;
|
|
694
|
+
|
|
695
|
+
if (connA && connB) {
|
|
696
|
+
node.status({ fill: 'green', shape: 'dot', text: `同步中 (${node.mappings.length}组)` });
|
|
697
|
+
} else if (connA || connB) {
|
|
698
|
+
node.status({ fill: 'yellow', shape: 'dot', text: '部分连接' });
|
|
699
|
+
} else {
|
|
700
|
+
node.status({ fill: 'red', shape: 'ring', text: '未连接' });
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// 绑定事件
|
|
705
|
+
node.rs485ConfigA.on('frame', onFrameA);
|
|
706
|
+
node.rs485ConfigA.on('connected', onConnectedA);
|
|
707
|
+
node.rs485ConfigA.on('disconnected', onDisconnectedA);
|
|
708
|
+
|
|
709
|
+
node.rs485ConfigB.on('frame', onFrameB);
|
|
710
|
+
node.rs485ConfigB.on('connected', onConnectedB);
|
|
711
|
+
node.rs485ConfigB.on('disconnected', onDisconnectedB);
|
|
712
|
+
|
|
713
|
+
// 注册到配置节点
|
|
714
|
+
node.rs485ConfigA.register(node);
|
|
715
|
+
node.rs485ConfigB.register(node);
|
|
716
|
+
|
|
717
|
+
// 输出映射信息
|
|
718
|
+
node.mappings.forEach((m, idx) => {
|
|
719
|
+
node.log(`[映射${idx + 1}] ${m.protocolA} <-> ${m.protocolB}`);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
updateStatus();
|
|
723
|
+
node.log(`[RS485 Sync] 初始化完成,${node.mappings.length}组映射,轮询${node.enablePolling ? '启用' : '禁用'}(${node.pollInterval}ms)`);
|
|
724
|
+
|
|
725
|
+
// 输入消息处理
|
|
726
|
+
node.on('input', function(msg) {
|
|
727
|
+
// 支持手动触发同步
|
|
728
|
+
if (msg.payload && msg.payload.action === 'query') {
|
|
729
|
+
// 发送查询命令到A侧
|
|
730
|
+
for (const mapping of node.mappings) {
|
|
731
|
+
if (mapping.protocolA === 'zhonghong') {
|
|
732
|
+
const cmd = buildZhonghongQueryCmd(mapping.configA);
|
|
733
|
+
node.rs485ConfigA.send(cmd).catch(err => {
|
|
734
|
+
node.error(`查询A失败: ${err.message}`);
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// 透传
|
|
741
|
+
node.send(msg);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
// 清理
|
|
745
|
+
node.on('close', function(done) {
|
|
746
|
+
// 停止轮询
|
|
747
|
+
stopPolling();
|
|
748
|
+
|
|
749
|
+
node.rs485ConfigA.removeListener('frame', onFrameA);
|
|
750
|
+
node.rs485ConfigA.removeListener('connected', onConnectedA);
|
|
751
|
+
node.rs485ConfigA.removeListener('disconnected', onDisconnectedA);
|
|
752
|
+
|
|
753
|
+
node.rs485ConfigB.removeListener('frame', onFrameB);
|
|
754
|
+
node.rs485ConfigB.removeListener('connected', onConnectedB);
|
|
755
|
+
node.rs485ConfigB.removeListener('disconnected', onDisconnectedB);
|
|
756
|
+
|
|
757
|
+
node.rs485ConfigA.deregister(node);
|
|
758
|
+
node.rs485ConfigB.deregister(node);
|
|
759
|
+
|
|
760
|
+
done();
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
RED.nodes.registerType('symi-rs485-sync', SymiRS485SyncNode);
|
|
765
|
+
};
|