node-red-contrib-ycznwl-knx-gateway 1.0.0
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 +40 -0
- package/knx-gateway-config.html +29 -0
- package/knx-gateway-config.js +8 -0
- package/knx-gateway-device.html +135 -0
- package/knx-gateway-device.js +138 -0
- package/knx-gateway-scene.html +84 -0
- package/knx-gateway-scene.js +124 -0
- package/knx-gateway-sensor.html +106 -0
- package/knx-gateway-sensor.js +183 -0
- package/node-red-contrib-ycznwl-knx-gateway-1.0.0.tgz +0 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# node-red-contrib-knx-gateway
|
|
2
|
+
|
|
3
|
+
Node-RED 节点包,对接 YCZNWL KNX 智能家居网关。
|
|
4
|
+
|
|
5
|
+
## 包含节点
|
|
6
|
+
|
|
7
|
+
| 节点 | 功能 |
|
|
8
|
+
|------|------|
|
|
9
|
+
| **knx-gateway-scene** | 场景执行 — 按名称或 UUID 触发场景 |
|
|
10
|
+
| **knx-gateway-device** | 设备控制 — 灯光、窗帘、空调、地暖、开关、门锁、风扇、净化器、播放器 |
|
|
11
|
+
| **knx-gateway-sensor** | 传感器轮询 — 温度/湿度/照度/CO₂/PM2.5/人体存在/移动/烟雾/漏水/门磁 |
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# 进入 Node-RED 目录
|
|
17
|
+
cd ~/.node-red
|
|
18
|
+
npm install /path/to/node-red-contrib-knx-gateway
|
|
19
|
+
node-red-restart
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 使用示例
|
|
23
|
+
|
|
24
|
+
### 场景执行
|
|
25
|
+
注入节点 → knx-gateway-scene(选"明亮")→ debug
|
|
26
|
+
|
|
27
|
+
### 灯光控制
|
|
28
|
+
注入节点(payload: `{brightness:80}`)→ knx-gateway-device(选主灯,动作: set_brightness)
|
|
29
|
+
|
|
30
|
+
### 空调控制
|
|
31
|
+
注入节点 `msg.action="set_target_temperature" msg.params={target_temperature:25}` → knx-gateway-device
|
|
32
|
+
|
|
33
|
+
### 温度传感器
|
|
34
|
+
knx-gateway-sensor → function(阈值判断)→ knx-gateway-device(开关空调)
|
|
35
|
+
|
|
36
|
+
## 配置
|
|
37
|
+
|
|
38
|
+
先拖一个 **knx-gateway-config** 节点,填入:
|
|
39
|
+
- API 地址: `http://网关IP/api/v1`
|
|
40
|
+
- Token: 从网关网页 → 头像 → Get API Token 获取
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script type="text/x-red" data-template-name="ycznwl-knx-gateway-config">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-config-input-baseUrl"><i class="fa fa-globe"></i> API 地址</label>
|
|
4
|
+
<input type="text" id="node-config-input-baseUrl" placeholder="http://192.168.2.39/api/v1">
|
|
5
|
+
</div>
|
|
6
|
+
<div class="form-row">
|
|
7
|
+
<label for="node-config-input-token"><i class="fa fa-key"></i> API Token</label>
|
|
8
|
+
<input type="password" id="node-config-input-token" placeholder="Bearer Token">
|
|
9
|
+
</div>
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<script type="text/x-red" data-help-name="ycznwl-knx-gateway-config">
|
|
13
|
+
<h3>KNX Gateway 配置</h3>
|
|
14
|
+
<p>填入网关 API 地址和 Token,其他节点共享此配置。</p>
|
|
15
|
+
<p><b>获取 Token:</b> 打开网关网页 → 右上角头像 → Get API Token</p>
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script type="text/javascript">
|
|
19
|
+
RED.nodes.registerType('ycznwl-knx-gateway-config', {
|
|
20
|
+
category: 'config',
|
|
21
|
+
defaults: {
|
|
22
|
+
baseUrl: { value: 'http://192.168.2.39/api/v1', required: true },
|
|
23
|
+
token: { value: '', required: true }
|
|
24
|
+
},
|
|
25
|
+
label: function() {
|
|
26
|
+
return this.baseUrl.replace('http://', '');
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
</script>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script type="text/x-red" data-template-name="ycznwl-knx-gateway-device">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-input-gateway"><i class="fa fa-server"></i> 网关</label>
|
|
4
|
+
<input type="text" id="node-input-gateway">
|
|
5
|
+
</div>
|
|
6
|
+
<div class="form-row">
|
|
7
|
+
<label for="node-input-device"><i class="fa fa-microchip"></i> 设备</label>
|
|
8
|
+
<select id="node-input-device" style="width:100%">
|
|
9
|
+
<option value="">—— 动态输入(msg.payload) ——</option>
|
|
10
|
+
</select>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="form-row">
|
|
13
|
+
<label for="node-input-action"><i class="fa fa-bolt"></i> 动作</label>
|
|
14
|
+
<select id="node-input-action" style="width:100%">
|
|
15
|
+
<option value="">—— 动态输入(msg.action) ——</option>
|
|
16
|
+
<option value="turn_on">开</option>
|
|
17
|
+
<option value="turn_off">关</option>
|
|
18
|
+
<option value="toggle">切换</option>
|
|
19
|
+
<option value="set_brightness">设置亮度</option>
|
|
20
|
+
<option value="set_color_temperature">设置色温</option>
|
|
21
|
+
<option value="set_color">设置颜色</option>
|
|
22
|
+
<option value="open">打开</option>
|
|
23
|
+
<option value="close">关闭</option>
|
|
24
|
+
<option value="stop">停止</option>
|
|
25
|
+
<option value="set_position">设置位置</option>
|
|
26
|
+
<option value="set_slat_position">设置百叶角度</option>
|
|
27
|
+
<option value="set_target_temperature">设置目标温度</option>
|
|
28
|
+
<option value="set_mode">设置模式</option>
|
|
29
|
+
<option value="set_fan_speed">设置风速%</option>
|
|
30
|
+
<option value="set_fan_speed_mode">设置风速档位</option>
|
|
31
|
+
<option value="set_swing_mode">设置摆风</option>
|
|
32
|
+
<option value="lock">上锁</option>
|
|
33
|
+
<option value="unlock">解锁</option>
|
|
34
|
+
<option value="activate_scene">激活场景</option>
|
|
35
|
+
<option value="play">播放</option>
|
|
36
|
+
<option value="pause">暂停</option>
|
|
37
|
+
<option value="play_pause">播放/暂停</option>
|
|
38
|
+
<option value="next_track">下一首</option>
|
|
39
|
+
<option value="set_volume">设置音量</option>
|
|
40
|
+
<option value="mute">静音</option>
|
|
41
|
+
<option value="unmute">取消静音</option>
|
|
42
|
+
<option value="toggle_mute">切换静音</option>
|
|
43
|
+
</select>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="form-row" id="row-params">
|
|
46
|
+
<label for="node-input-params"><i class="fa fa-cog"></i> 参数 (JSON)</label>
|
|
47
|
+
<input type="text" id="node-input-params" placeholder='{"brightness":80} 或空'>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="form-row" style="margin-top:8px">
|
|
50
|
+
<label> </label>
|
|
51
|
+
<button id="node-input-refresh-devices" class="red-ui-button" style="width:100%">
|
|
52
|
+
<i class="fa fa-refresh"></i> 刷新设备列表
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
<hr>
|
|
56
|
+
<div class="form-row">
|
|
57
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> 名称</label>
|
|
58
|
+
<input type="text" id="node-input-name">
|
|
59
|
+
</div>
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<script type="text/x-red" data-help-name="ycznwl-knx-gateway-device">
|
|
63
|
+
<h3>设备控制</h3>
|
|
64
|
+
<p>通用设备控制节点,支持灯光、窗帘、空调、地暖、开关、门锁、风扇、净化器、播放器等。</p>
|
|
65
|
+
<h4>输入 (msg) — 优先级高于面板配置</h4>
|
|
66
|
+
<ul>
|
|
67
|
+
<li><code>msg.device_uuid</code> — 设备 UUID</li>
|
|
68
|
+
<li><code>msg.device_id</code> — 设备数字 ID(备选)</li>
|
|
69
|
+
<li><code>msg.action</code> — 动作名</li>
|
|
70
|
+
<li><code>msg.params</code> — 参数对象,如 <code>{ brightness: 80 }</code></li>
|
|
71
|
+
<li><code>msg.payload</code> — 快捷写法,直接传参对象(需面板设好设备和动作)</li>
|
|
72
|
+
</ul>
|
|
73
|
+
<h4>常用动作示例</h4>
|
|
74
|
+
<ul>
|
|
75
|
+
<li>灯光: <code>turn_on</code> / <code>set_brightness:80</code> / <code>set_color_temperature:4000</code></li>
|
|
76
|
+
<li>窗帘: <code>open</code> / <code>close</code> / <code>set_position:50</code></li>
|
|
77
|
+
<li>空调: <code>turn_on</code> / <code>set_mode:cool</code> / <code>set_target_temperature:25</code> / <code>set_fan_speed_mode:auto</code></li>
|
|
78
|
+
<li>地暖: <code>turn_on</code> / <code>set_target_temperature:22</code></li>
|
|
79
|
+
<li>门锁: <code>lock</code> / <code>unlock</code></li>
|
|
80
|
+
</ul>
|
|
81
|
+
<h4>输出 (msg)</h4>
|
|
82
|
+
<ul>
|
|
83
|
+
<li><code>msg.payload</code> — 控制结果</li>
|
|
84
|
+
</ul>
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<script type="text/javascript">
|
|
88
|
+
RED.nodes.registerType('ycznwl-knx-gateway-device', {
|
|
89
|
+
category: 'knx',
|
|
90
|
+
color: '#10B981',
|
|
91
|
+
defaults: {
|
|
92
|
+
gateway: { type: 'ycznwl-knx-gateway-config', required: true },
|
|
93
|
+
device: { value: '' },
|
|
94
|
+
action: { value: '' },
|
|
95
|
+
params: { value: '' },
|
|
96
|
+
name: { value: '' }
|
|
97
|
+
},
|
|
98
|
+
inputs: 1,
|
|
99
|
+
outputs: 1,
|
|
100
|
+
icon: 'cog',
|
|
101
|
+
label: function() {
|
|
102
|
+
return this.name || '设备控制';
|
|
103
|
+
},
|
|
104
|
+
oneditprepare: function() {
|
|
105
|
+
var devSelect = $('#node-input-device');
|
|
106
|
+
var gwField = $('#node-input-gateway');
|
|
107
|
+
|
|
108
|
+
function fetchDevices() {
|
|
109
|
+
var gw = RED.nodes.node(gwField.val());
|
|
110
|
+
if (!gw) {
|
|
111
|
+
devSelect.empty().append('<option value="">—— 请先选择网关 ——</option>');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
devSelect.empty().append('<option value="">—— 动态输入(msg) ——</option>');
|
|
115
|
+
$.getJSON(gw.baseUrl + '/devices', function(data) {
|
|
116
|
+
(data.data || []).forEach(function(d) {
|
|
117
|
+
if (d.type === 'scene' && d.protocol === 'scene') return; // skip virtual scene devices
|
|
118
|
+
var label = (d.room_name ? d.room_name + '·' : '') + d.name + ' [' + d.type + '/' + d.sub_type + ']';
|
|
119
|
+
devSelect.append($('<option>', { value: d.uuid, text: label }));
|
|
120
|
+
});
|
|
121
|
+
}).fail(function() {
|
|
122
|
+
devSelect.empty().append('<option value="">—— 加载失败 ——</option>');
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
$('#node-input-refresh-devices').click(function(e) {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
fetchDevices();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
gwField.change(fetchDevices);
|
|
132
|
+
fetchDevices();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
</script>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
|
|
3
|
+
module.exports = function(RED) {
|
|
4
|
+
function KnxGatewayDevice(config) {
|
|
5
|
+
RED.nodes.createNode(this, config);
|
|
6
|
+
const node = this;
|
|
7
|
+
const gw = RED.nodes.getNode(config.gateway);
|
|
8
|
+
|
|
9
|
+
if (!gw) { node.error('未配置网关'); return; }
|
|
10
|
+
|
|
11
|
+
node.on('input', function(msg) {
|
|
12
|
+
// resolve device UUID
|
|
13
|
+
const deviceUuid = msg.device_uuid || msg.device_id || config.device;
|
|
14
|
+
if (!deviceUuid || deviceUuid === '') {
|
|
15
|
+
node.error('未指定设备');
|
|
16
|
+
node.status({ fill: 'red', shape: 'dot', text: '缺少设备' });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// resolve action
|
|
21
|
+
const action = msg.action || config.action;
|
|
22
|
+
if (!action || action === '') {
|
|
23
|
+
node.error('未指定动作');
|
|
24
|
+
node.status({ fill: 'red', shape: 'dot', text: '缺少动作' });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// resolve params: msg.params > msg.payload > config.params
|
|
29
|
+
let params = {};
|
|
30
|
+
let raw = msg.params || config.params;
|
|
31
|
+
if (typeof raw === 'string' && raw.trim() !== '') {
|
|
32
|
+
try { params = JSON.parse(raw); } catch(e) {
|
|
33
|
+
// try key:value shorthand like "brightness:80"
|
|
34
|
+
const m = raw.match(/^(\w+):(.+)$/);
|
|
35
|
+
if (m) {
|
|
36
|
+
const val = parseFloat(m[2]);
|
|
37
|
+
params = { [m[1]]: isNaN(val) ? m[2] : val };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} else if (typeof raw === 'object' && raw !== null) {
|
|
41
|
+
params = raw;
|
|
42
|
+
} else if (typeof msg.payload === 'object' && msg.payload !== null
|
|
43
|
+
&& !msg.payload.device_uuid && !msg.payload.action) {
|
|
44
|
+
// msg.payload used as params shortcut (only when it's a plain param object)
|
|
45
|
+
params = msg.payload;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
node.status({ fill: 'yellow', shape: 'dot', text: action + '...' });
|
|
49
|
+
|
|
50
|
+
// find device numeric ID by UUID
|
|
51
|
+
findDeviceId(gw, deviceUuid, function(err, deviceId) {
|
|
52
|
+
if (err) {
|
|
53
|
+
node.error(err.message);
|
|
54
|
+
node.status({ fill: 'red', shape: 'dot', text: '设备未找到' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const body = JSON.stringify({ action: action, params: params });
|
|
59
|
+
const url = new URL(gw.baseUrl + '/devices/' + deviceId + '/control');
|
|
60
|
+
|
|
61
|
+
const opts = {
|
|
62
|
+
hostname: url.hostname,
|
|
63
|
+
port: url.port || 80,
|
|
64
|
+
path: url.pathname,
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: {
|
|
67
|
+
'Authorization': 'Bearer ' + gw.token,
|
|
68
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
69
|
+
'Content-Length': Buffer.byteLength(body)
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const req = http.request(opts, function(res) {
|
|
74
|
+
let buf = '';
|
|
75
|
+
res.on('data', function(c) { buf += c; });
|
|
76
|
+
res.on('end', function() {
|
|
77
|
+
try {
|
|
78
|
+
const result = JSON.parse(buf);
|
|
79
|
+
if (result.code === 0) {
|
|
80
|
+
msg.payload = result.data || result;
|
|
81
|
+
node.status({ fill: 'green', shape: 'dot', text: 'OK' });
|
|
82
|
+
setTimeout(function() { node.status({}); }, 3000);
|
|
83
|
+
node.send(msg);
|
|
84
|
+
} else {
|
|
85
|
+
node.error('控制失败: ' + result.message);
|
|
86
|
+
node.status({ fill: 'red', shape: 'dot', text: result.message });
|
|
87
|
+
}
|
|
88
|
+
} catch(e) {
|
|
89
|
+
node.error('解析响应失败: ' + buf);
|
|
90
|
+
node.status({ fill: 'red', shape: 'dot', text: '解析失败' });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
req.on('error', function(e) {
|
|
96
|
+
node.error('请求失败: ' + e.message);
|
|
97
|
+
node.status({ fill: 'red', shape: 'dot', text: '网络错误' });
|
|
98
|
+
});
|
|
99
|
+
req.write(body);
|
|
100
|
+
req.end();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function findDeviceId(gw, uuid, cb) {
|
|
105
|
+
// cache device list for 60s
|
|
106
|
+
if (node._deviceCache && (Date.now() - node._cacheTime < 60000)) {
|
|
107
|
+
const d = node._deviceCache.find(function(d) { return d.uuid === uuid; });
|
|
108
|
+
if (d) return cb(null, d.id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const url = new URL(gw.baseUrl + '/devices');
|
|
112
|
+
http.get({
|
|
113
|
+
hostname: url.hostname,
|
|
114
|
+
port: url.port || 80,
|
|
115
|
+
path: url.pathname,
|
|
116
|
+
headers: { 'Authorization': 'Bearer ' + gw.token }
|
|
117
|
+
}, function(res) {
|
|
118
|
+
let body = '';
|
|
119
|
+
res.on('data', function(c) { body += c; });
|
|
120
|
+
res.on('end', function() {
|
|
121
|
+
try {
|
|
122
|
+
const data = JSON.parse(body);
|
|
123
|
+
node._deviceCache = data.data || [];
|
|
124
|
+
node._cacheTime = Date.now();
|
|
125
|
+
const d = node._deviceCache.find(function(d) { return d.uuid === uuid; });
|
|
126
|
+
if (d) cb(null, d.id);
|
|
127
|
+
else cb(new Error('未找到设备: ' + uuid));
|
|
128
|
+
} catch(e) {
|
|
129
|
+
cb(new Error('获取设备列表失败'));
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}).on('error', function(e) {
|
|
133
|
+
cb(new Error('网络请求失败: ' + e.message));
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
RED.nodes.registerType('ycznwl-knx-gateway-device', KnxGatewayDevice);
|
|
138
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script type="text/x-red" data-template-name="ycznwl-knx-gateway-scene">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-input-gateway"><i class="fa fa-server"></i> 网关</label>
|
|
4
|
+
<input type="text" id="node-input-gateway">
|
|
5
|
+
</div>
|
|
6
|
+
<div class="form-row">
|
|
7
|
+
<label for="node-input-scene"><i class="fa fa-film"></i> 场景</label>
|
|
8
|
+
<select id="node-input-scene" style="width:100%">
|
|
9
|
+
<option value="">—— 动态输入(msg.payload) ——</option>
|
|
10
|
+
</select>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="form-row" style="margin-top:8px">
|
|
13
|
+
<label> </label>
|
|
14
|
+
<button id="node-input-refresh" class="red-ui-button" style="width:100%">
|
|
15
|
+
<i class="fa fa-refresh"></i> 刷新场景列表
|
|
16
|
+
</button>
|
|
17
|
+
</div>
|
|
18
|
+
<hr>
|
|
19
|
+
<div class="form-row">
|
|
20
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> 名称</label>
|
|
21
|
+
<input type="text" id="node-input-name">
|
|
22
|
+
</div>
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<script type="text/x-red" data-help-name="ycznwl-knx-gateway-scene">
|
|
26
|
+
<h3>场景执行</h3>
|
|
27
|
+
<p>触发时调用网关 API 执行指定场景。</p>
|
|
28
|
+
<h4>输入 (msg)</h4>
|
|
29
|
+
<ul>
|
|
30
|
+
<li><code>msg.payload</code> — 场景名 或 UUID(当上方选择「动态输入」时)</li>
|
|
31
|
+
<li><code>msg.scene</code> — 场景名 或 UUID(优先级高于 payload)</li>
|
|
32
|
+
</ul>
|
|
33
|
+
<h4>输出 (msg)</h4>
|
|
34
|
+
<ul>
|
|
35
|
+
<li><code>msg.payload</code> — <code>{ success, actions_executed, actions_failed, duration_ms }</code></li>
|
|
36
|
+
</ul>
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<script type="text/javascript">
|
|
40
|
+
RED.nodes.registerType('ycznwl-knx-gateway-scene', {
|
|
41
|
+
category: 'knx',
|
|
42
|
+
color: '#3B82F6',
|
|
43
|
+
defaults: {
|
|
44
|
+
gateway: { type: 'ycznwl-knx-gateway-config', required: true },
|
|
45
|
+
scene: { value: '' },
|
|
46
|
+
name: { value: '' }
|
|
47
|
+
},
|
|
48
|
+
inputs: 1,
|
|
49
|
+
outputs: 1,
|
|
50
|
+
icon: 'film',
|
|
51
|
+
label: function() {
|
|
52
|
+
return this.name || (this.scene || '动态场景');
|
|
53
|
+
},
|
|
54
|
+
oneditprepare: function() {
|
|
55
|
+
var node = this;
|
|
56
|
+
var sceneSelect = $('#node-input-scene');
|
|
57
|
+
var gatewayField = $('#node-input-gateway');
|
|
58
|
+
|
|
59
|
+
function fetchScenes() {
|
|
60
|
+
var gw = RED.nodes.node(gatewayField.val());
|
|
61
|
+
if (!gw) {
|
|
62
|
+
sceneSelect.empty().append('<option value="">—— 请先选择网关 ——</option>');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
sceneSelect.empty().append('<option value="">—— 动态输入(msg.payload) ——</option>');
|
|
66
|
+
$.getJSON(gw.baseUrl + '/scenes', function(data) {
|
|
67
|
+
(data.data.scenes || []).forEach(function(s) {
|
|
68
|
+
sceneSelect.append($('<option>', { value: s.uuid, text: s.name }));
|
|
69
|
+
});
|
|
70
|
+
}).fail(function(err) {
|
|
71
|
+
sceneSelect.empty().append('<option value="">—— 加载失败 ——</option>');
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
$('#node-input-refresh').click(function(e) {
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
fetchScenes();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
gatewayField.change(fetchScenes);
|
|
81
|
+
fetchScenes();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
</script>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
|
|
3
|
+
module.exports = function(RED) {
|
|
4
|
+
function KnxGatewayScene(config) {
|
|
5
|
+
RED.nodes.createNode(this, config);
|
|
6
|
+
const node = this;
|
|
7
|
+
const gw = RED.nodes.getNode(config.gateway);
|
|
8
|
+
|
|
9
|
+
if (!gw) {
|
|
10
|
+
node.error('未配置网关');
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
node.on('input', function(msg) {
|
|
15
|
+
// scene_uuid: config first, then msg.scene, then msg.payload
|
|
16
|
+
let sceneId = config.scene;
|
|
17
|
+
if (!sceneId || sceneId === '') {
|
|
18
|
+
sceneId = msg.scene || msg.payload;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!sceneId || sceneId === '') {
|
|
22
|
+
node.error('未指定场景');
|
|
23
|
+
node.status({ fill: 'red', shape: 'dot', text: '缺少场景' });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// determine if it's a UUID or scene name
|
|
28
|
+
node.status({ fill: 'yellow', shape: 'dot', text: '执行中...' });
|
|
29
|
+
|
|
30
|
+
// if it looks like a name (not UUID), resolve it first
|
|
31
|
+
if (!/-/.test(sceneId)) {
|
|
32
|
+
resolveByName(gw, sceneId, function(err, uuid) {
|
|
33
|
+
if (err) return handleErr(err);
|
|
34
|
+
executeScene(gw, uuid);
|
|
35
|
+
});
|
|
36
|
+
} else {
|
|
37
|
+
executeScene(gw, sceneId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function executeScene(gw, uuid) {
|
|
41
|
+
const url = new URL(gw.baseUrl + '/scenes/' + encodeURIComponent(uuid) + '/execute');
|
|
42
|
+
const opts = {
|
|
43
|
+
hostname: url.hostname,
|
|
44
|
+
port: url.port || 80,
|
|
45
|
+
path: url.pathname,
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: {
|
|
48
|
+
'Authorization': 'Bearer ' + gw.token,
|
|
49
|
+
'Content-Type': 'application/json; charset=utf-8'
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const req = http.request(opts, function(res) {
|
|
54
|
+
let body = '';
|
|
55
|
+
res.on('data', function(chunk) { body += chunk; });
|
|
56
|
+
res.on('end', function() {
|
|
57
|
+
try {
|
|
58
|
+
const result = JSON.parse(body);
|
|
59
|
+
if (result.code === 0 && result.data) {
|
|
60
|
+
msg.payload = result.data;
|
|
61
|
+
node.status({
|
|
62
|
+
fill: result.data.success ? 'green' : 'yellow',
|
|
63
|
+
shape: 'dot',
|
|
64
|
+
text: result.data.actions_executed + '/' +
|
|
65
|
+
(result.data.actions_executed + result.data.actions_failed)
|
|
66
|
+
});
|
|
67
|
+
setTimeout(function() { node.status({}); }, 5000);
|
|
68
|
+
node.send(msg);
|
|
69
|
+
} else {
|
|
70
|
+
node.error('场景执行失败: ' + (result.message || body));
|
|
71
|
+
node.status({ fill: 'red', shape: 'dot', text: '失败' });
|
|
72
|
+
}
|
|
73
|
+
} catch(e) {
|
|
74
|
+
node.error('解析响应失败: ' + body);
|
|
75
|
+
node.status({ fill: 'red', shape: 'dot', text: '解析失败' });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
req.on('error', function(e) {
|
|
81
|
+
node.error('请求失败: ' + e.message);
|
|
82
|
+
node.status({ fill: 'red', shape: 'dot', text: '网络错误' });
|
|
83
|
+
});
|
|
84
|
+
req.end();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveByName(gw, name, cb) {
|
|
88
|
+
const url = new URL(gw.baseUrl + '/scenes');
|
|
89
|
+
const opts = {
|
|
90
|
+
hostname: url.hostname,
|
|
91
|
+
port: url.port || 80,
|
|
92
|
+
path: url.pathname,
|
|
93
|
+
headers: { 'Authorization': 'Bearer ' + gw.token }
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
http.get(opts, function(res) {
|
|
97
|
+
let body = '';
|
|
98
|
+
res.on('data', function(chunk) { body += chunk; });
|
|
99
|
+
res.on('end', function() {
|
|
100
|
+
try {
|
|
101
|
+
const data = JSON.parse(body);
|
|
102
|
+
const scenes = (data.data && data.data.scenes) || [];
|
|
103
|
+
const match = scenes.find(function(s) {
|
|
104
|
+
return s.name === name || s.uuid === name;
|
|
105
|
+
});
|
|
106
|
+
if (match) cb(null, match.uuid);
|
|
107
|
+
else cb(new Error('未找到场景: ' + name));
|
|
108
|
+
} catch(e) {
|
|
109
|
+
cb(new Error('解析场景列表失败'));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}).on('error', function(e) {
|
|
113
|
+
cb(new Error('获取场景列表失败: ' + e.message));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function handleErr(err) {
|
|
118
|
+
node.error(err.message);
|
|
119
|
+
node.status({ fill: 'red', shape: 'dot', text: err.message });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
RED.nodes.registerType('ycznwl-knx-gateway-scene', KnxGatewayScene);
|
|
124
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script type="text/x-red" data-template-name="ycznwl-knx-gateway-sensor">
|
|
2
|
+
<div class="form-row">
|
|
3
|
+
<label for="node-input-gateway"><i class="fa fa-server"></i> 网关</label>
|
|
4
|
+
<input type="text" id="node-input-gateway">
|
|
5
|
+
</div>
|
|
6
|
+
<div class="form-row">
|
|
7
|
+
<label for="node-input-device"><i class="fa fa-thermometer-half"></i> 传感器</label>
|
|
8
|
+
<select id="node-input-device" style="width:100%">
|
|
9
|
+
<option value="">—— 请先选择网关 ——</option>
|
|
10
|
+
</select>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="form-row">
|
|
13
|
+
<label for="node-input-interval"><i class="fa fa-clock-o"></i> 轮询间隔 (秒)</label>
|
|
14
|
+
<input type="number" id="node-input-interval" value="30" min="1" max="3600" style="width:100%">
|
|
15
|
+
</div>
|
|
16
|
+
<div class="form-row">
|
|
17
|
+
<label for="node-input-threshold"><i class="fa fa-filter"></i> 变化阈值</label>
|
|
18
|
+
<input type="text" id="node-input-threshold" placeholder="仅在变化时输出,空=每次都输出">
|
|
19
|
+
</div>
|
|
20
|
+
<div class="form-row" style="margin-top:8px">
|
|
21
|
+
<label> </label>
|
|
22
|
+
<button id="node-input-refresh-sensors" class="red-ui-button" style="width:100%">
|
|
23
|
+
<i class="fa fa-refresh"></i> 刷新传感器列表
|
|
24
|
+
</button>
|
|
25
|
+
</div>
|
|
26
|
+
<hr>
|
|
27
|
+
<div class="form-row">
|
|
28
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> 名称</label>
|
|
29
|
+
<input type="text" id="node-input-name">
|
|
30
|
+
</div>
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<script type="text/x-red" data-help-name="ycznwl-knx-gateway-sensor">
|
|
34
|
+
<h3>传感器轮询</h3>
|
|
35
|
+
<p>定时轮询传感器数据,变化时输出。支持温度、湿度、照度、CO₂、PM2.5、PM10、TVOC、空气质量、人体存在、移动、烟雾、漏水、门磁、电量等。</p>
|
|
36
|
+
<h4>输入 (msg) — 触发立即查询</h4>
|
|
37
|
+
<ul>
|
|
38
|
+
<li><code>msg.payload</code> — 任意值,触发一次立即查询</li>
|
|
39
|
+
</ul>
|
|
40
|
+
<h4>输出 (msg)</h4>
|
|
41
|
+
<ul>
|
|
42
|
+
<li><code>msg.payload</code> — 传感器状态对象</li>
|
|
43
|
+
<li><code>msg.topic</code> — 设备名</li>
|
|
44
|
+
<li><code>msg.device_uuid</code> — 设备 UUID</li>
|
|
45
|
+
<li><code>msg.online</code> — 在线状态</li>
|
|
46
|
+
<li>各传感器值作为顶层属性,如 <code>msg.temperature</code>, <code>msg.humidity</code>, <code>msg.motion</code> 等</li>
|
|
47
|
+
</ul>
|
|
48
|
+
<h4>变化阈值</h4>
|
|
49
|
+
<p>格式: <code>temperature:0.5,humidity:1</code> — 温度变化超0.5°C或湿度变化超1%才输出。</p>
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<script type="text/javascript">
|
|
53
|
+
RED.nodes.registerType('ycznwl-knx-gateway-sensor', {
|
|
54
|
+
category: 'knx',
|
|
55
|
+
color: '#F59E0B',
|
|
56
|
+
defaults: {
|
|
57
|
+
gateway: { type: 'ycznwl-knx-gateway-config', required: true },
|
|
58
|
+
device: { value: '', required: true },
|
|
59
|
+
interval: { value: 30, required: true, validate: RED.validators.number() },
|
|
60
|
+
threshold: { value: '' },
|
|
61
|
+
name: { value: '' }
|
|
62
|
+
},
|
|
63
|
+
inputs: 1,
|
|
64
|
+
outputs: 1,
|
|
65
|
+
icon: 'thermometer-half',
|
|
66
|
+
label: function() {
|
|
67
|
+
return this.name || '传感器';
|
|
68
|
+
},
|
|
69
|
+
oneditprepare: function() {
|
|
70
|
+
var devSelect = $('#node-input-device');
|
|
71
|
+
var gwField = $('#node-input-gateway');
|
|
72
|
+
|
|
73
|
+
function fetchSensors() {
|
|
74
|
+
var gw = RED.nodes.node(gwField.val());
|
|
75
|
+
if (!gw) {
|
|
76
|
+
devSelect.empty().append('<option value="">—— 请先选择网关 ——</option>');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
devSelect.empty();
|
|
80
|
+
$.getJSON(gw.baseUrl + '/devices', function(data) {
|
|
81
|
+
var sensors = (data.data || []).filter(function(d) {
|
|
82
|
+
return d.type === 'sensor';
|
|
83
|
+
});
|
|
84
|
+
if (sensors.length === 0) {
|
|
85
|
+
devSelect.append('<option value="">—— 无传感器 ——</option>');
|
|
86
|
+
}
|
|
87
|
+
sensors.forEach(function(s) {
|
|
88
|
+
if (s.id === '') return;
|
|
89
|
+
var label = (s.room_name ? s.room_name + '·' : '') + s.name + ' [' + s.sub_type + ']';
|
|
90
|
+
devSelect.append($('<option>', { value: s.uuid, text: label }));
|
|
91
|
+
});
|
|
92
|
+
}).fail(function() {
|
|
93
|
+
devSelect.empty().append('<option value="">—— 加载失败 ——</option>');
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
$('#node-input-refresh-sensors').click(function(e) {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
fetchSensors();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
gwField.change(fetchSensors);
|
|
103
|
+
fetchSensors();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
</script>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
|
|
3
|
+
module.exports = function(RED) {
|
|
4
|
+
function KnxGatewaySensor(config) {
|
|
5
|
+
RED.nodes.createNode(this, config);
|
|
6
|
+
const node = this;
|
|
7
|
+
const gw = RED.nodes.getNode(config.gateway);
|
|
8
|
+
|
|
9
|
+
if (!gw) { node.error('未配置网关'); return; }
|
|
10
|
+
if (!config.device || config.device === '') {
|
|
11
|
+
node.error('未选择传感器');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const interval = Math.max(1, parseInt(config.interval) || 30) * 1000;
|
|
16
|
+
let lastStatus = null;
|
|
17
|
+
let timer = null;
|
|
18
|
+
|
|
19
|
+
// parse threshold config: "temperature:0.5,humidity:1"
|
|
20
|
+
const thresholds = {};
|
|
21
|
+
if (config.threshold && config.threshold.trim() !== '') {
|
|
22
|
+
config.threshold.split(',').forEach(function(pair) {
|
|
23
|
+
const parts = pair.split(':');
|
|
24
|
+
if (parts.length === 2) {
|
|
25
|
+
thresholds[parts[0].trim()] = parseFloat(parts[1].trim());
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function poll() {
|
|
31
|
+
// determine numeric device ID from UUID
|
|
32
|
+
fetchDeviceId(gw, config.device, function(err, deviceId) {
|
|
33
|
+
if (err) {
|
|
34
|
+
node.error(err.message);
|
|
35
|
+
node.status({ fill: 'red', shape: 'dot', text: '找不到设备' });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const url = new URL(gw.baseUrl + '/devices/' + deviceId);
|
|
40
|
+
http.get({
|
|
41
|
+
hostname: url.hostname,
|
|
42
|
+
port: url.port || 80,
|
|
43
|
+
path: url.pathname,
|
|
44
|
+
headers: { 'Authorization': 'Bearer ' + gw.token }
|
|
45
|
+
}, function(res) {
|
|
46
|
+
let body = '';
|
|
47
|
+
res.on('data', function(c) { body += c; });
|
|
48
|
+
res.on('end', function() {
|
|
49
|
+
try {
|
|
50
|
+
const data = JSON.parse(body);
|
|
51
|
+
if (data.code !== 0 || !data.data) {
|
|
52
|
+
node.error('查询失败: ' + (data.message || body));
|
|
53
|
+
node.status({ fill: 'red', shape: 'dot', text: '查询失败' });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const dev = data.data;
|
|
58
|
+
const status = dev.status || {};
|
|
59
|
+
const online = dev.is_online;
|
|
60
|
+
|
|
61
|
+
// build msg
|
|
62
|
+
const msg = {
|
|
63
|
+
payload: status,
|
|
64
|
+
topic: dev.name,
|
|
65
|
+
device_uuid: dev.uuid,
|
|
66
|
+
device_id: dev.id,
|
|
67
|
+
device_name: dev.name,
|
|
68
|
+
device_type: dev.type + '/' + dev.sub_type,
|
|
69
|
+
online: online
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// flatten sensor values to msg properties
|
|
73
|
+
Object.keys(status).forEach(function(key) {
|
|
74
|
+
msg[key] = status[key];
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// check thresholds
|
|
78
|
+
let changed = false;
|
|
79
|
+
if (lastStatus) {
|
|
80
|
+
Object.keys(thresholds).forEach(function(key) {
|
|
81
|
+
const prev = lastStatus[key];
|
|
82
|
+
const curr = status[key];
|
|
83
|
+
if (prev !== undefined && curr !== undefined
|
|
84
|
+
&& Math.abs(curr - prev) >= thresholds[key]) {
|
|
85
|
+
changed = true;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
// also output if online state changed
|
|
89
|
+
if (lastStatus._online !== online) changed = true;
|
|
90
|
+
} else {
|
|
91
|
+
// first poll, always output
|
|
92
|
+
changed = true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
lastStatus = Object.assign({}, status, { _online: online });
|
|
96
|
+
|
|
97
|
+
if (changed || Object.keys(thresholds).length === 0) {
|
|
98
|
+
node.status({
|
|
99
|
+
fill: online ? 'green' : 'grey',
|
|
100
|
+
shape: 'dot',
|
|
101
|
+
text: formatStatus(status, dev.sub_type)
|
|
102
|
+
});
|
|
103
|
+
node.send(msg);
|
|
104
|
+
}
|
|
105
|
+
} catch(e) {
|
|
106
|
+
node.error('解析失败: ' + e.message);
|
|
107
|
+
node.status({ fill: 'red', shape: 'dot', text: '解析失败' });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}).on('error', function(e) {
|
|
111
|
+
node.error('请求失败: ' + e.message);
|
|
112
|
+
node.status({ fill: 'red', shape: 'dot', text: '网络错误' });
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function formatStatus(s, subType) {
|
|
118
|
+
switch(subType) {
|
|
119
|
+
case 'sensor_temperature': return (s.temperature || '--') + '°C';
|
|
120
|
+
case 'sensor_humidity': return (s.humidity || '--') + '%';
|
|
121
|
+
case 'sensor_co2': return (s.co2 || '--') + 'ppm';
|
|
122
|
+
case 'sensor_illuminance': return (s.illuminance || '--') + 'lux';
|
|
123
|
+
case 'sensor_pm25': return 'PM2.5:' + (s.pm25 || '--');
|
|
124
|
+
case 'sensor_pm10': return 'PM10:' + (s.pm10 || '--');
|
|
125
|
+
case 'sensor_presence': return s.presence ? '有人' : '无人';
|
|
126
|
+
case 'sensor_motion': return s.motion ? '移动' : '静止';
|
|
127
|
+
case 'sensor_smoke': return s.smoke ? '⚠烟雾' : '正常';
|
|
128
|
+
case 'sensor_leak': return s.leak ? '⚠漏水' : '正常';
|
|
129
|
+
case 'sensor_contact': return s.contact ? '开' : '关';
|
|
130
|
+
case 'sensor_tvoc': return 'TVOC:' + (s.tvoc || '--');
|
|
131
|
+
case 'sensor_air_quality': return 'AQI:' + (s.air_quality || '--');
|
|
132
|
+
default: return s._online !== false ? '在线' : '离线';
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function fetchDeviceId(gw, uuid, cb) {
|
|
137
|
+
if (node._deviceCache && (Date.now() - node._cacheTime < 60000)) {
|
|
138
|
+
const d = node._deviceCache.find(function(d) { return d.uuid === uuid; });
|
|
139
|
+
if (d) return cb(null, d.id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const url = new URL(gw.baseUrl + '/devices');
|
|
143
|
+
http.get({
|
|
144
|
+
hostname: url.hostname,
|
|
145
|
+
port: url.port || 80,
|
|
146
|
+
path: url.pathname,
|
|
147
|
+
headers: { 'Authorization': 'Bearer ' + gw.token }
|
|
148
|
+
}, function(res) {
|
|
149
|
+
let body = '';
|
|
150
|
+
res.on('data', function(c) { body += c; });
|
|
151
|
+
res.on('end', function() {
|
|
152
|
+
try {
|
|
153
|
+
const data = JSON.parse(body);
|
|
154
|
+
node._deviceCache = data.data || [];
|
|
155
|
+
node._cacheTime = Date.now();
|
|
156
|
+
const d = node._deviceCache.find(function(d) { return d.uuid === uuid; });
|
|
157
|
+
if (d) cb(null, d.id);
|
|
158
|
+
else cb(new Error('未找到设备: ' + uuid));
|
|
159
|
+
} catch(e) {
|
|
160
|
+
cb(new Error('获取设备列表失败'));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}).on('error', function(e) {
|
|
164
|
+
cb(new Error('网络请求失败: ' + e.message));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// start polling
|
|
169
|
+
timer = setInterval(poll, interval);
|
|
170
|
+
// do initial poll immediately
|
|
171
|
+
setTimeout(poll, 500);
|
|
172
|
+
|
|
173
|
+
// allow msg input to trigger immediate poll
|
|
174
|
+
node.on('input', function(msg) {
|
|
175
|
+
poll();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
node.on('close', function() {
|
|
179
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
RED.nodes.registerType('ycznwl-knx-gateway-sensor', KnxGatewaySensor);
|
|
183
|
+
};
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-red-contrib-ycznwl-knx-gateway",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Node-RED nodes for YCZNWL KNX Gateway — scenes, lights, curtains, AC, thermostat, sensors",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Your Name",
|
|
8
|
+
"email": "your@email.com"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/yourname/node-red-contrib-ycznwl-knx-gateway"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/yourname/node-red-contrib-ycznwl-knx-gateway/issues"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/yourname/node-red-contrib-ycznwl-knx-gateway#readme",
|
|
18
|
+
"keywords": ["node-red", "knx", "smart-home", "gateway", "ycznwl", "home-automation"],
|
|
19
|
+
"node-red": {
|
|
20
|
+
"nodes": {
|
|
21
|
+
"ycznwl-knx-gateway-config": "knx-gateway-config.js",
|
|
22
|
+
"ycznwl-knx-gateway-scene": "knx-gateway-scene.js",
|
|
23
|
+
"ycznwl-knx-gateway-device": "knx-gateway-device.js",
|
|
24
|
+
"ycznwl-knx-gateway-sensor": "knx-gateway-sensor.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=14"
|
|
30
|
+
}
|
|
31
|
+
}
|