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,303 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('symi-device', {
|
|
3
|
+
category: 'Symi Mesh',
|
|
4
|
+
color: '#89CFF0',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
gateway: { value: '', type: 'symi-gateway', required: true },
|
|
8
|
+
deviceMac: { value: '', required: true },
|
|
9
|
+
channel: { value: 1 },
|
|
10
|
+
deviceData: { value: '' },
|
|
11
|
+
outputFormat: { value: 'full' },
|
|
12
|
+
threeInOneSubType: { value: 'ac' },
|
|
13
|
+
threeInOneRealMac: { value: '' }
|
|
14
|
+
},
|
|
15
|
+
inputs: 1,
|
|
16
|
+
outputs: 1,
|
|
17
|
+
icon: 'bridge.png',
|
|
18
|
+
label: function() {
|
|
19
|
+
if (this.name) return this.name;
|
|
20
|
+
if (this.deviceMac && this.channel > 1) {
|
|
21
|
+
return 'Symi设备-' + this.channel + '路';
|
|
22
|
+
}
|
|
23
|
+
return 'Symi设备';
|
|
24
|
+
},
|
|
25
|
+
labelStyle: function() {
|
|
26
|
+
return this.name ? 'node_label_italic' : '';
|
|
27
|
+
},
|
|
28
|
+
oneditprepare: function() {
|
|
29
|
+
var node = this;
|
|
30
|
+
node._deviceCache = {};
|
|
31
|
+
|
|
32
|
+
var updateChannelSelector = function(channels) {
|
|
33
|
+
if (channels > 1) {
|
|
34
|
+
$('.channel-row').show();
|
|
35
|
+
var channelSelect = $('#node-input-channel');
|
|
36
|
+
channelSelect.empty();
|
|
37
|
+
for (var i = 1; i <= channels; i++) {
|
|
38
|
+
channelSelect.append($('<option></option>').val(i).text('第' + i + '路'));
|
|
39
|
+
}
|
|
40
|
+
if (node.channel) {
|
|
41
|
+
channelSelect.val(node.channel);
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
$('.channel-row').hide();
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
var updateThreeInOneSelector = function(deviceType) {
|
|
49
|
+
if (deviceType === 0x94 || deviceType === 148) {
|
|
50
|
+
$('.three-in-one-row').show();
|
|
51
|
+
$('.three-in-one-mac-row').show();
|
|
52
|
+
} else {
|
|
53
|
+
$('.three-in-one-row').hide();
|
|
54
|
+
$('.three-in-one-mac-row').hide();
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
var loadDevices = function(gatewayId) {
|
|
59
|
+
if (!gatewayId) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
var deviceSelect = $('#node-input-deviceMac');
|
|
64
|
+
var currentValue = deviceSelect.val();
|
|
65
|
+
|
|
66
|
+
deviceSelect.empty();
|
|
67
|
+
deviceSelect.append('<option value="">-- 选择设备 --</option>');
|
|
68
|
+
|
|
69
|
+
$.getJSON('/symi-gateway/devices/' + gatewayId)
|
|
70
|
+
.done(function(devices) {
|
|
71
|
+
console.log('加载设备列表:', devices.length, '个设备');
|
|
72
|
+
if (devices && devices.length > 0) {
|
|
73
|
+
devices.forEach(function(device) {
|
|
74
|
+
var label = device.name + ' (' + device.mac + ')';
|
|
75
|
+
if (device.channels > 1) {
|
|
76
|
+
label += ' [' + device.channels + '路]';
|
|
77
|
+
}
|
|
78
|
+
if (device.isThreeInOne) {
|
|
79
|
+
label += ' [三合一]';
|
|
80
|
+
}
|
|
81
|
+
var option = $('<option></option>')
|
|
82
|
+
.val(device.mac)
|
|
83
|
+
.text(label)
|
|
84
|
+
.data('channels', device.channels)
|
|
85
|
+
.data('deviceType', device.isThreeInOne ? 0x94 : device.deviceType);
|
|
86
|
+
deviceSelect.append(option);
|
|
87
|
+
node._deviceCache[device.mac] = device;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (currentValue) {
|
|
92
|
+
deviceSelect.val(currentValue);
|
|
93
|
+
} else if (node.deviceMac) {
|
|
94
|
+
deviceSelect.val(node.deviceMac);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
deviceSelect.trigger('change');
|
|
98
|
+
})
|
|
99
|
+
.fail(function(err) {
|
|
100
|
+
console.log('加载设备失败:', err);
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
$('#node-input-gateway').on('change', function() {
|
|
105
|
+
loadDevices($(this).val());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
$('#node-input-deviceMac').on('change', function() {
|
|
109
|
+
var selectedMac = $(this).val();
|
|
110
|
+
if (!selectedMac) {
|
|
111
|
+
$('.channel-row').hide();
|
|
112
|
+
$('.three-in-one-row').hide();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
var device = node._deviceCache[selectedMac];
|
|
117
|
+
if (!device) {
|
|
118
|
+
var selectedOption = $(this).find('option:selected');
|
|
119
|
+
var channels = selectedOption.data('channels') || 1;
|
|
120
|
+
var deviceType = selectedOption.data('deviceType') || 0;
|
|
121
|
+
updateChannelSelector(channels);
|
|
122
|
+
updateThreeInOneSelector(deviceType);
|
|
123
|
+
} else {
|
|
124
|
+
updateChannelSelector(device.channels);
|
|
125
|
+
updateThreeInOneSelector(device.deviceType);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (this.deviceMac && this.deviceData) {
|
|
130
|
+
try {
|
|
131
|
+
var savedDevice = JSON.parse(this.deviceData);
|
|
132
|
+
if (savedDevice.channels > 1) {
|
|
133
|
+
updateChannelSelector(savedDevice.channels);
|
|
134
|
+
}
|
|
135
|
+
if (savedDevice.deviceType === 0x94 || savedDevice.deviceType === 148) {
|
|
136
|
+
updateThreeInOneSelector(savedDevice.deviceType);
|
|
137
|
+
}
|
|
138
|
+
} catch(e) {}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (this.gateway) {
|
|
142
|
+
loadDevices(this.gateway);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
oneditsave: function() {
|
|
146
|
+
var selectedMac = $('#node-input-deviceMac').val();
|
|
147
|
+
if (selectedMac && this._deviceCache && this._deviceCache[selectedMac]) {
|
|
148
|
+
this.deviceData = JSON.stringify(this._deviceCache[selectedMac]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
</script>
|
|
153
|
+
|
|
154
|
+
<script type="text/html" data-template-name="symi-device">
|
|
155
|
+
<div class="form-row">
|
|
156
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> 名称</label>
|
|
157
|
+
<input type="text" id="node-input-name" placeholder="设备名称">
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div class="form-row">
|
|
161
|
+
<label for="node-input-gateway"><i class="fa fa-server"></i> 网关</label>
|
|
162
|
+
<input type="text" id="node-input-gateway">
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div class="form-row">
|
|
166
|
+
<label for="node-input-deviceMac"><i class="fa fa-microchip"></i> 设备</label>
|
|
167
|
+
<select id="node-input-deviceMac" style="width:70%">
|
|
168
|
+
<option value="">-- 选择设备 --</option>
|
|
169
|
+
</select>
|
|
170
|
+
<input type="hidden" id="node-input-deviceData">
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div class="form-row channel-row" style="display:none">
|
|
174
|
+
<label for="node-input-channel"><i class="fa fa-th"></i> 通道</label>
|
|
175
|
+
<select id="node-input-channel" style="width:70%">
|
|
176
|
+
<option value="1">第1路</option>
|
|
177
|
+
</select>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div class="form-row three-in-one-row" style="display:none">
|
|
181
|
+
<label for="node-input-threeInOneSubType"><i class="fa fa-th-list"></i> 子实体</label>
|
|
182
|
+
<select id="node-input-threeInOneSubType" style="width:70%">
|
|
183
|
+
<option value="ac">空调</option>
|
|
184
|
+
<option value="fresh_air">新风</option>
|
|
185
|
+
<option value="floor_heating">地暖</option>
|
|
186
|
+
</select>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<div class="form-row three-in-one-mac-row" style="display:none">
|
|
190
|
+
<label for="node-input-threeInOneRealMac"><i class="fa fa-id-card"></i> 真实MAC</label>
|
|
191
|
+
<input type="text" id="node-input-threeInOneRealMac" placeholder="CCDA20B84A89" style="width:70%">
|
|
192
|
+
<div style="margin-left: 105px; margin-top: 5px;">
|
|
193
|
+
<small>输入12位MAC地址(无冒号,如CCDA20B84A89)</small>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div class="form-row">
|
|
198
|
+
<label for="node-input-outputFormat"><i class="fa fa-list"></i> 输出格式</label>
|
|
199
|
+
<select id="node-input-outputFormat">
|
|
200
|
+
<option value="full">完整(包含设备信息)</option>
|
|
201
|
+
<option value="simple">简单(仅状态值)</option>
|
|
202
|
+
</select>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div class="form-tips">
|
|
206
|
+
<p><b>输入:</b>接收控制命令,支持多种格式</p>
|
|
207
|
+
<p><b>输出:</b>设备状态变化时自动输出</p>
|
|
208
|
+
<p><b>KNX同步:</b>直接与KNX节点连线即可实现双向同步</p>
|
|
209
|
+
</div>
|
|
210
|
+
</script>
|
|
211
|
+
|
|
212
|
+
<script type="text/html" data-help-name="symi-device">
|
|
213
|
+
<p>Symi Mesh设备节点,同时支持输入控制和输出状态反馈</p>
|
|
214
|
+
|
|
215
|
+
<h3>输入 (控制设备)</h3>
|
|
216
|
+
<p>接收以下格式的控制命令:</p>
|
|
217
|
+
|
|
218
|
+
<h4>开关控制</h4>
|
|
219
|
+
<pre>msg.payload = true; // 打开
|
|
220
|
+
msg.payload = false; // 关闭
|
|
221
|
+
msg.payload = "on"; // 打开
|
|
222
|
+
msg.payload = 1; // 打开
|
|
223
|
+
msg.channel = 1; // 多路开关指定通道(1-6)</pre>
|
|
224
|
+
|
|
225
|
+
<h4>调光控制</h4>
|
|
226
|
+
<pre>msg.command = "brightness";
|
|
227
|
+
msg.payload = 80; // 亮度 0-100
|
|
228
|
+
|
|
229
|
+
msg.command = "colorTemp";
|
|
230
|
+
msg.payload = 50; // 色温 0-100
|
|
231
|
+
|
|
232
|
+
msg.command = "rgb";
|
|
233
|
+
msg.payload = { // RGB颜色
|
|
234
|
+
r: 255, g: 128, b: 64,
|
|
235
|
+
ww: 0, cw: 0 // 可选
|
|
236
|
+
};</pre>
|
|
237
|
+
|
|
238
|
+
<h4>窗帘控制</h4>
|
|
239
|
+
<pre>msg.payload = "open"; // 打开
|
|
240
|
+
msg.payload = "close"; // 关闭
|
|
241
|
+
msg.payload = "stop"; // 停止
|
|
242
|
+
msg.payload = 50; // 位置 0-100</pre>
|
|
243
|
+
|
|
244
|
+
<h4>温控控制</h4>
|
|
245
|
+
<pre>msg.command = "temperature";
|
|
246
|
+
msg.payload = 25; // 温度 16-30
|
|
247
|
+
|
|
248
|
+
msg.command = "mode";
|
|
249
|
+
msg.payload = "cool"; // cool/heat/fan_only/dry
|
|
250
|
+
|
|
251
|
+
msg.command = "fan";
|
|
252
|
+
msg.payload = "auto"; // high/medium/low/auto</pre>
|
|
253
|
+
|
|
254
|
+
<h4>场景控制</h4>
|
|
255
|
+
<pre>msg.payload = 2; // 场景ID 0-31</pre>
|
|
256
|
+
|
|
257
|
+
<h3>输出 (状态反馈)</h3>
|
|
258
|
+
<p>设备状态变化时自动输出:</p>
|
|
259
|
+
|
|
260
|
+
<h4>完整格式 (outputFormat: full)</h4>
|
|
261
|
+
<pre>{
|
|
262
|
+
topic: "设备名称",
|
|
263
|
+
payload: {
|
|
264
|
+
switch: true, // 或其他状态字段
|
|
265
|
+
brightness: 80,
|
|
266
|
+
...
|
|
267
|
+
},
|
|
268
|
+
device: {
|
|
269
|
+
mac: "aa:bb:cc:dd:ee:ff",
|
|
270
|
+
name: "设备名称",
|
|
271
|
+
type: "light",
|
|
272
|
+
address: 0x1234
|
|
273
|
+
},
|
|
274
|
+
attrType: 0x03
|
|
275
|
+
}</pre>
|
|
276
|
+
|
|
277
|
+
<h4>简单格式 (outputFormat: simple)</h4>
|
|
278
|
+
<pre>{
|
|
279
|
+
topic: "设备名称",
|
|
280
|
+
payload: true // 或 80, 或其他状态值
|
|
281
|
+
}</pre>
|
|
282
|
+
|
|
283
|
+
<h3>KNX双向同步</h3>
|
|
284
|
+
<p>直接与KNX节点连线实现双向同步:</p>
|
|
285
|
+
<pre>
|
|
286
|
+
[KNX节点] ----输出---→ [Symi设备节点] ----输入
|
|
287
|
+
↑ ↓
|
|
288
|
+
└--------输入←------输出-----┘
|
|
289
|
+
|
|
290
|
+
双向连线后:
|
|
291
|
+
- KNX控制 → Symi设备执行
|
|
292
|
+
- Symi状态变化 → KNX同步
|
|
293
|
+
</pre>
|
|
294
|
+
|
|
295
|
+
<h3>使用技巧</h3>
|
|
296
|
+
<ul>
|
|
297
|
+
<li>从下拉列表选择设备,无需手动输入MAC地址</li>
|
|
298
|
+
<li>一个节点同时处理输入和输出</li>
|
|
299
|
+
<li>可以与inject、debug、KNX、function等任意节点连接</li>
|
|
300
|
+
<li>支持msg覆盖配置(如channel、command等)</li>
|
|
301
|
+
</ul>
|
|
302
|
+
</script>
|
|
303
|
+
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symi Device Node - 设备节点(同时支持输入控制和输出状态)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
module.exports = function(RED) {
|
|
6
|
+
function SymiDeviceNode(config) {
|
|
7
|
+
RED.nodes.createNode(this, config);
|
|
8
|
+
const node = this;
|
|
9
|
+
|
|
10
|
+
node.gateway = RED.nodes.getNode(config.gateway);
|
|
11
|
+
node.deviceMac = config.deviceMac;
|
|
12
|
+
node.channel = parseInt(config.channel) || 1;
|
|
13
|
+
node.outputFormat = config.outputFormat || 'full';
|
|
14
|
+
node.threeInOneSubType = config.threeInOneSubType || 'ac';
|
|
15
|
+
node.threeInOneRealMac = config.threeInOneRealMac || '';
|
|
16
|
+
|
|
17
|
+
// 如果提供了真实MAC,更新设备
|
|
18
|
+
if (node.threeInOneRealMac && node.deviceMac && node.deviceMac.startsWith('00:00:00:00:')) {
|
|
19
|
+
setTimeout(() => {
|
|
20
|
+
const device = node.gateway.getDevice(node.deviceMac);
|
|
21
|
+
if (device && device.isThreeInOne) {
|
|
22
|
+
// MAC反序并格式化
|
|
23
|
+
const cleanMac = node.threeInOneRealMac.replace(/[:-]/g, '').toLowerCase();
|
|
24
|
+
if (cleanMac.length === 12) {
|
|
25
|
+
const reversedMac = cleanMac.match(/.{2}/g).reverse().join(':');
|
|
26
|
+
node.gateway.updateThreeInOneMac(device.networkAddress, reversedMac);
|
|
27
|
+
node.log(`已更新三合一面板MAC: ${reversedMac}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}, 2000);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!node.gateway) {
|
|
34
|
+
node.error('未配置网关');
|
|
35
|
+
node.status({ fill: 'red', shape: 'ring', text: '未配置网关' });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
node.device = null;
|
|
40
|
+
node.status({ fill: 'yellow', shape: 'ring', text: '等待连接' });
|
|
41
|
+
|
|
42
|
+
const updateDevice = () => {
|
|
43
|
+
if (node.deviceMac) {
|
|
44
|
+
node.device = node.gateway.getDevice(node.deviceMac);
|
|
45
|
+
if (node.device) {
|
|
46
|
+
const channelInfo = node.device.channels > 1 ? ` 第${node.channel}路` : '';
|
|
47
|
+
node.status({
|
|
48
|
+
fill: 'green',
|
|
49
|
+
shape: 'dot',
|
|
50
|
+
text: `已连接${channelInfo}`
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
node.status({ fill: 'yellow', shape: 'ring', text: '等待设备' });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleStateChange = (eventData) => {
|
|
59
|
+
if (!node.deviceMac || eventData.device.macAddress !== node.deviceMac) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
updateDevice();
|
|
64
|
+
|
|
65
|
+
const msg = node.formatOutput(eventData);
|
|
66
|
+
node.send(msg);
|
|
67
|
+
|
|
68
|
+
node.status({
|
|
69
|
+
fill: 'green',
|
|
70
|
+
shape: 'dot',
|
|
71
|
+
text: `最后更新: ${new Date().toLocaleTimeString()}`
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
node.gateway.on('gateway-connected', () => {
|
|
76
|
+
node.status({ fill: 'green', shape: 'ring', text: '网关已连接' });
|
|
77
|
+
updateDevice();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
node.gateway.on('gateway-disconnected', () => {
|
|
81
|
+
node.status({ fill: 'red', shape: 'ring', text: '网关断开' });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
node.gateway.on('device-list-complete', () => {
|
|
85
|
+
updateDevice();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
node.gateway.on('device-state-changed', handleStateChange);
|
|
89
|
+
|
|
90
|
+
node.on('input', async function(msg) {
|
|
91
|
+
try {
|
|
92
|
+
if (!node.gateway.connected) {
|
|
93
|
+
node.error('网关未连接');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!node.device) {
|
|
98
|
+
updateDevice();
|
|
99
|
+
if (!node.device) {
|
|
100
|
+
node.error('设备未找到');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const command = node.parseInputCommand(msg);
|
|
106
|
+
if (!command) {
|
|
107
|
+
node.warn('无效的命令格式');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await node.sendControl(command);
|
|
112
|
+
|
|
113
|
+
node.status({ fill: 'green', shape: 'dot', text: '命令已发送' });
|
|
114
|
+
|
|
115
|
+
} catch (error) {
|
|
116
|
+
node.error(`控制失败: ${error.message}`);
|
|
117
|
+
node.status({ fill: 'red', shape: 'dot', text: '控制失败' });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
node.on('close', () => {
|
|
122
|
+
node.gateway.removeListener('device-state-changed', handleStateChange);
|
|
123
|
+
node.status({});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
updateDevice();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
SymiDeviceNode.prototype.parseInputCommand = function(msg) {
|
|
130
|
+
const payload = msg.payload;
|
|
131
|
+
const device = this.device;
|
|
132
|
+
const entityType = device.getEntityType();
|
|
133
|
+
|
|
134
|
+
let command = null;
|
|
135
|
+
|
|
136
|
+
if (entityType === 'switch') {
|
|
137
|
+
const channel = parseInt(msg.channel) || node.channel || 1;
|
|
138
|
+
let value;
|
|
139
|
+
|
|
140
|
+
if (typeof payload === 'boolean') {
|
|
141
|
+
value = payload;
|
|
142
|
+
} else if (typeof payload === 'number') {
|
|
143
|
+
value = payload > 0;
|
|
144
|
+
} else if (typeof payload === 'string') {
|
|
145
|
+
value = payload.toLowerCase() === 'on' || payload === '1' || payload === 'true';
|
|
146
|
+
} else {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
command = {
|
|
151
|
+
attrType: 0x02,
|
|
152
|
+
param: this.buildSwitchParam(channel, value, device.channels)
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
} else if (entityType === 'light') {
|
|
156
|
+
if (msg.command === 'brightness' || (!msg.command && typeof payload === 'number')) {
|
|
157
|
+
const brightness = Math.max(0, Math.min(100, parseInt(payload)));
|
|
158
|
+
command = { attrType: 0x03, param: Buffer.from([brightness]) };
|
|
159
|
+
|
|
160
|
+
} else if (msg.command === 'colorTemp') {
|
|
161
|
+
const colorTemp = Math.max(0, Math.min(100, parseInt(payload)));
|
|
162
|
+
command = { attrType: 0x04, param: Buffer.from([colorTemp]) };
|
|
163
|
+
|
|
164
|
+
} else if (msg.command === 'rgb' && typeof payload === 'object') {
|
|
165
|
+
// 五色调光:RGB+WW+CW,参数范围0-100(百分比)
|
|
166
|
+
const r = Math.max(0, Math.min(100, parseInt(payload.r || 0)));
|
|
167
|
+
const g = Math.max(0, Math.min(100, parseInt(payload.g || 0)));
|
|
168
|
+
const b = Math.max(0, Math.min(100, parseInt(payload.b || 0)));
|
|
169
|
+
const ww = Math.max(0, Math.min(100, parseInt(payload.ww || 0)));
|
|
170
|
+
const cw = Math.max(0, Math.min(100, parseInt(payload.cw || 0)));
|
|
171
|
+
command = { attrType: 0x11, param: Buffer.from([r, g, b, ww, cw]) };
|
|
172
|
+
|
|
173
|
+
} else if (typeof payload === 'boolean' || typeof payload === 'string') {
|
|
174
|
+
const value = typeof payload === 'boolean' ? payload :
|
|
175
|
+
(payload.toLowerCase() === 'on' || payload === '1');
|
|
176
|
+
command = { attrType: 0x02, param: Buffer.from([value ? 0x02 : 0x01]) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
} else if (entityType === 'cover') {
|
|
180
|
+
if (typeof payload === 'string') {
|
|
181
|
+
const actions = { 'open': 0x01, 'close': 0x02, 'stop': 0x03 };
|
|
182
|
+
const action = actions[payload.toLowerCase()];
|
|
183
|
+
if (action) {
|
|
184
|
+
command = { attrType: 0x05, param: Buffer.from([action]) };
|
|
185
|
+
}
|
|
186
|
+
} else if (typeof payload === 'number') {
|
|
187
|
+
const position = Math.max(0, Math.min(100, parseInt(payload)));
|
|
188
|
+
command = { attrType: 0x06, param: Buffer.from([position]) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
} else if (entityType === 'climate') {
|
|
192
|
+
if (msg.command === 'temperature') {
|
|
193
|
+
const temp = Math.max(16, Math.min(30, parseInt(payload)));
|
|
194
|
+
command = { attrType: 0x1B, param: Buffer.from([temp]) };
|
|
195
|
+
|
|
196
|
+
} else if (msg.command === 'mode') {
|
|
197
|
+
const modes = { 'cool': 1, 'heat': 2, 'fan_only': 3, 'dry': 4, 'off': 0 };
|
|
198
|
+
const mode = modes[payload] || 0;
|
|
199
|
+
if (mode > 0) {
|
|
200
|
+
command = { attrType: 0x1D, param: Buffer.from([mode]) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
} else if (msg.command === 'fan') {
|
|
204
|
+
const fans = { 'high': 1, 'medium': 2, 'low': 3, 'auto': 4 };
|
|
205
|
+
const fan = fans[payload] || 4;
|
|
206
|
+
command = { attrType: 0x1C, param: Buffer.from([fan]) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
} else if (entityType === 'scene') {
|
|
210
|
+
const sceneId = parseInt(payload);
|
|
211
|
+
if (sceneId >= 0 && sceneId <= 31) {
|
|
212
|
+
command = { sceneId: sceneId };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
} else if (entityType === 'three_in_one') {
|
|
216
|
+
// 三合一设备控制
|
|
217
|
+
const subType = msg.subType || this.threeInOneSubType;
|
|
218
|
+
|
|
219
|
+
if (subType === 'ac') {
|
|
220
|
+
// 空调控制
|
|
221
|
+
if (msg.command === 'temperature') {
|
|
222
|
+
const temp = Math.max(16, Math.min(30, parseInt(payload)));
|
|
223
|
+
command = { attrType: 0x94, param: Buffer.from([1, 0x1B, temp]) };
|
|
224
|
+
} else if (msg.command === 'mode') {
|
|
225
|
+
const modes = { 'cool': 1, 'heat': 2, 'fan_only': 3, 'dry': 4, 'off': 0 };
|
|
226
|
+
const mode = modes[payload] || 0;
|
|
227
|
+
if (mode > 0) {
|
|
228
|
+
command = { attrType: 0x94, param: Buffer.from([1, 0x1D, mode]) };
|
|
229
|
+
}
|
|
230
|
+
} else if (msg.command === 'fan') {
|
|
231
|
+
const fans = { 'high': 1, 'medium': 2, 'low': 3, 'auto': 4 };
|
|
232
|
+
const fan = fans[payload] || 4;
|
|
233
|
+
command = { attrType: 0x94, param: Buffer.from([1, 0x1C, fan]) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
} else if (subType === 'fresh_air') {
|
|
237
|
+
// 新风控制
|
|
238
|
+
if (msg.command === 'switch' || typeof payload === 'boolean') {
|
|
239
|
+
const switchValue = typeof payload === 'boolean' ? payload :
|
|
240
|
+
(payload.toLowerCase() === 'on' || payload === '1');
|
|
241
|
+
command = { attrType: 0x94, param: Buffer.from([2, 0x02, switchValue ? 0x02 : 0x01]) };
|
|
242
|
+
} else if (msg.command === 'speed') {
|
|
243
|
+
const speed = Math.max(0, Math.min(100, parseInt(payload)));
|
|
244
|
+
command = { attrType: 0x94, param: Buffer.from([2, 0x03, speed]) };
|
|
245
|
+
} else if (msg.command === 'mode') {
|
|
246
|
+
const modes = { 'low': 1, 'medium': 2, 'high': 3, 'auto': 4 };
|
|
247
|
+
const mode = modes[payload] || 4;
|
|
248
|
+
command = { attrType: 0x94, param: Buffer.from([2, 0x1C, mode]) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
} else if (subType === 'floor_heating') {
|
|
252
|
+
// 地暖控制
|
|
253
|
+
if (msg.command === 'temperature') {
|
|
254
|
+
const temp = Math.max(16, Math.min(35, parseInt(payload)));
|
|
255
|
+
command = { attrType: 0x94, param: Buffer.from([3, 0x1B, temp]) };
|
|
256
|
+
} else if (msg.command === 'mode') {
|
|
257
|
+
const modes = { 'heat': 2, 'off': 0 };
|
|
258
|
+
const mode = modes[payload] || 0;
|
|
259
|
+
if (mode > 0) {
|
|
260
|
+
command = { attrType: 0x94, param: Buffer.from([3, 0x1D, mode]) };
|
|
261
|
+
}
|
|
262
|
+
} else if (typeof payload === 'boolean' || typeof payload === 'string') {
|
|
263
|
+
const value = typeof payload === 'boolean' ? payload :
|
|
264
|
+
(payload.toLowerCase() === 'on' || payload === '1');
|
|
265
|
+
command = { attrType: 0x94, param: Buffer.from([3, 0x02, value ? 0x02 : 0x01]) };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return command;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
SymiDeviceNode.prototype.buildSwitchParam = function(channel, value, totalChannels) {
|
|
274
|
+
// 使用新的状态组合算法,返回参数格式:[channels, targetChannel, targetState]
|
|
275
|
+
// 这将触发protocol.js中的状态查询和组合逻辑
|
|
276
|
+
return Buffer.from([totalChannels, channel, value ? 1 : 0]);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
SymiDeviceNode.prototype.sendControl = async function(command) {
|
|
280
|
+
if (command.sceneId !== undefined) {
|
|
281
|
+
return await this.gateway.sendScene(command.sceneId);
|
|
282
|
+
} else {
|
|
283
|
+
return await this.gateway.sendControl(
|
|
284
|
+
this.device.networkAddress,
|
|
285
|
+
command.attrType,
|
|
286
|
+
command.param
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
SymiDeviceNode.prototype.formatOutput = function(eventData) {
|
|
292
|
+
const device = eventData.device;
|
|
293
|
+
const state = eventData.state;
|
|
294
|
+
const attrType = eventData.attrType;
|
|
295
|
+
|
|
296
|
+
if (this.outputFormat === 'simple') {
|
|
297
|
+
return {
|
|
298
|
+
topic: device.name,
|
|
299
|
+
payload: this.extractMainValue(state, attrType)
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
topic: device.name,
|
|
305
|
+
payload: state,
|
|
306
|
+
device: {
|
|
307
|
+
mac: device.macAddress,
|
|
308
|
+
name: device.name,
|
|
309
|
+
type: device.getEntityType(),
|
|
310
|
+
address: device.networkAddress
|
|
311
|
+
},
|
|
312
|
+
attrType: attrType
|
|
313
|
+
};
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
SymiDeviceNode.prototype.extractMainValue = function(state, attrType) {
|
|
317
|
+
switch (attrType) {
|
|
318
|
+
case 0x02: return state.switch !== undefined ? state.switch : state;
|
|
319
|
+
case 0x03: return state.brightness;
|
|
320
|
+
case 0x04: return state.colorTemp;
|
|
321
|
+
case 0x05: return state.curtainStatus;
|
|
322
|
+
case 0x06: return state.curtainPosition;
|
|
323
|
+
case 0x0C: return state.motion;
|
|
324
|
+
case 0x11: return state.rgb;
|
|
325
|
+
case 0x1B: return state.targetTemp;
|
|
326
|
+
case 0x1C: return state.fanMode;
|
|
327
|
+
case 0x1D: return state.climateMode;
|
|
328
|
+
case 0x94:
|
|
329
|
+
// 三合一设备,根据子类型返回状态
|
|
330
|
+
if (this.threeInOneSubType === 'ac') {
|
|
331
|
+
return state.ac || state;
|
|
332
|
+
} else if (this.threeInOneSubType === 'fresh_air') {
|
|
333
|
+
return state.fresh_air || state;
|
|
334
|
+
} else if (this.threeInOneSubType === 'floor_heating') {
|
|
335
|
+
return state.floor_heating || state;
|
|
336
|
+
}
|
|
337
|
+
return state;
|
|
338
|
+
default: return state;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
RED.nodes.registerType('symi-device', SymiDeviceNode);
|
|
343
|
+
};
|
|
344
|
+
|