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.
Files changed (79) hide show
  1. package/CHANGELOG.md +21 -8
  2. package/README.md +10 -3
  3. package/img/readmemain.png +0 -0
  4. package/nodes/commonFunctions.js +40 -3
  5. package/nodes/knxUltimate-config.js +379 -335
  6. package/nodes/knxUltimate.html +8 -9
  7. package/nodes/knxUltimate.js +5 -5
  8. package/nodes/knxUltimateAI.js +1 -1
  9. package/nodes/knxUltimateHueLight.html +3 -3
  10. package/nodes/knxUltimateHueLight.js +1 -1
  11. package/nodes/knxUltimateMatterBridge.html +321 -247
  12. package/nodes/knxUltimateMatterBridge.js +48 -1
  13. package/nodes/knxUltimateMatterControllerDevice.html +2119 -0
  14. package/nodes/knxUltimateMatterControllerDevice.js +1701 -0
  15. package/nodes/locales/de/knxUltimateMatterBridge.html +6 -2
  16. package/nodes/locales/de/knxUltimateMatterBridge.json +14 -1
  17. package/nodes/locales/de/knxUltimateMatterControllerDevice.html +27 -0
  18. package/nodes/locales/de/knxUltimateMatterControllerDevice.json +97 -0
  19. package/nodes/locales/de/matter-config.json +5 -1
  20. package/nodes/locales/de/matterbridge-config.json +1 -0
  21. package/nodes/locales/en/knxUltimateMatterBridge.html +6 -2
  22. package/nodes/locales/en/knxUltimateMatterBridge.json +14 -1
  23. package/nodes/locales/en/knxUltimateMatterControllerDevice.html +27 -0
  24. package/nodes/locales/en/knxUltimateMatterControllerDevice.json +97 -0
  25. package/nodes/locales/en/matter-config.json +6 -1
  26. package/nodes/locales/en/matterbridge-config.json +1 -0
  27. package/nodes/locales/es/knxUltimateMatterBridge.html +6 -2
  28. package/nodes/locales/es/knxUltimateMatterBridge.json +14 -1
  29. package/nodes/locales/es/knxUltimateMatterControllerDevice.html +27 -0
  30. package/nodes/locales/es/knxUltimateMatterControllerDevice.json +97 -0
  31. package/nodes/locales/es/matter-config.json +5 -1
  32. package/nodes/locales/es/matterbridge-config.json +1 -0
  33. package/nodes/locales/fr/knxUltimateMatterBridge.html +6 -2
  34. package/nodes/locales/fr/knxUltimateMatterBridge.json +14 -1
  35. package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +27 -0
  36. package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +89 -0
  37. package/nodes/locales/fr/matter-config.json +5 -1
  38. package/nodes/locales/fr/matterbridge-config.json +1 -0
  39. package/nodes/locales/it/knxUltimateMatterBridge.html +6 -2
  40. package/nodes/locales/it/knxUltimateMatterBridge.json +14 -1
  41. package/nodes/locales/it/knxUltimateMatterControllerDevice.html +27 -0
  42. package/nodes/locales/it/knxUltimateMatterControllerDevice.json +97 -0
  43. package/nodes/locales/it/matter-config.json +6 -1
  44. package/nodes/locales/it/matterbridge-config.json +1 -0
  45. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +6 -2
  46. package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +14 -1
  47. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +27 -0
  48. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +89 -0
  49. package/nodes/locales/zh-CN/matter-config.json +5 -1
  50. package/nodes/locales/zh-CN/matterbridge-config.json +1 -0
  51. package/nodes/matter-config.html +58 -6
  52. package/nodes/matter-config.js +7 -2
  53. package/nodes/matterbridge-config.html +1 -0
  54. package/nodes/utils/lightEngines/canonicalLight.js +75 -0
  55. package/nodes/utils/lightEngines/hueLightEngine.js +51 -0
  56. package/nodes/utils/lightEngines/index.js +25 -0
  57. package/nodes/utils/lightEngines/knxLightCommand.js +79 -0
  58. package/nodes/utils/lightEngines/lightEngine.js +46 -0
  59. package/nodes/utils/lightEngines/matterHueShim.js +59 -0
  60. package/nodes/utils/lightEngines/matterLightEngine.js +117 -0
  61. package/nodes/utils/matterEngine.mjs +185 -14
  62. package/nodes/utils/matterKnxConverter.js +1 -1
  63. package/package.json +21 -3
  64. package/resources/KNXSendSnippets.js +53 -53
  65. package/resources/htmlUtils.js +6 -4
  66. package/nodes/knxUltimateMatterDevice.html +0 -632
  67. package/nodes/knxUltimateMatterDevice.js +0 -308
  68. package/nodes/locales/de/knxUltimateMatterDevice.html +0 -56
  69. package/nodes/locales/de/knxUltimateMatterDevice.json +0 -109
  70. package/nodes/locales/en/knxUltimateMatterDevice.html +0 -56
  71. package/nodes/locales/en/knxUltimateMatterDevice.json +0 -109
  72. package/nodes/locales/es/knxUltimateMatterDevice.html +0 -56
  73. package/nodes/locales/es/knxUltimateMatterDevice.json +0 -109
  74. package/nodes/locales/fr/knxUltimateMatterDevice.html +0 -56
  75. package/nodes/locales/fr/knxUltimateMatterDevice.json +0 -109
  76. package/nodes/locales/it/knxUltimateMatterDevice.html +0 -56
  77. package/nodes/locales/it/knxUltimateMatterDevice.json +0 -109
  78. package/nodes/locales/zh-CN/knxUltimateMatterDevice.html +0 -56
  79. 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": "请先部署此配置节点,然后点击刷新。",
@@ -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
- RED.notify(RED._("node-red-contrib-knx-ultimate/matter-config:matter-config.properties.pairing_failed") + ': ' + data.error, { type: 'error', timeout: 8000 });
130
+ notifyPairingError(data.error);
98
131
  } else {
99
- RED.notify(RED._("node-red-contrib-knx-ultimate/matter-config:matter-config.properties.pairing_ok") + ' (Node ID ' + data.nodeId + ')', { type: 'success' });
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
- RED.notify(RED._("node-red-contrib-knx-ultimate/matter-config:matter-config.properties.pairing_failed") + ': ' + msg, { type: 'error', timeout: 8000 });
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>
@@ -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 }