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.
- package/LICENSE +22 -0
- package/README.md +592 -0
- package/examples/knx-sync-example.json +465 -0
- package/lib/device-manager.js +575 -0
- package/lib/mqtt-helper.js +659 -0
- package/lib/protocol.js +510 -0
- package/lib/serial-client.js +286 -0
- package/lib/tcp-client.js +262 -0
- package/nodes/symi-device.html +303 -0
- package/nodes/symi-device.js +344 -0
- package/nodes/symi-gateway.html +83 -0
- package/nodes/symi-gateway.js +450 -0
- package/nodes/symi-mqtt.html +94 -0
- package/nodes/symi-mqtt.js +1113 -0
- package/package.json +58 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('symi-gateway', {
|
|
3
|
+
category: 'config',
|
|
4
|
+
defaults: {
|
|
5
|
+
name: { value: '' },
|
|
6
|
+
connectionType: { value: 'tcp' },
|
|
7
|
+
host: { value: '' },
|
|
8
|
+
port: { value: 4196 },
|
|
9
|
+
serialPort: { value: '' },
|
|
10
|
+
baudRate: { value: 115200 }
|
|
11
|
+
},
|
|
12
|
+
label: function() {
|
|
13
|
+
return this.name || (this.connectionType === 'tcp'
|
|
14
|
+
? `Symi Gateway (${this.host}:${this.port})`
|
|
15
|
+
: `Symi Gateway (${this.serialPort})`);
|
|
16
|
+
},
|
|
17
|
+
oneditprepare: function() {
|
|
18
|
+
$('#node-config-input-connectionType').on('change', function() {
|
|
19
|
+
if ($(this).val() === 'tcp') {
|
|
20
|
+
$('.tcp-config').show();
|
|
21
|
+
$('.serial-config').hide();
|
|
22
|
+
} else {
|
|
23
|
+
$('.tcp-config').hide();
|
|
24
|
+
$('.serial-config').show();
|
|
25
|
+
}
|
|
26
|
+
}).trigger('change');
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<script type="text/html" data-template-name="symi-gateway">
|
|
32
|
+
<div class="form-row">
|
|
33
|
+
<label for="node-config-input-name"><i class="fa fa-tag"></i> 名称</label>
|
|
34
|
+
<input type="text" id="node-config-input-name" placeholder="网关名称">
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="form-row">
|
|
38
|
+
<label for="node-config-input-connectionType"><i class="fa fa-plug"></i> 连接方式</label>
|
|
39
|
+
<select id="node-config-input-connectionType">
|
|
40
|
+
<option value="tcp">TCP/IP (局域网)</option>
|
|
41
|
+
<option value="serial">串口 (USB)</option>
|
|
42
|
+
</select>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="form-row tcp-config">
|
|
46
|
+
<label for="node-config-input-host"><i class="fa fa-server"></i> IP地址</label>
|
|
47
|
+
<input type="text" id="node-config-input-host" placeholder="192.168.1.100">
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="form-row tcp-config">
|
|
51
|
+
<label for="node-config-input-port"><i class="fa fa-random"></i> 端口</label>
|
|
52
|
+
<input type="number" id="node-config-input-port" placeholder="4196">
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div class="form-row serial-config">
|
|
56
|
+
<label for="node-config-input-serialPort"><i class="fa fa-usb"></i> 串口路径</label>
|
|
57
|
+
<input type="text" id="node-config-input-serialPort" placeholder="/dev/ttyUSB0 或 COM3">
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="form-row serial-config">
|
|
61
|
+
<label for="node-config-input-baudRate"><i class="fa fa-tachometer"></i> 波特率</label>
|
|
62
|
+
<input type="number" id="node-config-input-baudRate" placeholder="115200">
|
|
63
|
+
</div>
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<script type="text/html" data-help-name="symi-gateway">
|
|
67
|
+
<p>Symi蓝牙Mesh网关配置节点</p>
|
|
68
|
+
<h3>连接方式</h3>
|
|
69
|
+
<dl>
|
|
70
|
+
<dt>TCP/IP</dt>
|
|
71
|
+
<dd>通过局域网连接网关,适用于网络版网关</dd>
|
|
72
|
+
<dt>串口</dt>
|
|
73
|
+
<dd>通过USB串口连接网关,适用于串口模块</dd>
|
|
74
|
+
</dl>
|
|
75
|
+
<h3>功能</h3>
|
|
76
|
+
<ul>
|
|
77
|
+
<li>自动连接并保持连接(断线重连)</li>
|
|
78
|
+
<li>自动发现设备(53 12 00 41命令)</li>
|
|
79
|
+
<li>MAC地址持久化存储</li>
|
|
80
|
+
<li>短地址动态更新</li>
|
|
81
|
+
</ul>
|
|
82
|
+
</script>
|
|
83
|
+
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symi Gateway Configuration Node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const TCPClient = require('../lib/tcp-client');
|
|
6
|
+
const SerialClient = require('../lib/serial-client');
|
|
7
|
+
const { DeviceManager } = require('../lib/device-manager');
|
|
8
|
+
const { ProtocolHandler, parseStatusEvent, OP_RESP_DEVICE_LIST } = require('../lib/protocol');
|
|
9
|
+
|
|
10
|
+
module.exports = function(RED) {
|
|
11
|
+
function SymiGatewayNode(config) {
|
|
12
|
+
RED.nodes.createNode(this, config);
|
|
13
|
+
|
|
14
|
+
this.name = config.name;
|
|
15
|
+
this.connectionType = config.connectionType || 'tcp';
|
|
16
|
+
this.host = config.host;
|
|
17
|
+
this.port = parseInt(config.port) || 4196;
|
|
18
|
+
this.serialPort = config.serialPort;
|
|
19
|
+
this.baudRate = parseInt(config.baudRate) || 115200;
|
|
20
|
+
|
|
21
|
+
this.client = null;
|
|
22
|
+
this.deviceManager = new DeviceManager(this.context(), this);
|
|
23
|
+
this.protocolHandler = new ProtocolHandler();
|
|
24
|
+
this.connected = false;
|
|
25
|
+
this.deviceListComplete = false;
|
|
26
|
+
this.isQueryingStates = false; // 标记是否正在查询状态
|
|
27
|
+
|
|
28
|
+
this.log(`Initializing Symi Gateway: ${this.connectionType === 'tcp' ? `${this.host}:${this.port}` : this.serialPort}`);
|
|
29
|
+
|
|
30
|
+
// 监听三合一设备检测事件
|
|
31
|
+
this.deviceManager.on('three-in-one-detected', async (device) => {
|
|
32
|
+
this.log(`三合一设备被识别,开始查询所有状态: ${device.name}`);
|
|
33
|
+
try {
|
|
34
|
+
// 查询环境温湿度(优先)
|
|
35
|
+
await this.sendControl(device.networkAddress, 0x16, Buffer.from([]));
|
|
36
|
+
await new Promise(r => setTimeout(r, 100));
|
|
37
|
+
await this.sendControl(device.networkAddress, 0x17, Buffer.from([]));
|
|
38
|
+
await new Promise(r => setTimeout(r, 100));
|
|
39
|
+
// 查询空调状态
|
|
40
|
+
await this.sendControl(device.networkAddress, 0x1B, Buffer.from([]));
|
|
41
|
+
await new Promise(r => setTimeout(r, 100));
|
|
42
|
+
await this.sendControl(device.networkAddress, 0x1C, Buffer.from([]));
|
|
43
|
+
await new Promise(r => setTimeout(r, 100));
|
|
44
|
+
await this.sendControl(device.networkAddress, 0x1D, Buffer.from([]));
|
|
45
|
+
await new Promise(r => setTimeout(r, 100));
|
|
46
|
+
// 查询新风状态
|
|
47
|
+
await this.sendControl(device.networkAddress, 0x68, Buffer.from([]));
|
|
48
|
+
await new Promise(r => setTimeout(r, 100));
|
|
49
|
+
await this.sendControl(device.networkAddress, 0x69, Buffer.from([]));
|
|
50
|
+
await new Promise(r => setTimeout(r, 100));
|
|
51
|
+
await this.sendControl(device.networkAddress, 0x6A, Buffer.from([]));
|
|
52
|
+
await new Promise(r => setTimeout(r, 100));
|
|
53
|
+
// 查询地暖状态
|
|
54
|
+
await this.sendControl(device.networkAddress, 0x6B, Buffer.from([]));
|
|
55
|
+
await new Promise(r => setTimeout(r, 100));
|
|
56
|
+
await this.sendControl(device.networkAddress, 0x6C, Buffer.from([]));
|
|
57
|
+
await new Promise(r => setTimeout(r, 100));
|
|
58
|
+
this.log(`三合一设备状态查询完成`);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
this.warn(`三合一设备状态查询失败: ${error.message}`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this.connect();
|
|
65
|
+
|
|
66
|
+
this.on('close', (done) => {
|
|
67
|
+
this.disconnect().then(done).catch(done);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
SymiGatewayNode.prototype.connect = async function() {
|
|
72
|
+
try {
|
|
73
|
+
if (this.connectionType === 'tcp') {
|
|
74
|
+
this.client = new TCPClient(this.host, this.port, this);
|
|
75
|
+
} else {
|
|
76
|
+
this.client = new SerialClient(this.serialPort, this.baudRate, this);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.client.on('connected', () => {
|
|
80
|
+
this.log('Gateway connected');
|
|
81
|
+
this.connected = true;
|
|
82
|
+
this.emit('gateway-connected');
|
|
83
|
+
|
|
84
|
+
// 连接成功后,延迟发现设备
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
if (this.connected) {
|
|
87
|
+
this.discoverDevices();
|
|
88
|
+
}
|
|
89
|
+
}, 1000);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
this.client.on('disconnected', () => {
|
|
93
|
+
this.log('Gateway disconnected');
|
|
94
|
+
this.connected = false;
|
|
95
|
+
this.deviceListComplete = false;
|
|
96
|
+
this.isQueryingStates = false;
|
|
97
|
+
this.emit('gateway-disconnected');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
this.client.on('frame', (frame) => {
|
|
101
|
+
this.handleFrame(frame);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
this.client.on('error', (error) => {
|
|
105
|
+
// 错误已经在客户端记录,这里只做额外处理(如果需要)
|
|
106
|
+
this.debug(`Client error handled: ${error.message}`);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// 尝试初始连接
|
|
110
|
+
await this.client.connect();
|
|
111
|
+
|
|
112
|
+
} catch (error) {
|
|
113
|
+
// 初始连接失败,但自动重连会继续尝试
|
|
114
|
+
this.error(`Initial connection failed: ${error.message}, will retry automatically`);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
SymiGatewayNode.prototype.disconnect = async function() {
|
|
119
|
+
if (this.client) {
|
|
120
|
+
await this.client.disconnect();
|
|
121
|
+
this.client = null;
|
|
122
|
+
}
|
|
123
|
+
this.connected = false;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
SymiGatewayNode.prototype.handleFrame = function(frame) {
|
|
127
|
+
// 记录所有接收到的frame的详细信息
|
|
128
|
+
const frameHex = Buffer.from([frame.header, frame.opcode, ...(frame.status !== null ? [frame.status] : []), frame.length, ...frame.payload, frame.checksum]).toString('hex').toUpperCase();
|
|
129
|
+
this.log(`[TCP Frame] 接收: ${frameHex} (opcode=0x${frame.opcode.toString(16).toUpperCase()})`);
|
|
130
|
+
|
|
131
|
+
if (frame.opcode === OP_RESP_DEVICE_LIST && frame.status === 0x00) {
|
|
132
|
+
this.parseDeviceListFrame(frame);
|
|
133
|
+
} else if (frame.isDeviceStatusEvent()) {
|
|
134
|
+
try {
|
|
135
|
+
const event = parseStatusEvent(frame);
|
|
136
|
+
this.log(`[状态事件] 地址=0x${event.networkAddress.toString(16).toUpperCase()}, 消息类型=0x${event.attrType.toString(16).toUpperCase()}, 参数=[${Array.from(event.parameters).map(p => '0x' + p.toString(16).toUpperCase()).join(', ')}]`);
|
|
137
|
+
|
|
138
|
+
const device = this.deviceManager.updateDeviceState(
|
|
139
|
+
event.networkAddress,
|
|
140
|
+
event.attrType,
|
|
141
|
+
event.parameters
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// 只在非查询状态期间才发送state-changed事件到MQTT
|
|
145
|
+
// 查询状态期间的事件只用于更新设备状态,不触发MQTT发布
|
|
146
|
+
if (device && !this.isQueryingStates) {
|
|
147
|
+
this.emit('device-state-changed', {
|
|
148
|
+
device: device,
|
|
149
|
+
attrType: event.attrType,
|
|
150
|
+
parameters: event.parameters,
|
|
151
|
+
state: device.state
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
this.error(`Error parsing status event: ${error.message}`);
|
|
156
|
+
}
|
|
157
|
+
} else if (frame.opcode === 0xB0) {
|
|
158
|
+
// 控制命令响应
|
|
159
|
+
this.log(`[控制响应] 0xB0: ${frameHex} ${frame.status === 0 ? '(成功)' : '(失败)'}`)
|
|
160
|
+
} else {
|
|
161
|
+
// 其他frame类型
|
|
162
|
+
this.log(`[其他Frame] ${frameHex}`);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
SymiGatewayNode.prototype.parseDeviceListFrame = function(frame) {
|
|
167
|
+
if (frame.payload.length < 16) return;
|
|
168
|
+
|
|
169
|
+
const maxDevices = frame.payload[0];
|
|
170
|
+
const index = frame.payload[1];
|
|
171
|
+
const macBytes = frame.payload.slice(2, 8);
|
|
172
|
+
const macAddress = Array.from(macBytes).map(b => b.toString(16).padStart(2, '0')).join(':');
|
|
173
|
+
const networkAddress = frame.payload[8] | (frame.payload[9] << 8);
|
|
174
|
+
const vendorId = frame.payload[10] | (frame.payload[11] << 8);
|
|
175
|
+
const deviceType = frame.payload[12];
|
|
176
|
+
const deviceSubType = frame.payload[13];
|
|
177
|
+
const statusByte = frame.payload[14];
|
|
178
|
+
const online = (statusByte & 0x01) === 0x01;
|
|
179
|
+
|
|
180
|
+
const device = this.deviceManager.addOrUpdateDevice({
|
|
181
|
+
macAddress, networkAddress, deviceType, deviceSubType,
|
|
182
|
+
vendorId, online
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
this.log(`Device ${index + 1}/${maxDevices}: ${device.name} @ 0x${networkAddress.toString(16).toUpperCase()}`);
|
|
186
|
+
|
|
187
|
+
// 对于温控器类型(0x0A),需要通过查询0x94来判断是否是三合一
|
|
188
|
+
if (deviceType === 10) {
|
|
189
|
+
device.needsThreeInOneCheck = true;
|
|
190
|
+
this.log(`温控器设备待检测,将通过0x94查询确认类型: ${device.name}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (index === maxDevices - 1) {
|
|
194
|
+
this.log(`Device discovery complete: ${maxDevices} devices`);
|
|
195
|
+
|
|
196
|
+
// 先查询设备状态,完成后再触发device-list-complete
|
|
197
|
+
setTimeout(async () => {
|
|
198
|
+
this.isQueryingStates = true; // 开始查询状态
|
|
199
|
+
await this.queryAllDeviceStates();
|
|
200
|
+
this.isQueryingStates = false; // 查询完成
|
|
201
|
+
|
|
202
|
+
this.deviceListComplete = true;
|
|
203
|
+
// 状态查询完成后才触发device-list-complete,确保设备类型已确认
|
|
204
|
+
this.emit('device-list-complete', this.deviceManager.getAllDevices());
|
|
205
|
+
}, 500);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
SymiGatewayNode.prototype.queryAllDeviceStates = async function() {
|
|
210
|
+
const devices = this.deviceManager.getAllDevices();
|
|
211
|
+
this.log(`开始查询${devices.length}个设备的初始状态...`);
|
|
212
|
+
|
|
213
|
+
for (const device of devices) {
|
|
214
|
+
try {
|
|
215
|
+
let queryAttrs = [];
|
|
216
|
+
|
|
217
|
+
// 根据设备类型查询不同属性
|
|
218
|
+
if ([1, 2, 3].includes(device.deviceType)) {
|
|
219
|
+
queryAttrs = [0x02]; // 开关状态
|
|
220
|
+
} else if (device.deviceType === 9) {
|
|
221
|
+
queryAttrs = [0x02, 0x0E]; // 插卡取电:开关状态、插卡状态
|
|
222
|
+
} else if (device.deviceType === 4 || device.deviceType === 0x18) {
|
|
223
|
+
queryAttrs = [0x02, 0x03, 0x04]; // 灯光:开关、亮度、色温
|
|
224
|
+
} else if (device.deviceType === 5) {
|
|
225
|
+
queryAttrs = [0x05, 0x06]; // 窗帘:运行状态、位置
|
|
226
|
+
} else if (device.deviceType === 10) {
|
|
227
|
+
// 温控器类型:先查询三合一专用消息(0x68/0x6B)判断是否是三合一
|
|
228
|
+
if (device.needsThreeInOneCheck) {
|
|
229
|
+
this.log(`检测设备${device.name}是否为三合一面板...`);
|
|
230
|
+
// 先查询0x68(新风开关),如果有响应就是三合一
|
|
231
|
+
queryAttrs = [0x68];
|
|
232
|
+
} else if (device.isThreeInOne) {
|
|
233
|
+
// 已确认是三合一,查询完整状态
|
|
234
|
+
queryAttrs = [0x16, 0x1B, 0x1C, 0x1D, 0x68, 0x6A, 0x6B, 0x6C];
|
|
235
|
+
} else {
|
|
236
|
+
// 普通温控器
|
|
237
|
+
queryAttrs = [0x02, 0x16, 0x1B, 0x1C, 0x1D];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const attr of queryAttrs) {
|
|
242
|
+
const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
|
|
243
|
+
await this.client.sendFrame(frame, 2);
|
|
244
|
+
await this.sleep(150); // 增加延迟确保设备有时间响应
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 等待设备响应,检查是否被标记为三合一
|
|
248
|
+
if (device.needsThreeInOneCheck) {
|
|
249
|
+
await this.sleep(800); // 增加到800ms确保响应到达
|
|
250
|
+
if (device.isThreeInOne) {
|
|
251
|
+
this.log(`设备${device.name}确认为三合一面板,继续查询完整状态`);
|
|
252
|
+
device.needsThreeInOneCheck = false;
|
|
253
|
+
this.deviceManager.saveDevices();
|
|
254
|
+
|
|
255
|
+
// 查询三合一的其他状态
|
|
256
|
+
const threeInOneAttrs = [0x02, 0x16, 0x1B, 0x1C, 0x1D, 0x6A, 0x6B, 0x6C];
|
|
257
|
+
for (const attr of threeInOneAttrs) {
|
|
258
|
+
const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
|
|
259
|
+
await this.client.sendFrame(frame, 2);
|
|
260
|
+
await this.sleep(150);
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
this.log(`设备${device.name}确认为普通温控器`);
|
|
264
|
+
device.needsThreeInOneCheck = false;
|
|
265
|
+
device.thermostatConfirmed = true; // 标记为已确认的温控器
|
|
266
|
+
this.deviceManager.saveDevices();
|
|
267
|
+
|
|
268
|
+
// 查询温控器状态
|
|
269
|
+
const tempCtrlAttrs = [0x02, 0x16, 0x1B, 0x1C, 0x1D];
|
|
270
|
+
for (const attr of tempCtrlAttrs) {
|
|
271
|
+
const frame = this.protocolHandler.buildDeviceStatusQueryFrame(device.networkAddress, attr);
|
|
272
|
+
await this.client.sendFrame(frame, 2);
|
|
273
|
+
await this.sleep(150);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch(e) {
|
|
278
|
+
this.error(`查询设备${device.name}失败: ${e.message}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.log('设备状态查询完成');
|
|
283
|
+
|
|
284
|
+
// 启用所有设备的状态上报功能
|
|
285
|
+
this.log('启用设备状态上报功能...');
|
|
286
|
+
for (const device of devices) {
|
|
287
|
+
try {
|
|
288
|
+
// 启用状态上报:msg_type=0x10, param=0x01
|
|
289
|
+
const frame = this.protocolHandler.buildDeviceControlFrame(device.networkAddress, 0x10, Buffer.from([0x01]));
|
|
290
|
+
await this.client.sendFrame(frame, 2);
|
|
291
|
+
await this.sleep(50);
|
|
292
|
+
this.log(`已启用设备${device.name}的状态上报`);
|
|
293
|
+
} catch(e) {
|
|
294
|
+
this.error(`启用设备${device.name}状态上报失败: ${e.message}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
this.log('状态上报功能启用完成');
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
SymiGatewayNode.prototype.sleep = function(ms) {
|
|
301
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
SymiGatewayNode.prototype.discoverDevices = async function() {
|
|
305
|
+
if (!this.connected) {
|
|
306
|
+
this.error('Not connected');
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
this.log('Discovering devices (53 12 00 41)...');
|
|
312
|
+
const frame = this.protocolHandler.buildReadDeviceListFrame();
|
|
313
|
+
await this.client.sendFrame(frame, 0);
|
|
314
|
+
return true;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
this.error(`Discovery failed: ${error.message}`);
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
SymiGatewayNode.prototype.sendControl = async function(networkAddr, attrType, param) {
|
|
322
|
+
if (!this.connected) {
|
|
323
|
+
throw new Error('Not connected');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 对于开关控制,如果参数格式是[channels, targetChannel, targetState],需要先查询当前状态
|
|
327
|
+
if (attrType === 0x02 && param.length === 3) {
|
|
328
|
+
const channels = param[0];
|
|
329
|
+
const targetChannel = param[1];
|
|
330
|
+
const targetState = param[2] === 1;
|
|
331
|
+
|
|
332
|
+
// 单路开关直接控制,无需查询
|
|
333
|
+
if (channels === 1) {
|
|
334
|
+
const directParam = Buffer.from([targetState ? 0x02 : 0x01]);
|
|
335
|
+
const frame = this.protocolHandler.buildDeviceControlFrame(networkAddr, attrType, directParam);
|
|
336
|
+
return await this.client.sendFrame(frame, 1);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 多路开关需要使用当前缓存状态
|
|
340
|
+
try {
|
|
341
|
+
// 从设备管理器获取当前状态
|
|
342
|
+
const device = this.deviceManager.getDeviceByAddress(networkAddr);
|
|
343
|
+
let currentState = null;
|
|
344
|
+
|
|
345
|
+
if (device && typeof device.getCurrentSwitchState === 'function') {
|
|
346
|
+
currentState = device.getCurrentSwitchState();
|
|
347
|
+
this.log(`使用缓存状态: 0x${currentState.toString(16).toUpperCase()}`);
|
|
348
|
+
} else if (device && device.state.switchState !== undefined) {
|
|
349
|
+
currentState = device.state.switchState;
|
|
350
|
+
this.log(`使用保存状态: 0x${currentState.toString(16).toUpperCase()}`);
|
|
351
|
+
} else {
|
|
352
|
+
// 如果没有当前状态,查询一次
|
|
353
|
+
this.log(`查询开关状态: 地址0x${networkAddr.toString(16).toUpperCase()}`);
|
|
354
|
+
const queryFrame = this.protocolHandler.buildDeviceStatusQueryFrame(networkAddr, 0x02);
|
|
355
|
+
await this.client.sendFrame(queryFrame, 2);
|
|
356
|
+
await this.sleep(200);
|
|
357
|
+
|
|
358
|
+
if (device && typeof device.getCurrentSwitchState === 'function') {
|
|
359
|
+
currentState = device.getCurrentSwitchState();
|
|
360
|
+
} else {
|
|
361
|
+
// 使用默认全关状态
|
|
362
|
+
const defaultStates = { 2: 0x05, 3: 0x15, 4: 0x55, 6: 0x5555 };
|
|
363
|
+
currentState = defaultStates[channels] || 0x55;
|
|
364
|
+
this.log(`使用默认状态: 0x${currentState.toString(16).toUpperCase()}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 使用状态组合算法
|
|
369
|
+
const stateValue = this.protocolHandler.buildSwitchState(channels, targetChannel, targetState, currentState);
|
|
370
|
+
|
|
371
|
+
let controlParam;
|
|
372
|
+
if (Buffer.isBuffer(stateValue)) {
|
|
373
|
+
// 6路开关,2字节状态
|
|
374
|
+
controlParam = stateValue;
|
|
375
|
+
} else {
|
|
376
|
+
// 1-4路开关,1字节状态
|
|
377
|
+
controlParam = Buffer.from([stateValue]);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
this.log(`发送开关控制: 地址0x${networkAddr.toString(16).toUpperCase()}, 第${targetChannel}路${targetState ? '开' : '关'}, 状态值0x${stateValue.toString(16).toUpperCase()}`);
|
|
381
|
+
const frame = this.protocolHandler.buildDeviceControlFrame(networkAddr, attrType, controlParam);
|
|
382
|
+
return await this.client.sendFrame(frame, 1);
|
|
383
|
+
|
|
384
|
+
} catch (error) {
|
|
385
|
+
this.error(`开关控制失败: ${error.message}`);
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 其他控制直接发送
|
|
391
|
+
const frame = this.protocolHandler.buildDeviceControlFrame(networkAddr, attrType, param);
|
|
392
|
+
return await this.client.sendFrame(frame, 1);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
SymiGatewayNode.prototype.sendScene = async function(sceneId) {
|
|
396
|
+
if (!this.connected) {
|
|
397
|
+
throw new Error('Not connected');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const frame = this.protocolHandler.buildSceneControlFrame(sceneId);
|
|
401
|
+
return await this.client.sendFrame(frame, 1);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
SymiGatewayNode.prototype.getDeviceList = function() {
|
|
405
|
+
return this.deviceManager.getDeviceList();
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
SymiGatewayNode.prototype.getDevice = function(mac) {
|
|
409
|
+
return this.deviceManager.getDeviceByMac(mac);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
SymiGatewayNode.prototype.updateThreeInOneMac = function(addr, realMac) {
|
|
413
|
+
const device = this.deviceManager.getDeviceByAddress(addr);
|
|
414
|
+
if (device && device.isThreeInOne && device.needsMacUpdate) {
|
|
415
|
+
this.log(`更新三合一面板MAC: 0x${addr.toString(16).toUpperCase()} -> ${realMac}`);
|
|
416
|
+
|
|
417
|
+
// 删除旧MAC映射
|
|
418
|
+
const oldMac = device.macAddress;
|
|
419
|
+
this.deviceManager.devices.delete(oldMac);
|
|
420
|
+
|
|
421
|
+
// 更新设备MAC
|
|
422
|
+
device.macAddress = realMac;
|
|
423
|
+
device.needsMacUpdate = false;
|
|
424
|
+
device.name = device.generateName();
|
|
425
|
+
|
|
426
|
+
// 重新添加映射
|
|
427
|
+
this.deviceManager.devices.set(realMac, device);
|
|
428
|
+
this.deviceManager.macToAddress.set(realMac, addr);
|
|
429
|
+
this.deviceManager.saveDevices();
|
|
430
|
+
|
|
431
|
+
// 触发重新发布MQTT Discovery
|
|
432
|
+
this.emit('device-updated', device, true);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
RED.nodes.registerType('symi-gateway', SymiGatewayNode);
|
|
437
|
+
|
|
438
|
+
if (RED.httpAdmin) {
|
|
439
|
+
RED.httpAdmin.get('/symi-gateway/devices/:id', function(req, res) {
|
|
440
|
+
const node = RED.nodes.getNode(req.params.id);
|
|
441
|
+
if (node && node.deviceManager) {
|
|
442
|
+
res.json(node.deviceManager.getDeviceList());
|
|
443
|
+
} else {
|
|
444
|
+
res.json([]);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('symi-mqtt', {
|
|
3
|
+
category: 'Symi Mesh',
|
|
4
|
+
color: '#DB4437',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
gateway: { value: '', type: 'symi-gateway', required: true },
|
|
8
|
+
mqttBroker: { value: 'mqtt://localhost:1883' },
|
|
9
|
+
mqttUsername: { value: '' },
|
|
10
|
+
mqttPassword: { value: '' },
|
|
11
|
+
mqttPrefix: { value: 'homeassistant' }
|
|
12
|
+
},
|
|
13
|
+
inputs: 0,
|
|
14
|
+
outputs: 0,
|
|
15
|
+
icon: 'bridge-dash.png',
|
|
16
|
+
label: function() {
|
|
17
|
+
return this.name || 'MQTT桥接';
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<script type="text/html" data-template-name="symi-mqtt">
|
|
23
|
+
<div class="form-row">
|
|
24
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> 名称</label>
|
|
25
|
+
<input type="text" id="node-input-name" placeholder="MQTT桥接">
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div class="form-row">
|
|
29
|
+
<label for="node-input-gateway"><i class="fa fa-server"></i> 网关</label>
|
|
30
|
+
<input type="text" id="node-input-gateway">
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div class="form-row">
|
|
34
|
+
<label for="node-input-mqttBroker"><i class="fa fa-rss"></i> MQTT地址</label>
|
|
35
|
+
<input type="text" id="node-input-mqttBroker" placeholder="mqtt://localhost:1883">
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div class="form-row">
|
|
39
|
+
<label for="node-input-mqttUsername"><i class="fa fa-user"></i> 用户名</label>
|
|
40
|
+
<input type="text" id="node-input-mqttUsername" placeholder="可选">
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="form-row">
|
|
44
|
+
<label for="node-input-mqttPassword"><i class="fa fa-lock"></i> 密码</label>
|
|
45
|
+
<input type="password" id="node-input-mqttPassword" placeholder="可选">
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="form-row">
|
|
49
|
+
<label for="node-input-mqttPrefix"><i class="fa fa-folder-open"></i> HA前缀</label>
|
|
50
|
+
<input type="text" id="node-input-mqttPrefix" placeholder="homeassistant">
|
|
51
|
+
</div>
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<script type="text/html" data-help-name="symi-mqtt">
|
|
55
|
+
<p>MQTT桥接节点,用于Home Assistant集成</p>
|
|
56
|
+
|
|
57
|
+
<h3>功能</h3>
|
|
58
|
+
<ul>
|
|
59
|
+
<li>自动发布HA MQTT Discovery配置</li>
|
|
60
|
+
<li>双向状态同步(Symi ↔ MQTT)</li>
|
|
61
|
+
<li>支持所有设备类型</li>
|
|
62
|
+
</ul>
|
|
63
|
+
|
|
64
|
+
<h3>配置说明</h3>
|
|
65
|
+
<dl>
|
|
66
|
+
<dt>MQTT地址</dt>
|
|
67
|
+
<dd>MQTT Broker地址,格式:mqtt://ip:port</dd>
|
|
68
|
+
|
|
69
|
+
<dt>用户名/密码</dt>
|
|
70
|
+
<dd>MQTT认证信息(如果Broker需要)</dd>
|
|
71
|
+
|
|
72
|
+
<dt>HA前缀</dt>
|
|
73
|
+
<dd>Home Assistant Discovery前缀,默认homeassistant</dd>
|
|
74
|
+
</dl>
|
|
75
|
+
|
|
76
|
+
<h3>工作原理</h3>
|
|
77
|
+
<ol>
|
|
78
|
+
<li>连接MQTT Broker</li>
|
|
79
|
+
<li>等待网关发现设备完成</li>
|
|
80
|
+
<li>为每个设备发布HA Discovery配置</li>
|
|
81
|
+
<li>订阅命令topic,接收HA控制</li>
|
|
82
|
+
<li>监听设备状态,发布到MQTT</li>
|
|
83
|
+
</ol>
|
|
84
|
+
|
|
85
|
+
<h3>Home Assistant配置</h3>
|
|
86
|
+
<p>在HA的configuration.yaml中启用MQTT Discovery:</p>
|
|
87
|
+
<pre>mqtt:
|
|
88
|
+
broker: localhost
|
|
89
|
+
discovery: true
|
|
90
|
+
discovery_prefix: homeassistant</pre>
|
|
91
|
+
|
|
92
|
+
<p>重启HA后,Symi设备会自动出现在设备列表中</p>
|
|
93
|
+
</script>
|
|
94
|
+
|