node-red-contrib-knx-ultimate 5.2.4 → 6.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/CHANGELOG.md +21 -8
- package/README.md +10 -3
- package/img/readmemain.png +0 -0
- package/nodes/commonFunctions.js +40 -3
- package/nodes/knxUltimate-config.js +379 -335
- package/nodes/knxUltimate.html +8 -9
- package/nodes/knxUltimate.js +5 -5
- package/nodes/knxUltimateAI.js +1 -1
- package/nodes/knxUltimateHueLight.html +3 -3
- package/nodes/knxUltimateHueLight.js +1 -1
- package/nodes/knxUltimateMatterBridge.html +321 -247
- package/nodes/knxUltimateMatterBridge.js +48 -1
- package/nodes/knxUltimateMatterControllerDevice.html +2119 -0
- package/nodes/knxUltimateMatterControllerDevice.js +1701 -0
- package/nodes/locales/de/knxUltimateMatterBridge.html +6 -2
- package/nodes/locales/de/knxUltimateMatterBridge.json +14 -1
- package/nodes/locales/de/knxUltimateMatterControllerDevice.html +27 -0
- package/nodes/locales/de/knxUltimateMatterControllerDevice.json +97 -0
- package/nodes/locales/de/matter-config.json +5 -1
- package/nodes/locales/de/matterbridge-config.json +1 -0
- package/nodes/locales/en/knxUltimateMatterBridge.html +6 -2
- package/nodes/locales/en/knxUltimateMatterBridge.json +14 -1
- package/nodes/locales/en/knxUltimateMatterControllerDevice.html +27 -0
- package/nodes/locales/en/knxUltimateMatterControllerDevice.json +97 -0
- package/nodes/locales/en/matter-config.json +6 -1
- package/nodes/locales/en/matterbridge-config.json +1 -0
- package/nodes/locales/es/knxUltimateMatterBridge.html +6 -2
- package/nodes/locales/es/knxUltimateMatterBridge.json +14 -1
- package/nodes/locales/es/knxUltimateMatterControllerDevice.html +27 -0
- package/nodes/locales/es/knxUltimateMatterControllerDevice.json +97 -0
- package/nodes/locales/es/matter-config.json +5 -1
- package/nodes/locales/es/matterbridge-config.json +1 -0
- package/nodes/locales/fr/knxUltimateMatterBridge.html +6 -2
- package/nodes/locales/fr/knxUltimateMatterBridge.json +14 -1
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +27 -0
- package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +89 -0
- package/nodes/locales/fr/matter-config.json +5 -1
- package/nodes/locales/fr/matterbridge-config.json +1 -0
- package/nodes/locales/it/knxUltimateMatterBridge.html +6 -2
- package/nodes/locales/it/knxUltimateMatterBridge.json +14 -1
- package/nodes/locales/it/knxUltimateMatterControllerDevice.html +27 -0
- package/nodes/locales/it/knxUltimateMatterControllerDevice.json +97 -0
- package/nodes/locales/it/matter-config.json +6 -1
- package/nodes/locales/it/matterbridge-config.json +1 -0
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +6 -2
- package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +14 -1
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +27 -0
- package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +89 -0
- package/nodes/locales/zh-CN/matter-config.json +5 -1
- package/nodes/locales/zh-CN/matterbridge-config.json +1 -0
- package/nodes/matter-config.html +58 -6
- package/nodes/matter-config.js +7 -2
- package/nodes/matterbridge-config.html +1 -0
- package/nodes/utils/lightEngines/canonicalLight.js +75 -0
- package/nodes/utils/lightEngines/hueLightEngine.js +51 -0
- package/nodes/utils/lightEngines/index.js +25 -0
- package/nodes/utils/lightEngines/knxLightCommand.js +79 -0
- package/nodes/utils/lightEngines/lightEngine.js +46 -0
- package/nodes/utils/lightEngines/matterHueShim.js +59 -0
- package/nodes/utils/lightEngines/matterLightEngine.js +117 -0
- package/nodes/utils/matterEngine.mjs +185 -14
- package/nodes/utils/matterKnxConverter.js +1 -1
- package/package.json +21 -3
- package/resources/KNXSendSnippets.js +53 -53
- package/resources/htmlUtils.js +6 -4
- package/nodes/knxUltimateMatterDevice.html +0 -632
- package/nodes/knxUltimateMatterDevice.js +0 -308
- package/nodes/locales/de/knxUltimateMatterDevice.html +0 -56
- package/nodes/locales/de/knxUltimateMatterDevice.json +0 -109
- package/nodes/locales/en/knxUltimateMatterDevice.html +0 -56
- package/nodes/locales/en/knxUltimateMatterDevice.json +0 -109
- package/nodes/locales/es/knxUltimateMatterDevice.html +0 -56
- package/nodes/locales/es/knxUltimateMatterDevice.json +0 -109
- package/nodes/locales/fr/knxUltimateMatterDevice.html +0 -56
- package/nodes/locales/fr/knxUltimateMatterDevice.json +0 -109
- package/nodes/locales/it/knxUltimateMatterDevice.html +0 -56
- package/nodes/locales/it/knxUltimateMatterDevice.json +0 -109
- package/nodes/locales/zh-CN/knxUltimateMatterDevice.html +0 -56
- package/nodes/locales/zh-CN/knxUltimateMatterDevice.json +0 -109
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"knxUltimateMatterControllerDevice": {
|
|
3
|
+
"title": "从 KNX 控制 Matter",
|
|
4
|
+
"node-input-name": "Name",
|
|
5
|
+
"node-input-nameLightSwitch": "Switch (bit)",
|
|
6
|
+
"node-input-GALightSwitch": "GA",
|
|
7
|
+
"node-input-dptLightSwitch": "dpt",
|
|
8
|
+
"node-input-nameLightState": "State (bit)",
|
|
9
|
+
"node-input-GALightState": "GA",
|
|
10
|
+
"node-input-dptLightState": "dpt",
|
|
11
|
+
"node-input-hueLight": "Matter 设备",
|
|
12
|
+
"paletteLabel": "从 KNX 控制 Matter",
|
|
13
|
+
"no_devices": "未找到 Matter 设备。请检查 Matter 控制器并点击刷新。",
|
|
14
|
+
"tabs": {
|
|
15
|
+
"switch": "开关",
|
|
16
|
+
"dim": "调光",
|
|
17
|
+
"tunable_white": "可调白光",
|
|
18
|
+
"rgb_hsv": "RGB/HSV",
|
|
19
|
+
"effects": "效果",
|
|
20
|
+
"behaviour": "行为"
|
|
21
|
+
},
|
|
22
|
+
"control": "控制",
|
|
23
|
+
"status": "状态",
|
|
24
|
+
"night_lighting": "夜间照明",
|
|
25
|
+
"no_night_lighting": "无夜间照明",
|
|
26
|
+
"get_current": "获取当前",
|
|
27
|
+
"get_again": "再次获取",
|
|
28
|
+
"wait": "请稍候...",
|
|
29
|
+
"day_night": "昼/夜",
|
|
30
|
+
"invert_day_night": "反转昼/夜值",
|
|
31
|
+
"override_night_mode": "强制日间模式",
|
|
32
|
+
"override_no": "否",
|
|
33
|
+
"override_set_day_fast_this": "快速关开切换到白天模式(仅此灯)",
|
|
34
|
+
"override_set_day_fast_all": "快速关开切换到白天模式(应用于所有灯)",
|
|
35
|
+
"node_pins": "节点输入/输出引脚",
|
|
36
|
+
"node_pins_hide": "隐藏",
|
|
37
|
+
"node_pins_show": "显示输入/输出引脚",
|
|
38
|
+
"read_status_startup": "启动时读取状态",
|
|
39
|
+
"opt_no": "否",
|
|
40
|
+
"opt_yes_emit": "是,并发送 KNX 电报。",
|
|
41
|
+
"knx_brightness_status": "KNX 亮度状态",
|
|
42
|
+
"knx_brightness_onhueoff": "当 HUE 灯关闭时发送 0%。当 HUE 打开时恢复先前值(默认 KNX 行为)",
|
|
43
|
+
"knx_brightness_no": "保持不变(默认 HUE 行为)",
|
|
44
|
+
"update_local_state_from_knx_write": "根据 KNX 总线写入更新本地缓存的 Hue 状态",
|
|
45
|
+
"update_local_state_from_knx_write_hint": "启用:本地响应更快,KNX 即时读回更一致。禁用:缓存只根据 Hue 网关的真实事件更新。",
|
|
46
|
+
"use_min_brightness": "使用 HUE 灯中设置的最小亮度",
|
|
47
|
+
"k_suffix": "K",
|
|
48
|
+
"temp_desc_2200": "(飞利浦 White Ambiance 系列起始)",
|
|
49
|
+
"temp_desc_2700": "(暖白,温馨、舒适、私密,适合客厅)",
|
|
50
|
+
"temp_desc_3000": "(柔和白,温暖、平静,适合浴室和厨房)",
|
|
51
|
+
"temp_desc_3500_day": "(中性白,均衡、友好、亲切,适合办公室和零售)",
|
|
52
|
+
"temp_desc_3500_night": "(夜间不推荐——中性白,适合办公室和零售)",
|
|
53
|
+
"temp_desc_4100_day": "(冷白,精准、干净、聚焦,适合车库和超市)",
|
|
54
|
+
"temp_desc_4100_night": "(夜间不推荐——冷白,精准、干净、聚焦,适合车库和超市)",
|
|
55
|
+
"temp_desc_5000_day": "(明亮白,鲜明、清晰,适合仓库、体育场和医疗)",
|
|
56
|
+
"temp_desc_5000_night": "(夜间不推荐——明亮白,鲜明、清晰,适合仓库、体育场和医疗)",
|
|
57
|
+
"temp_desc_6500_day": "(日光,警觉、充满活力,适合室内农业)",
|
|
58
|
+
"temp_desc_6500_night": "(夜间不推荐——日光,警觉、充满活力,适合室内农业)",
|
|
59
|
+
"switch_on_behaviour": "开灯行为",
|
|
60
|
+
"none": "无",
|
|
61
|
+
"switch_off": "(关)",
|
|
62
|
+
"select_color": "选择颜色",
|
|
63
|
+
"select_temperature_brightness": "选择色温和亮度",
|
|
64
|
+
"select_brightness": "选择亮度",
|
|
65
|
+
"effect_command": "效果命令",
|
|
66
|
+
"effect_status": "效果状态",
|
|
67
|
+
"effect_mapping": "映射",
|
|
68
|
+
"effect_autofill": "填充可用效果",
|
|
69
|
+
"effect_tip": "配置 KNX 数值与 Hue 效果对,收到匹配的 KNX 数值时触发对应效果。",
|
|
70
|
+
"effect_tip_status": "若配置状态组地址,将把当前效果(映射值或效果名称)发送到 KNX。",
|
|
71
|
+
"effect_not_supported": "该灯具不支持 Hue 效果。",
|
|
72
|
+
"effect_knx_value_placeholder": "匹配值",
|
|
73
|
+
"effect_base_label": "非 Hue 基础效果",
|
|
74
|
+
"effect_native_label": "Hue 原生效果",
|
|
75
|
+
"matter_controller": "Matter 控制器",
|
|
76
|
+
"matter_device": "Matter 设备",
|
|
77
|
+
"matter_section": "Matter 设备"
|
|
78
|
+
},
|
|
79
|
+
"common": {
|
|
80
|
+
"ga": "组地址",
|
|
81
|
+
"dpt": "数据点类型",
|
|
82
|
+
"name": "名称",
|
|
83
|
+
"youtube_sample": "YouTube 示例",
|
|
84
|
+
"knx_gw": "KNX 网关",
|
|
85
|
+
"hue_bridge": "Hue Bridge",
|
|
86
|
+
"philips_hue": "飞利浦 HUE",
|
|
87
|
+
"read": "读取"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"pairing_title": "配对新设备",
|
|
9
9
|
"pairing_help": "输入 11 位手动配对码(例如 3497-011-2332)或二维码内容(MT:....)。设备必须可通过 IP 网络访问(WiFi/以太网或通过边界路由器的 Thread)。如果设备已与其他控制器(Alexa、Google、Apple)配对,请使用其\"与其他中枢共享/配对\"功能获取新的配对码。",
|
|
10
10
|
"pairing_code": "配对码",
|
|
11
|
+
"pairing_name": "设备名称",
|
|
11
12
|
"pair_button": "配对",
|
|
12
13
|
"devices_title": "已配对设备",
|
|
13
14
|
"col_name": "名称",
|
|
@@ -20,7 +21,10 @@
|
|
|
20
21
|
"pairing_code_missing": "请输入配对码。",
|
|
21
22
|
"pairing_wait": "正在配对,最多可能需要一分钟...",
|
|
22
23
|
"pairing_failed": "配对失败",
|
|
23
|
-
"pairing_ok": "设备配对成功!"
|
|
24
|
+
"pairing_ok": "设备配对成功!",
|
|
25
|
+
"rename_button": "重命名",
|
|
26
|
+
"rename_prompt": "新的设备名称",
|
|
27
|
+
"rename_ok": "设备已重命名。"
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
30
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"pairing_title": "配对(Alexa、Google Home、Apple Home...)",
|
|
11
11
|
"not_running": "Matter 桥接器尚未运行。请先部署,等待几秒后点击刷新。",
|
|
12
12
|
"commissioned": "桥接器已与以下控制器配对:",
|
|
13
|
+
"commissioned_hint": "二维码已隐藏,因为桥接器已经配对。若要将 Alexa 添加为另一个 Matter 控制器,请先从上方已配对的控制器之一(例如 Apple Home 或 Google Home)打开配对模式,然后在 Alexa 中添加 Matter 设备。只有在要移除所有现有控制器并重新开始时,才使用重置配对。",
|
|
13
14
|
"awaiting": "等待配对。请在您的 Matter App 中扫描二维码或输入手动配对码:",
|
|
14
15
|
"qr_link": "在浏览器中打开二维码",
|
|
15
16
|
"deploy_first": "请先部署此配置节点,然后点击刷新。",
|
package/nodes/matter-config.html
CHANGED
|
@@ -16,10 +16,21 @@
|
|
|
16
16
|
var node = this;
|
|
17
17
|
var $pairButton = $("#matterPairButton");
|
|
18
18
|
var $pairingCode = $("#matter-pairing-code");
|
|
19
|
+
var $pairingName = $("#matter-pairing-name");
|
|
19
20
|
var $devicesBody = $("#matter-devices-body");
|
|
20
21
|
var $refreshDevices = $("#matterRefreshDevices");
|
|
21
22
|
var $pairSpinner = $("#matterPairSpinner");
|
|
22
23
|
|
|
24
|
+
function notifyPairingError(msg) {
|
|
25
|
+
var text = RED._("node-red-contrib-knx-ultimate/matter-config:matter-config.properties.pairing_failed") + ': ' + (msg || '');
|
|
26
|
+
try { console.error('[matter-config] ' + text); } catch (error) { }
|
|
27
|
+
RED.notify(text, {
|
|
28
|
+
type: 'error',
|
|
29
|
+
fixed: true,
|
|
30
|
+
modal: false
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
function renderDevices(devices) {
|
|
24
35
|
$devicesBody.empty();
|
|
25
36
|
if (!Array.isArray(devices) || devices.length === 0) {
|
|
@@ -33,6 +44,27 @@
|
|
|
33
44
|
$tr.append($('<td></td>').text((dev.vendorName || '') + (dev.productName ? ' ' + dev.productName : '')));
|
|
34
45
|
$tr.append($('<td style="font-family:monospace;"></td>').text(dev.nodeId));
|
|
35
46
|
$tr.append($('<td></td>').append($('<span></span>').css('color', stateColor).text(dev.connectionState)));
|
|
47
|
+
var $rename = $('<button type="button" class="red-ui-button" style="margin-right:6px; color:#1b7d33; border-color:#1b7d33;" title="' + (RED._("node-red-contrib-knx-ultimate/matter-config:matter-config.properties.rename_button") || 'Rename') + '"><i class="fa fa-pencil"></i></button>');
|
|
48
|
+
$rename.on('click', function () {
|
|
49
|
+
var currentName = dev.name || '';
|
|
50
|
+
var promptMsg = RED._("node-red-contrib-knx-ultimate/matter-config:matter-config.properties.rename_prompt") || 'New device name';
|
|
51
|
+
var newName = window.prompt(promptMsg, currentName);
|
|
52
|
+
if (newName === null) return;
|
|
53
|
+
newName = (newName || '').trim();
|
|
54
|
+
if (newName === '' || newName === currentName) return;
|
|
55
|
+
$rename.prop('disabled', true);
|
|
56
|
+
$.getJSON('KNXUltimateMatterRename?serverId=' + node.id + '&nodeId=' + encodeURIComponent(dev.nodeId) + '&name=' + encodeURIComponent(newName), function (data) {
|
|
57
|
+
if (data && data.error) {
|
|
58
|
+
RED.notify(data.error, { type: 'error', fixed: true });
|
|
59
|
+
} else {
|
|
60
|
+
RED.notify(RED._("node-red-contrib-knx-ultimate/matter-config:matter-config.properties.rename_ok") || 'Device renamed', { type: 'success' });
|
|
61
|
+
}
|
|
62
|
+
loadDevices();
|
|
63
|
+
}).fail(function (jqXHR, textStatus, errorThrown) {
|
|
64
|
+
RED.notify('Rename failed: ' + (errorThrown || textStatus), { type: 'error', fixed: true });
|
|
65
|
+
$rename.prop('disabled', false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
36
68
|
var $unpair = $('<button type="button" class="red-ui-button" style="color:#c00; border-color:#c00;"><i class="fa fa-trash"></i></button>');
|
|
37
69
|
$unpair.on('click', function () {
|
|
38
70
|
var confirmMsg = RED._("node-red-contrib-knx-ultimate/matter-config:matter-config.properties.unpair_confirm") + ' "' + (dev.name || dev.nodeId) + '"?';
|
|
@@ -50,7 +82,7 @@
|
|
|
50
82
|
$unpair.prop('disabled', false);
|
|
51
83
|
});
|
|
52
84
|
});
|
|
53
|
-
$tr.append($('<td style="text-align:right;"></td>').append($unpair));
|
|
85
|
+
$tr.append($('<td style="text-align:right;"></td>').append($rename).append($unpair));
|
|
54
86
|
$devicesBody.append($tr);
|
|
55
87
|
});
|
|
56
88
|
}
|
|
@@ -77,6 +109,7 @@
|
|
|
77
109
|
|
|
78
110
|
$pairButton.on('click', function () {
|
|
79
111
|
var code = ($pairingCode.val() || '').trim();
|
|
112
|
+
var desiredName = ($pairingName.val() || '').trim();
|
|
80
113
|
if (code === '') {
|
|
81
114
|
RED.notify(RED._("node-red-contrib-knx-ultimate/matter-config:matter-config.properties.pairing_code_missing"), { type: 'warning' });
|
|
82
115
|
return;
|
|
@@ -89,21 +122,24 @@
|
|
|
89
122
|
type: 'info'
|
|
90
123
|
});
|
|
91
124
|
$.ajax({
|
|
92
|
-
url: 'KNXUltimateMatterPair?serverId=' + node.id + '&code=' + encodeURIComponent(code),
|
|
125
|
+
url: 'KNXUltimateMatterPair?serverId=' + node.id + '&code=' + encodeURIComponent(code) + (desiredName !== '' ? '&name=' + encodeURIComponent(desiredName) : ''),
|
|
93
126
|
dataType: 'json',
|
|
94
127
|
timeout: 180000
|
|
95
128
|
}).done(function (data) {
|
|
96
129
|
if (data && data.error) {
|
|
97
|
-
|
|
130
|
+
notifyPairingError(data.error);
|
|
98
131
|
} else {
|
|
99
|
-
|
|
132
|
+
var pairedName = data.name || data.productName || data.nodeId;
|
|
133
|
+
RED.notify(RED._("node-red-contrib-knx-ultimate/matter-config:matter-config.properties.pairing_ok") + ' ' + pairedName + ' (Node ID ' + data.nodeId + ')', { type: 'success' });
|
|
134
|
+
if (data.renameError) RED.notify(data.renameError, { type: 'warning', fixed: true });
|
|
100
135
|
$pairingCode.val('');
|
|
136
|
+
$pairingName.val('');
|
|
101
137
|
loadDevices();
|
|
102
138
|
}
|
|
103
139
|
}).fail(function (jqXHR, textStatus, errorThrown) {
|
|
104
140
|
var msg = '';
|
|
105
141
|
try { msg = jqXHR.responseJSON.error; } catch (e) { msg = errorThrown || textStatus; }
|
|
106
|
-
|
|
142
|
+
notifyPairingError(msg);
|
|
107
143
|
}).always(function () {
|
|
108
144
|
if (myNotification) myNotification.close();
|
|
109
145
|
$pairButton.prop('disabled', false);
|
|
@@ -193,6 +229,22 @@
|
|
|
193
229
|
</span>
|
|
194
230
|
</div>
|
|
195
231
|
|
|
232
|
+
<div class="form-row">
|
|
233
|
+
<label for="matter-pairing-name">
|
|
234
|
+
<i class="fa fa-tag"></i>
|
|
235
|
+
<span data-i18n="matter-config.properties.pairing_name"></span>
|
|
236
|
+
</label>
|
|
237
|
+
<input type="text" id="matter-pairing-name" placeholder="Kitchen plug" style="width:250px;">
|
|
238
|
+
</div>
|
|
239
|
+
<div class="form-tips" style="margin: 4px 0 8px; font-size:12px;">
|
|
240
|
+
<i class="fa fa-circle-info" style="color:#1b7d33; margin-right:4px;"></i>
|
|
241
|
+
<span data-i18n="matter-config.properties.pairing_hint">Prefer the QR code (MT:…): it carries the full device discriminator. The manual code
|
|
242
|
+
stores only a short discriminator and cannot reliably tell identical devices apart
|
|
243
|
+
(e.g. several Shelly of the same model), which can pair the wrong one or report it as
|
|
244
|
+
already commissioned. Pair one device at a time; if a device is already paired
|
|
245
|
+
elsewhere, factory-reset it first.</span>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
196
248
|
<hr/>
|
|
197
249
|
|
|
198
250
|
<div class="form-row">
|
|
@@ -223,4 +275,4 @@
|
|
|
223
275
|
<br/>
|
|
224
276
|
<br/>
|
|
225
277
|
<br/>
|
|
226
|
-
</script>
|
|
278
|
+
</script>
|
package/nodes/matter-config.js
CHANGED
|
@@ -176,15 +176,20 @@ module.exports = (RED) => {
|
|
|
176
176
|
return node.matterManager.getNodeStructure(_nodeId)
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
node.commission = async (_pairingCode) => {
|
|
179
|
+
node.commission = async (_pairingCode, _options = {}) => {
|
|
180
180
|
if (node.matterManager === null) throw new Error('Matter controller not started')
|
|
181
|
-
return node.matterManager.commission(_pairingCode)
|
|
181
|
+
return node.matterManager.commission(_pairingCode, _options)
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
node.removeCommissionedNode = async (_nodeId) => {
|
|
185
185
|
if (node.matterManager === null) throw new Error('Matter controller not started')
|
|
186
186
|
return node.matterManager.removeNode(_nodeId)
|
|
187
187
|
}
|
|
188
|
+
|
|
189
|
+
node.renameCommissionedNode = async (_nodeId, _label) => {
|
|
190
|
+
if (node.matterManager === null) throw new Error('Matter controller not started')
|
|
191
|
+
return node.matterManager.renameNode(_nodeId, _label)
|
|
192
|
+
}
|
|
188
193
|
// END functions called from the nodes -------------------------------------------------------------
|
|
189
194
|
|
|
190
195
|
node.addClient = (_Node) => {
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
info.fabrics.forEach(function (f) {
|
|
38
38
|
$pairingBody.append($('<div style="margin-left:10px;"></div>').text('• ' + (f.label || 'Controller') + ' (vendor ' + f.vendorId + ')'));
|
|
39
39
|
});
|
|
40
|
+
$pairingBody.append($('<p style="max-width:760px; margin:14px auto 0; color:#777;"></p>').text(t('commissioned_hint')));
|
|
40
41
|
$resetButton.show();
|
|
41
42
|
return;
|
|
42
43
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Canonical, engine-agnostic light model.
|
|
3
|
+
//
|
|
4
|
+
// The Light node's behaviour logic (dimming ramps, day/night presets, blink,
|
|
5
|
+
// colour cycle, HSV...) speaks ONLY this vocabulary. Each concrete engine
|
|
6
|
+
// (Hue, Matter, future Shelly) translates a canonical patch into its native
|
|
7
|
+
// shape and back. Keeping the node blind to the ecosystem is what prevents the
|
|
8
|
+
// "if engine === 'hue'" spaghetti.
|
|
9
|
+
//
|
|
10
|
+
// A canonical patch is a plain object with any subset of:
|
|
11
|
+
// { on: boolean,
|
|
12
|
+
// brightnessPct: number 0..100,
|
|
13
|
+
// colorTempK: number (kelvin),
|
|
14
|
+
// rgb: { r, g, b } (0..255) }
|
|
15
|
+
|
|
16
|
+
const clamp = (v, min, max) => Math.min(max, Math.max(min, Number(v)))
|
|
17
|
+
|
|
18
|
+
const clampByte = (v, max = 254) => {
|
|
19
|
+
let n = Math.round(Number(v))
|
|
20
|
+
if (Number.isNaN(n)) n = 0
|
|
21
|
+
if (n < 0) n = 0
|
|
22
|
+
if (n > max) n = max
|
|
23
|
+
return n
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Kelvin <-> mired. Same physical unit; Hue calls it "mirek", Matter "mireds".
|
|
27
|
+
const kelvinToMired = (kelvin) => {
|
|
28
|
+
const k = Number(kelvin)
|
|
29
|
+
if (!k || Number.isNaN(k)) return 0
|
|
30
|
+
return Math.round(1000000 / k)
|
|
31
|
+
}
|
|
32
|
+
const miredToKelvin = (mired) => {
|
|
33
|
+
const m = Number(mired)
|
|
34
|
+
if (!m || Number.isNaN(m)) return 0
|
|
35
|
+
return Math.round(1000000 / m)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// sRGB (0..255) -> CIE 1931 xy, using the Wide RGB D65 conversion Philips Hue expects.
|
|
39
|
+
const rgbToXy = ({ r, g, b }) => {
|
|
40
|
+
const norm = (c) => {
|
|
41
|
+
const v = clamp(c, 0, 255) / 255
|
|
42
|
+
return v > 0.04045 ? Math.pow((v + 0.055) / 1.055, 2.4) : v / 12.92
|
|
43
|
+
}
|
|
44
|
+
const rn = norm(r)
|
|
45
|
+
const gn = norm(g)
|
|
46
|
+
const bn = norm(b)
|
|
47
|
+
const X = rn * 0.649926 + gn * 0.103455 + bn * 0.197109
|
|
48
|
+
const Y = rn * 0.234327 + gn * 0.743075 + bn * 0.022598
|
|
49
|
+
const Z = rn * 0.0000000 + gn * 0.053077 + bn * 1.035763
|
|
50
|
+
const sum = X + Y + Z
|
|
51
|
+
if (sum === 0) return [0, 0]
|
|
52
|
+
return [Number((X / sum).toFixed(4)), Number((Y / sum).toFixed(4))]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// RGB (0..255) -> HSV. hue in 0..360, sat/val in 0..1.
|
|
56
|
+
const rgbToHsv = ({ r, g, b }) => {
|
|
57
|
+
const rn = clamp(r, 0, 255) / 255
|
|
58
|
+
const gn = clamp(g, 0, 255) / 255
|
|
59
|
+
const bn = clamp(b, 0, 255) / 255
|
|
60
|
+
const max = Math.max(rn, gn, bn)
|
|
61
|
+
const min = Math.min(rn, gn, bn)
|
|
62
|
+
const d = max - min
|
|
63
|
+
let h = 0
|
|
64
|
+
if (d !== 0) {
|
|
65
|
+
if (max === rn) h = ((gn - bn) / d) % 6
|
|
66
|
+
else if (max === gn) h = (bn - rn) / d + 2
|
|
67
|
+
else h = (rn - gn) / d + 4
|
|
68
|
+
h *= 60
|
|
69
|
+
if (h < 0) h += 360
|
|
70
|
+
}
|
|
71
|
+
const s = max === 0 ? 0 : d / max
|
|
72
|
+
return { h, s, v: max }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { clamp, clampByte, kelvinToMired, miredToKelvin, rgbToXy, rgbToHsv }
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { LightEngine } = require('./lightEngine')
|
|
3
|
+
const { clampByte, kelvinToMired, miredToKelvin, rgbToXy } = require('./canonicalLight')
|
|
4
|
+
|
|
5
|
+
// Hue speaks a single COMPOSITE, atomic state object (Hue API v2 shape):
|
|
6
|
+
// { on:{on}, dimming:{brightness}, color:{xy}, color_temperature:{mirek} }
|
|
7
|
+
// so a canonical patch collapses into ONE writeHueQueueAdd call.
|
|
8
|
+
class HueLightEngine extends LightEngine {
|
|
9
|
+
constructor (opts = {}) {
|
|
10
|
+
super(opts)
|
|
11
|
+
this.manager = opts.manager // hue-config's hueManager (writeHueQueueAdd)
|
|
12
|
+
this.operation = opts.grouped ? 'setGroupedLight' : 'setLight'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get kind () { return 'hue' }
|
|
16
|
+
|
|
17
|
+
capabilities () {
|
|
18
|
+
return { onOff: true, dimming: true, colorTemp: true, color: true, effects: true }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
translate (patch) {
|
|
22
|
+
const state = {}
|
|
23
|
+
if (patch.on !== undefined) state.on = { on: !!patch.on }
|
|
24
|
+
if (patch.brightnessPct !== undefined) state.dimming = { brightness: clampByte(patch.brightnessPct, 100) }
|
|
25
|
+
if (patch.colorTempK !== undefined) state.color_temperature = { mirek: kelvinToMired(patch.colorTempK) }
|
|
26
|
+
if (patch.rgb !== undefined) state.color = { xy: { x: rgbToXy(patch.rgb)[0], y: rgbToXy(patch.rgb)[1] } }
|
|
27
|
+
return state
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
applyState (patch) {
|
|
31
|
+
const state = this.translate(patch)
|
|
32
|
+
if (Object.keys(state).length === 0) return []
|
|
33
|
+
if (this.manager && typeof this.manager.writeHueQueueAdd === 'function') {
|
|
34
|
+
this.manager.writeHueQueueAdd(this.deviceId, state, this.operation)
|
|
35
|
+
}
|
|
36
|
+
return [{ api: 'writeHueQueueAdd', deviceId: this.deviceId, state, operation: this.operation }]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Hue resource object (as delivered to handleSendHUE) -> canonical patch.
|
|
40
|
+
parseIncoming (event) {
|
|
41
|
+
if (!event) return null
|
|
42
|
+
if (event.id !== undefined && event.id !== this.deviceId) return null
|
|
43
|
+
const patch = {}
|
|
44
|
+
if (event.on && event.on.on !== undefined) patch.on = !!event.on.on
|
|
45
|
+
if (event.dimming && event.dimming.brightness !== undefined) patch.brightnessPct = Number(event.dimming.brightness)
|
|
46
|
+
if (event.color_temperature && event.color_temperature.mirek) patch.colorTempK = miredToKelvin(event.color_temperature.mirek)
|
|
47
|
+
return Object.keys(patch).length ? patch : null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { HueLightEngine }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Factory + registry for light engines. Adding a new ecosystem (e.g. Shelly)
|
|
3
|
+
// is a one-line registration here + one new *LightEngine file: the Light node
|
|
4
|
+
// and its behaviour logic stay untouched.
|
|
5
|
+
const { LightEngine } = require('./lightEngine')
|
|
6
|
+
const { HueLightEngine } = require('./hueLightEngine')
|
|
7
|
+
const { MatterLightEngine } = require('./matterLightEngine')
|
|
8
|
+
|
|
9
|
+
const ENGINES = {
|
|
10
|
+
hue: HueLightEngine,
|
|
11
|
+
matter: MatterLightEngine
|
|
12
|
+
// shelly: ShellyLightEngine <-- future: just add the class + this line
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createLightEngine (kind, opts = {}) {
|
|
16
|
+
const Engine = ENGINES[kind]
|
|
17
|
+
if (!Engine) {
|
|
18
|
+
throw new Error(`Unknown light engine "${kind}". Available: ${Object.keys(ENGINES).join(', ')}`)
|
|
19
|
+
}
|
|
20
|
+
return new Engine(opts)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const availableEngines = () => Object.keys(ENGINES)
|
|
24
|
+
|
|
25
|
+
module.exports = { createLightEngine, availableEngines, LightEngine, HueLightEngine, MatterLightEngine }
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { miredToKelvin } = require('./canonicalLight')
|
|
3
|
+
|
|
4
|
+
// Linear scale, mirrors hueColorConverter.ColorConverter.scale.
|
|
5
|
+
const scale = (v, [inMin, inMax], [outMin, outMax]) =>
|
|
6
|
+
outMin + ((Number(v) - inMin) * (outMax - outMin)) / (inMax - inMin)
|
|
7
|
+
|
|
8
|
+
const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v === 'on'
|
|
9
|
+
|
|
10
|
+
// Pure, engine-agnostic mapping of a DECODED KNX light group-address value into a
|
|
11
|
+
// canonical patch. `value` is the already dpt-decoded payload, so this stays free
|
|
12
|
+
// of knxultimate/dptlib and is trivially unit-testable.
|
|
13
|
+
//
|
|
14
|
+
// Returns:
|
|
15
|
+
// { patch } -> a canonical patch to push to the engine
|
|
16
|
+
// { unsupported: reason } -> a Hue-only behaviour with no Matter equivalent
|
|
17
|
+
// null -> destination not handled by the light command path
|
|
18
|
+
function canonicalizeKnxLightCommand ({ config, destination, value, dayTime = true }) {
|
|
19
|
+
switch (destination) {
|
|
20
|
+
case config.GALightSwitch: {
|
|
21
|
+
const on = truthy(value)
|
|
22
|
+
const patch = { on }
|
|
23
|
+
// Parity with the Hue switch-on preset (temperature mode): a Matter light
|
|
24
|
+
// turns on with the same day/night brightness + colour temperature.
|
|
25
|
+
if (on && config.specifySwitchOnBrightness === 'temperature') {
|
|
26
|
+
const preset = (dayTime === false && config.enableDayNightLighting === 'temperature')
|
|
27
|
+
? config.colorAtSwitchOnNightTime
|
|
28
|
+
: config.colorAtSwitchOnDayTime
|
|
29
|
+
if (preset && typeof preset === 'object') {
|
|
30
|
+
if (preset.brightness !== undefined) patch.brightnessPct = Number(preset.brightness)
|
|
31
|
+
if (preset.kelvin !== undefined) patch.colorTempK = Number(preset.kelvin)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { patch }
|
|
35
|
+
}
|
|
36
|
+
case config.GALightBrightness: {
|
|
37
|
+
const pct = Number(value)
|
|
38
|
+
if (Number.isNaN(pct)) throw new Error('Invalid KNX brightness payload')
|
|
39
|
+
return { patch: { brightnessPct: pct, on: pct > 0 } }
|
|
40
|
+
}
|
|
41
|
+
case config.GALightKelvin: {
|
|
42
|
+
const kelvin = Math.min(6535, Math.max(2000, Number(value)))
|
|
43
|
+
return { patch: { colorTempK: kelvin } }
|
|
44
|
+
}
|
|
45
|
+
case config.GALightKelvinPercentage: {
|
|
46
|
+
if (config.dptLightKelvinPercentage !== '5.001') return null
|
|
47
|
+
const mirek = scale(100 - Number(value), [0, 100], [153, 500])
|
|
48
|
+
return { patch: { colorTempK: miredToKelvin(mirek) } }
|
|
49
|
+
}
|
|
50
|
+
case config.GALightColor: {
|
|
51
|
+
if (!value || typeof value !== 'object') return null
|
|
52
|
+
return { patch: { rgb: { r: value.red, g: value.green, b: value.blue } } }
|
|
53
|
+
}
|
|
54
|
+
case config.GALightDIM:
|
|
55
|
+
case config.GALightHSV_H_DIM:
|
|
56
|
+
case config.GALightHSV_S_DIM:
|
|
57
|
+
case config.GALightKelvinDIM:
|
|
58
|
+
return { unsupported: 'Relative/HSV dimming not supported on Matter' }
|
|
59
|
+
case config.GALightBlink:
|
|
60
|
+
case config.GALightColorCycle:
|
|
61
|
+
return { unsupported: 'Effect not supported on Matter' }
|
|
62
|
+
default:
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The DPT that must be used to decode a given destination GA.
|
|
68
|
+
function dptForDestination (config, destination) {
|
|
69
|
+
switch (destination) {
|
|
70
|
+
case config.GALightSwitch: return config.dptLightSwitch
|
|
71
|
+
case config.GALightBrightness: return config.dptLightBrightness
|
|
72
|
+
case config.GALightKelvin: return config.dptLightKelvin
|
|
73
|
+
case config.GALightKelvinPercentage: return config.dptLightKelvinPercentage
|
|
74
|
+
case config.GALightColor: return config.dptLightColor
|
|
75
|
+
default: return null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { canonicalizeKnxLightCommand, dptForDestination }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Abstract LightEngine: the single seam between the Light node's engine-agnostic
|
|
3
|
+
// behaviour logic and a concrete ecosystem (Hue, Matter, ...future Shelly).
|
|
4
|
+
//
|
|
5
|
+
// A concrete engine must implement:
|
|
6
|
+
// - applyState(patch): push a canonical patch toward the ecosystem
|
|
7
|
+
// - parseIncoming(event): turn a native ecosystem event into a canonical patch
|
|
8
|
+
// - capabilities(): advertise what it can honour, so the node can degrade
|
|
9
|
+
// gracefully (e.g. native Hue effects have no Matter equivalent)
|
|
10
|
+
|
|
11
|
+
class LightEngine {
|
|
12
|
+
constructor (opts = {}) {
|
|
13
|
+
this.deviceId = opts.deviceId
|
|
14
|
+
this._onState = typeof opts.onStateChange === 'function' ? opts.onStateChange : () => {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get kind () { return 'abstract' }
|
|
18
|
+
|
|
19
|
+
capabilities () {
|
|
20
|
+
return { onOff: false, dimming: false, colorTemp: false, color: false, effects: false }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Canonical patch -> native ecosystem call(s). Returns the list of native
|
|
24
|
+
// operations issued (useful for tests and status/debug).
|
|
25
|
+
applyState (_patch) {
|
|
26
|
+
throw new Error(`${this.kind}: applyState not implemented`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Native ecosystem event -> canonical patch, or null when irrelevant.
|
|
30
|
+
parseIncoming (_event) {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Feed a native event and, when relevant, emit the canonical patch upward
|
|
35
|
+
// (to the node, which writes it to KNX). Keeps parseIncoming pure/testable.
|
|
36
|
+
handleIncoming (event) {
|
|
37
|
+
let patch = null
|
|
38
|
+
try {
|
|
39
|
+
patch = this.parseIncoming(event)
|
|
40
|
+
if (patch && Object.keys(patch).length > 0) this._onState(patch)
|
|
41
|
+
} catch (error) { /* a bad event / feedback sink must never crash the caller */ }
|
|
42
|
+
return patch
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { LightEngine }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Boundary shim that lets the (copied) Hue Light logic drive a MATTER light untouched.
|
|
3
|
+
// The node keeps speaking Hue-native composite states ({on:{on}}, {dimming:{brightness}},
|
|
4
|
+
// {color:{xy}}, {color_temperature:{mirek}}); these pure functions translate them to the
|
|
5
|
+
// canonical light model (for MatterLightEngine) and Matter attribute reports back into
|
|
6
|
+
// Hue-shaped feedback patches.
|
|
7
|
+
const hueColorConverter = require('../colorManipulators/hueColorConverter')
|
|
8
|
+
const { matterToKnx } = require('../matterKnxConverter')
|
|
9
|
+
|
|
10
|
+
// Hue-native state object -> canonical patch { on, brightnessPct, colorTempK, rgb }.
|
|
11
|
+
function hueStateToCanonical (hueState) {
|
|
12
|
+
try {
|
|
13
|
+
return _hueStateToCanonical(hueState)
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function _hueStateToCanonical (hueState) {
|
|
19
|
+
if (!hueState || typeof hueState !== 'object') return null
|
|
20
|
+
const patch = {}
|
|
21
|
+
if (hueState.on && hueState.on.on !== undefined) patch.on = !!hueState.on.on
|
|
22
|
+
if (hueState.dimming && hueState.dimming.brightness !== undefined) patch.brightnessPct = Number(hueState.dimming.brightness)
|
|
23
|
+
if (hueState.color_temperature && hueState.color_temperature.mirek) patch.colorTempK = Math.round(1000000 / Number(hueState.color_temperature.mirek))
|
|
24
|
+
if (hueState.color && hueState.color.xy && hueState.color.xy.x !== undefined) {
|
|
25
|
+
const bri = patch.brightnessPct !== undefined ? patch.brightnessPct : 100
|
|
26
|
+
try {
|
|
27
|
+
const rgb = hueColorConverter.ColorConverter.xyBriToRgb(Number(hueState.color.xy.x), Number(hueState.color.xy.y), bri)
|
|
28
|
+
patch.rgb = { r: rgb.r, g: rgb.g, b: rgb.b }
|
|
29
|
+
} catch (error) { /* colour conversion failed: skip the colour part */ }
|
|
30
|
+
}
|
|
31
|
+
return Object.keys(patch).length ? patch : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Matter attribute report -> feedback patch for the node
|
|
35
|
+
// { on } | { brightnessPct } | { colorTempK, mirek } | { xyX } | { xyY } | null.
|
|
36
|
+
function matterEventToHuePatch (event) {
|
|
37
|
+
try {
|
|
38
|
+
if (!event || event.attributeName === undefined) return null
|
|
39
|
+
switch (event.attributeName) {
|
|
40
|
+
case 'onOff':
|
|
41
|
+
return { on: event.value === true }
|
|
42
|
+
case 'currentLevel': {
|
|
43
|
+
const v = matterToKnx(Number(event.clusterId), 'currentLevel', event.value)
|
|
44
|
+
return v === undefined ? null : { brightnessPct: Number(v) }
|
|
45
|
+
}
|
|
46
|
+
case 'colorTemperatureMireds': {
|
|
47
|
+
const m = Number(event.value)
|
|
48
|
+
return m > 0 ? { colorTempK: Math.round(1000000 / m), mirek: m } : null
|
|
49
|
+
}
|
|
50
|
+
case 'currentX': return { xyX: Number(event.value) / 65536 }
|
|
51
|
+
case 'currentY': return { xyY: Number(event.value) / 65536 }
|
|
52
|
+
default: return null
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { hueStateToCanonical, matterEventToHuePatch }
|