node-red-contrib-knx-ultimate 6.0.12 → 6.1.1

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 (56) hide show
  1. package/CHANGELOG.md +12 -2
  2. package/examples/Matter Controller - Semantic Flow Input.json +248 -0
  3. package/nodes/knxUltimateMatterBridge.html +144 -11
  4. package/nodes/knxUltimateMatterControllerDevice.html +298 -13
  5. package/nodes/knxUltimateMatterControllerDevice.js +37 -0
  6. package/nodes/locales/de/knxUltimateMatterBridge.html +1 -1
  7. package/nodes/locales/de/knxUltimateMatterBridge.json +14 -0
  8. package/nodes/locales/de/knxUltimateMatterControllerDevice.html +13 -1
  9. package/nodes/locales/de/knxUltimateMatterControllerDevice.json +23 -0
  10. package/nodes/locales/de/matter-config.html +11 -1
  11. package/nodes/locales/de/matter-config.json +12 -0
  12. package/nodes/locales/en/knxUltimateMatterBridge.html +1 -1
  13. package/nodes/locales/en/knxUltimateMatterBridge.json +14 -0
  14. package/nodes/locales/en/knxUltimateMatterControllerDevice.html +13 -1
  15. package/nodes/locales/en/knxUltimateMatterControllerDevice.json +23 -0
  16. package/nodes/locales/en/matter-config.html +11 -1
  17. package/nodes/locales/en/matter-config.json +12 -0
  18. package/nodes/locales/es/knxUltimateMatterBridge.html +1 -1
  19. package/nodes/locales/es/knxUltimateMatterBridge.json +14 -0
  20. package/nodes/locales/es/knxUltimateMatterControllerDevice.html +13 -1
  21. package/nodes/locales/es/knxUltimateMatterControllerDevice.json +23 -0
  22. package/nodes/locales/es/matter-config.html +11 -1
  23. package/nodes/locales/es/matter-config.json +12 -0
  24. package/nodes/locales/fr/knxUltimateMatterBridge.html +1 -1
  25. package/nodes/locales/fr/knxUltimateMatterBridge.json +14 -0
  26. package/nodes/locales/fr/knxUltimateMatterControllerDevice.html +13 -1
  27. package/nodes/locales/fr/knxUltimateMatterControllerDevice.json +31 -0
  28. package/nodes/locales/fr/matter-config.html +11 -1
  29. package/nodes/locales/fr/matter-config.json +12 -0
  30. package/nodes/locales/it/knxUltimateMatterBridge.html +1 -1
  31. package/nodes/locales/it/knxUltimateMatterBridge.json +14 -0
  32. package/nodes/locales/it/knxUltimateMatterControllerDevice.html +13 -1
  33. package/nodes/locales/it/knxUltimateMatterControllerDevice.json +23 -0
  34. package/nodes/locales/it/matter-config.html +11 -1
  35. package/nodes/locales/it/matter-config.json +12 -0
  36. package/nodes/locales/zh-CN/knxUltimateMatterBridge.html +1 -1
  37. package/nodes/locales/zh-CN/knxUltimateMatterBridge.json +14 -0
  38. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.html +13 -1
  39. package/nodes/locales/zh-CN/knxUltimateMatterControllerDevice.json +31 -0
  40. package/nodes/locales/zh-CN/matter-config.html +11 -1
  41. package/nodes/locales/zh-CN/matter-config.json +12 -0
  42. package/nodes/matter-config.html +215 -23
  43. package/nodes/matter-config.js +48 -2
  44. package/nodes/utils/matterControllerCommandGate.js +47 -0
  45. package/nodes/utils/matterControllerProfiles/doorLock.js +19 -4
  46. package/nodes/utils/matterControllerProfiles/fan.js +0 -1
  47. package/nodes/utils/matterControllerProfiles/mappedEndpoint.js +32 -15
  48. package/nodes/utils/matterControllerProfiles/switch.js +0 -1
  49. package/nodes/utils/matterControllerProfiles/thermostat.js +0 -1
  50. package/nodes/utils/matterControllerProfiles/windowCovering.js +0 -1
  51. package/nodes/utils/matterControllerSemanticInput.js +212 -0
  52. package/nodes/utils/matterEngine.mjs +118 -16
  53. package/package.json +1 -1
  54. package/resources/matterQrScanner.js +98 -0
  55. package/resources/zxing-browser-0.2.0.min.js +1 -0
  56. package/resources/zxing-browser-LICENSE.txt +21 -0
@@ -11,7 +11,11 @@ Il controller comunica con i dispositivi tramite la **rete IP** (WiFi, Ethernet,
11
11
 
12
12
  1. Fai prima il **deploy** di questo nodo di configurazione (il controller deve essere in esecuzione).
13
13
  2. Riapri il nodo e inserisci il **codice di abbinamento**: il codice manuale a 11 cifre (es. `3497-011-2332`) oppure il contenuto del QR code (`MT:....`).
14
- 3. Clicca **ASSOCIA**. Il commissioning può richiedere fino a un minuto.
14
+ 3. Per un codice inserito manualmente, clicca **ASSOCIA**. Una lettura con **Webcam** o **Immagine** avvia automaticamente l'associazione. Il commissioning può richiedere fino a un minuto.
15
+
16
+ In alternativa alla digitazione del payload QR, clicca **Webcam** per leggerlo in tempo reale oppure **Immagine** per ricavarlo da una foto locale. Sono supportati sia i QR standard scuri su fondo chiaro sia quelli invertiti bianchi su fondo scuro. La decodifica avviene interamente nel browser; dopo la lettura di un QR Matter valido, l'editor compila il campo del codice e avvia immediatamente l'associazione. Se desideri assegnare un nome, inseriscilo prima della scansione. Un codice digitato manualmente parte ancora soltanto quando clicchi **ASSOCIA**. L'accesso live alla webcam richiede che l'editor sia aperto tramite HTTPS o da `localhost`; quando non è possibile, l'editor spiega il limite e il caricamento dell'immagine rimane disponibile.
17
+
18
+ Durante il commissioning, un pannello di attesa bloccante copre l'editor e impedisce ulteriori clic finché l'operazione non termina con successo o con un errore.
15
19
 
16
20
  Se il dispositivo è nuovo di fabbrica e supporta solo il commissioning Bluetooth, associalo prima con l'app del produttore o con un altro controller Matter (Alexa, Google Home, Apple Home), poi usa la funzione **"condividi / abbina con altro hub"** di quel controller per generare un nuovo codice di abbinamento per KNX-Ultimate. In questo modo il dispositivo entra in più fabric contemporaneamente.
17
21
 
@@ -26,4 +30,10 @@ Usa **Esporta** per scaricare un backup completo di questa istanza controller. I
26
30
  ## Rimuovere un dispositivo
27
31
 
28
32
  Usa il pulsante cestino nella lista dei dispositivi associati. Il controller prova a decommissionare correttamente il dispositivo; se non è raggiungibile, viene comunque rimosso dalla fabric (potrebbe poi servire un reset di fabbrica del dispositivo).
33
+
34
+ La lista contiene una riga per ogni nodo attualmente memorizzato nella fabric Matter di questo controller. I Node ID sono univoci all'interno della fabric; gli endpoint esposti da un unico bridge commissionato non sono elencati come dispositivi separati. La colonna dello stato indica se ogni nodo è connesso, disconnesso, in riconnessione o in attesa di rilevamento.
35
+
36
+ Il controller mantiene separato per ogni dispositivo associato l'ordine dei comandi. Un dispositivo lento, offline o rimosso non può bloccare i comandi destinati agli altri dispositivi. I nodi Controller che puntano ancora a un Node ID rimosso rifiutano subito i nuovi comandi e mostrano **Device no longer commissioned**.
37
+
38
+ Quando un dispositivo diventa non disponibile, i suoi nodi Controller rimangono bloccati e ignorano gli altri comandi finché quel dispositivo non segnala nuovamente `connected`. Il ripristino è automatico; anche l'apertura dell'editor del nodo dispositivo azzera il blocco per un tentativo manuale.
29
39
  </script>
@@ -9,6 +9,18 @@
9
9
  "pairing_code": "Codice abbinamento",
10
10
  "pairing_name": "Nome dispositivo",
11
11
  "pair_button": "ASSOCIA",
12
+ "qr_acquire": "Leggi QR code",
13
+ "qr_scan_camera": "Webcam",
14
+ "qr_scan_image": "Immagine",
15
+ "qr_stop_camera": "Ferma webcam",
16
+ "qr_camera_insecure": "Il browser non può usare la webcam perché l'editor Node-RED non è aperto tramite HTTPS o localhost. Riaprilo in HTTPS oppure usa Immagine.",
17
+ "qr_camera_unsupported": "Questo browser non può accedere alla webcam. Usa un browser supportato oppure carica un'immagine.",
18
+ "qr_camera_permission_denied": "Il permesso per la webcam è stato negato. Consenti l'accesso alla fotocamera nel browser oppure carica un'immagine.",
19
+ "qr_camera_failed": "Impossibile avviare la webcam",
20
+ "qr_image_failed": "Nell'immagine selezionata non è stato trovato un QR code leggibile",
21
+ "qr_invalid_matter": "Il QR code non è un codice di abbinamento Matter (MT:…).",
22
+ "qr_found": "QR code Matter letto. L'associazione parte automaticamente.",
23
+ "qr_decoder_unavailable": "Impossibile caricare il lettore QR.",
12
24
  "devices_title": "Dispositivi associati",
13
25
  "col_name": "Nome",
14
26
  "col_product": "Prodotto",
@@ -45,7 +45,7 @@
45
45
 
46
46
  ## 节点引脚
47
47
 
48
- 如果启用节点的输入/输出引脚:
48
+ 引脚选择器位于编辑器选项卡之外。启用后,其下方会显示与所选设备类型匹配的 **Flow 输入/输出** 区域,并提供可复制示例:
49
49
 
50
50
  - **输入**:从 Flow 更新 Matter 状态,无需经过 KNX 总线:`msg.payload = { function: "onoff", value: true }`(`function` 为 `onoff`、`level`、`rgb`、`colortemp`、`position`、`temperature`、`humidity`、`illuminance`、`occupancy`、`contact`、`currenttemp`、`setpoint`、`fanspeed`、`smoke`、`co`、`leak`、`co2`、`rvcstate`、`rvcmode` 之一)。适合把 Flow 中计算的值(如虚拟传感器)公开给 Alexa 等。
51
51
  - **输出**:从 Matter 控制器收到的每个命令都转发到 Flow:`msg.topic` = 设备名称,`msg.payload` = 值,`msg.matter` = 原始命令。没有命令 GA 的设备成为**纯 Flow 设备**。
@@ -36,6 +36,20 @@
36
36
  "mappings": "KNX 映射",
37
37
  "advanced": "高级选项"
38
38
  },
39
+ "flow_help": {
40
+ "title": "Flow 输入/输出",
41
+ "intro": "这些示例与所选设备类型匹配,并在启用节点输入/输出引脚时显示。",
42
+ "input_title": "Flow → Matter(节点输入)",
43
+ "input_hint": "发送以下消息之一,无需写入 KNX 即可更新所公开的 Matter 状态。",
44
+ "output_title": "Matter → Flow(节点输出)",
45
+ "output_hint": "Matter 控制器收到的命令会输出,值位于 msg.payload,原始路由详情位于 msg.matter。",
46
+ "function": "功能",
47
+ "message": "示例消息",
48
+ "copy": "复制",
49
+ "copied": "消息已复制",
50
+ "no_input": "此设备类型没有 Flow 状态更新示例。",
51
+ "no_output": "此设备类型不会输出控制器命令;其输出保持静默。"
52
+ },
39
53
  "functions": {
40
54
  "fn_onoff_cmd": "开/关 命令 GA",
41
55
  "fn_onoff_status": "开/关 状态 GA",
@@ -19,7 +19,15 @@
19
19
  | 传感器 | 传感器 endpoint 只在支持时显示对应测量/状态 GA:温度、湿度、照度、占用、接触和电池。 |
20
20
  | Read at startup | 在部署/启动或设备重新连接时发布缓存的 Matter 值。 |
21
21
  | Update local state from KNX write | 当配置的 KNX GA 收到写入 telegram 时,更新本地 Matter/KNX 缓存。 |
22
- | Node Input/Output PINs | 显示 Node-RED 输入/输出端口。输入接受布尔 payload,以及 `msg.payload` 或 `msg.on.on` 中的 Matter 风格消息;输出发送状态更新。重新打开编辑器时会保留此选择。 |
22
+ | Node Input/Output PINs | 显示 Node-RED 输入/输出端口,并在该字段下方显示 **流程输入** 区域。灯光会显示其支持的顶层灯光状态消息;其他端点会显示简单的 `{function,value}` 格式和高级 Matter 字段。 |
23
+
24
+ ## 流程输入
25
+
26
+ 启用 **Node Input/Output PINs** 后,**流程输入** 区域会直接显示在选择器下方。对于灯光,它提供所支持顶层属性的可复制示例,例如 `msg.on`、`msg.dimming`、`msg.color_temperature` 和 `msg.color`。对于其他端点,该区域根据公布的结构生成,并显示 Endpoint ID、所有可读/可写属性以及接受的命令。未选择 KNX 网关时仍然可用。
27
+
28
+ 简单写入使用 `msg.payload = {function:"position",value:35}`。省略 `value` 即可读取支持的状态,例如 `{function:"temperature"}`;输出使用易懂单位,并在 `msg.matter` 中保留原始详情。`onoff`、`level`、`position`、`open`、`close`、`stop`、设定点、风扇和传感器功能仅在端点公布支持时出现。门锁接受 `{function:"lock",value:true|false}`。
29
+
30
+ 原有高级输入继续有效:使用 `msg.clusterId` 配合 `msg.command`/`msg.args`,或使用 `msg.attribute` 和可选的 `msg.value`。Node ID 和 Endpoint ID 已由节点选择。
23
31
 
24
32
  ## 通用模式
25
33
 
@@ -34,4 +42,8 @@
34
42
  ## 行为
35
43
 
36
44
  节点根据 Matter 更新和 KNX 写入维护本地缓存,用该缓存响应 KNX 读取请求,并可在启动时读取/发送值。节点只监听已配置的组地址,因此会忽略无关的 KNX 流量。
45
+
46
+ 每个已配对设备使用独立且有序的命令队列。因此,离线、超时或已移除的设备不会延迟使用同一 KNX 组地址的其他 Matter 设备。仍引用已移除设备的节点会立即拒绝命令,并以红色显示 **Device no longer commissioned**;请选择有效的 Matter 设备,或删除遗留的 Controller 节点。
47
+
48
+ 设备不可用错误会保持锁定:后续 KNX 和 flow 命令将被忽略,且无法覆盖红色状态。一旦该 Matter 设备报告 `connected`,节点会自动恢复;打开节点编辑器也会解除锁定,以便手动重试。
37
49
  </script>
@@ -11,6 +11,29 @@
11
11
  "node-input-hueLight": "Matter 设备",
12
12
  "paletteLabel": "从 KNX 控制 Matter",
13
13
  "no_devices": "未找到 Matter 设备。请检查 Matter 控制器并点击刷新。",
14
+ "input_help_label": "流程输入",
15
+ "input_help_pin_hint": "发送这些消息前,请启用节点输入/输出端口。",
16
+ "input_help_endpoint": "所选端点",
17
+ "input_help_endpoint_hint": "节点已知 Node ID 和 Endpoint ID,因此简单消息无需提供它们。",
18
+ "input_help_simple_title": "简单消息",
19
+ "input_help_simple_hint": "这些功能使用易懂的单位,并自动转换为所选端点公开的 Matter 集群、命令或属性。",
20
+ "input_help_light_hint": "灯光端点直接在 msg 上接收灯光状态属性(而不是放在 msg.payload 中)。这里只显示所选灯具支持的控制项。",
21
+ "input_help_advanced_title": "高级 Matter 详情",
22
+ "input_help_advanced_hint": "仅列出设备公布的属性和命令。写入示例中的 0 是占位值;命令参数可能需要按 Matter 数据类型调整。",
23
+ "input_help_operation": "操作",
24
+ "input_help_function": "功能",
25
+ "input_help_target": "Matter 目标",
26
+ "input_help_message": "消息",
27
+ "input_help_read": "读取",
28
+ "input_help_write": "写入",
29
+ "input_help_command": "命令",
30
+ "input_help_copy": "复制",
31
+ "input_help_copied": "消息已复制",
32
+ "input_help_no_simple": "此端点没有已知的简单输入功能。",
33
+ "input_help_no_structure": "Matter 结构不可用。请在端点在线时刷新设备列表。",
34
+ "input_functions": {
35
+ "onoff": "开 / 关", "on": "打开", "off": "关闭", "level": "亮度", "brightness": "亮度", "color_temperature": "色温", "xy_color": "XY 颜色", "position": "窗帘位置", "tiltposition": "百叶角度", "open": "打开", "close": "关闭", "stop": "停止", "setpoint": "制热设定点", "coolingsetpoint": "制冷设定点", "currenttemp": "当前温度", "fanspeed": "风扇速度", "temperature": "温度", "humidity": "湿度", "illuminance": "照度", "occupancy": "占用", "contact": "触点", "battery": "电池", "activepower": "有功功率", "importedenergy": "输入电能", "identify": "识别", "lock": "上锁", "unlock": "解锁"
36
+ },
14
37
  "tabs": {
15
38
  "switch": "开关",
16
39
  "dim": "调光",
@@ -35,6 +58,14 @@
35
58
  "get_current": "获取当前",
36
59
  "get_again": "再次获取",
37
60
  "wait": "请稍候...",
61
+ "locate_no_bridge": "请先选择 Matter 控制器",
62
+ "locate_no_device": "请先选择 Matter 设备",
63
+ "locate_success": "已发送定位命令",
64
+ "locate_started": "定位模式已启动。再次按下可停止(10 分钟后自动停止)。",
65
+ "locate_stopped": "定位模式已停止。",
66
+ "locate_start_title": "定位所选 Matter 设备",
67
+ "locate_stop_title": "停止定位模式",
68
+ "locate_error": "无法定位 Matter 设备",
38
69
  "day_night": "昼/夜",
39
70
  "invert_day_night": "反转昼/夜值",
40
71
  "override_night_mode": "强制日间模式",
@@ -11,7 +11,11 @@
11
11
 
12
12
  1. 先**部署**此配置节点(控制器必须已运行)。
13
13
  2. 重新打开节点并输入**配对码**:11 位手动配对码(例如 `3497-011-2332`)或二维码内容(`MT:....`)。
14
- 3. 点击**配对**。调试过程最多可能需要一分钟。
14
+ 3. 对于手动输入的配对码,请点击**配对**。通过**摄像头**或**图片**读取二维码后会自动开始配对。调试过程最多可能需要一分钟。
15
+
16
+ 无需手动输入二维码 payload:点击**摄像头**可实时扫描,点击**图片**可从本地照片读取。支持浅色背景上的标准深色二维码以及深色背景上的反白二维码。解码完全在浏览器中进行;读取到有效的 Matter 二维码后,编辑器会填写配对码并立即自动开始配对。如需设置可选设备名称,请在扫描前填写。手动输入的配对码仍需点击**配对**才会开始。实时使用摄像头要求通过 HTTPS 或从 `localhost` 打开编辑器;若条件不满足,编辑器会说明此限制,而图片加载功能仍然可用。
17
+
18
+ 配对期间,阻塞式等待面板会覆盖编辑器并阻止进一步点击,直到操作成功完成或失败。
15
19
 
16
20
  如果设备是全新的且仅支持蓝牙配对,请先用厂商 App 或其他 Matter 控制器(Alexa、Google Home、Apple Home)配对,然后使用其**"与其他中枢共享/配对"**功能为 KNX-Ultimate 生成新的配对码。这样设备可以同时加入多个 fabric。
17
21
 
@@ -26,4 +30,10 @@ Fabric 凭据和已配对设备保存在 Node-RED 用户目录下的 `knxultimat
26
30
  ## 移除设备
27
31
 
28
32
  使用已配对设备列表中的垃圾桶按钮。控制器会尝试正确地解除设备调试;如果设备无法访问,仍会从 fabric 中移除(之后可能需要对设备进行恢复出厂设置)。
33
+
34
+ 该列表为当前存储在此控制器 Matter fabric 中的每个节点显示一行。Node ID 在同一 fabric 内是唯一的;由同一个已调试 bridge 暴露的 endpoint 不会作为独立设备列出。状态列会显示各节点处于已连接、已断开、正在重新连接或等待发现状态。
35
+
36
+ 控制器会为每个已配对设备分别保持命令顺序。缓慢、离线或已移除的设备不会阻塞发送给其他设备的命令。仍引用已移除 Node ID 的 Controller 设备节点会立即拒绝新命令,并显示 **Device no longer commissioned**。
37
+
38
+ 设备不可用时,其 Controller 节点会保持锁定并忽略后续命令,直到该设备再次报告 `connected`。恢复会自动进行;打开设备节点编辑器也会解除锁定,以便手动重试。
29
39
  </script>
@@ -9,6 +9,18 @@
9
9
  "pairing_code": "配对码",
10
10
  "pairing_name": "设备名称",
11
11
  "pair_button": "配对",
12
+ "qr_acquire": "读取二维码",
13
+ "qr_scan_camera": "摄像头",
14
+ "qr_scan_image": "图片",
15
+ "qr_stop_camera": "停止摄像头",
16
+ "qr_camera_insecure": "由于 Node-RED 编辑器未通过 HTTPS 或 localhost 打开,浏览器无法使用摄像头。请通过 HTTPS 重新打开,或使用图片。",
17
+ "qr_camera_unsupported": "此浏览器无法访问摄像头。请使用受支持的浏览器或加载图片。",
18
+ "qr_camera_permission_denied": "摄像头权限被拒绝。请在浏览器中允许摄像头访问,或加载图片。",
19
+ "qr_camera_failed": "无法启动摄像头",
20
+ "qr_image_failed": "在所选图片中未找到可读取的二维码",
21
+ "qr_invalid_matter": "此二维码不是 Matter 配对码(MT:…)。",
22
+ "qr_found": "已读取 Matter 二维码。配对将自动开始。",
23
+ "qr_decoder_unavailable": "无法加载二维码读取器。",
12
24
  "devices_title": "已配对设备",
13
25
  "col_name": "名称",
14
26
  "col_product": "产品",
@@ -1,4 +1,5 @@
1
1
  <script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/htmlUtils.js"></script>
2
+ <script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/matterQrScanner.js"></script>
2
3
 
3
4
  <script type="text/javascript">
4
5
  RED.nodes.registerType('matter-config', {
@@ -24,6 +25,165 @@
24
25
  var $storageExport = $('#matter-storage-export');
25
26
  var $storageImport = $('#matter-storage-import');
26
27
  var $storageFile = $('#matter-storage-file');
28
+ var $qrCameraButton = $('#matter-qr-camera');
29
+ var $qrImageButton = $('#matter-qr-image');
30
+ var $qrImageFile = $('#matter-qr-image-file');
31
+ var $qrCameraPanel = $('#matter-qr-camera-panel');
32
+ var $qrCameraVideo = $('#matter-qr-camera-video');
33
+ var $qrCameraStop = $('#matter-qr-camera-stop');
34
+ var $qrCameraWarning = $('#matter-qr-camera-warning');
35
+ var qrScannerControls = null;
36
+ var qrScannerStarting = false;
37
+ var qrUtils = window.KNXUltimateMatterQrScanner;
38
+
39
+ function qrText(key) {
40
+ return RED._("node-red-contrib-knx-ultimate/matter-config:matter-config.properties." + key);
41
+ }
42
+
43
+ function setQrButtonsDisabled(disabled) {
44
+ $qrCameraButton.prop('disabled', disabled);
45
+ $qrImageButton.prop('disabled', disabled);
46
+ }
47
+
48
+ function stopQrCamera() {
49
+ qrScannerStarting = false;
50
+ if (qrScannerControls && typeof qrScannerControls.stop === 'function') {
51
+ try { qrScannerControls.stop(); } catch (error) { }
52
+ }
53
+ qrScannerControls = null;
54
+ if (qrUtils) qrUtils.stopVideo($qrCameraVideo[0]);
55
+ $qrCameraPanel.hide();
56
+ setQrButtonsDisabled(false);
57
+ }
58
+
59
+ function notifyQrError(key, error) {
60
+ var message = qrText(key);
61
+ if (error && error.message) message += ': ' + error.message;
62
+ RED.notify(message, { type: 'error', fixed: true });
63
+ }
64
+
65
+ function applyQrResult(result, scannerControls) {
66
+ if (scannerControls && typeof scannerControls.stop === 'function') {
67
+ try { scannerControls.stop(); } catch (error) { }
68
+ }
69
+ stopQrCamera();
70
+ var value = qrUtils ? qrUtils.normalizeMatterQrPayload(qrUtils.getResultText(result)) : null;
71
+ if (!value) {
72
+ RED.notify(qrText('qr_invalid_matter'), { type: 'warning', fixed: true });
73
+ return false;
74
+ }
75
+ $pairingCode.val(value).trigger('change').focus();
76
+ RED.notify(qrText('qr_found'), { type: 'success' });
77
+ $pairButton.trigger('click');
78
+ return true;
79
+ }
80
+
81
+ function loadQrDecoder() {
82
+ if (window.ZXingBrowser && window.ZXingBrowser.BrowserQRCodeReader) return Promise.resolve(window.ZXingBrowser);
83
+ if (window.knxUltimateMatterQrDecoderPromise) return window.knxUltimateMatterQrDecoderPromise;
84
+ window.knxUltimateMatterQrDecoderPromise = new Promise(function (resolve, reject) {
85
+ var script = document.createElement('script');
86
+ script.async = true;
87
+ script.src = 'resources/node-red-contrib-knx-ultimate/zxing-browser-0.2.0.min.js';
88
+ script.onload = function () {
89
+ if (window.ZXingBrowser && window.ZXingBrowser.BrowserQRCodeReader) {
90
+ resolve(window.ZXingBrowser);
91
+ } else {
92
+ reject(new Error(qrText('qr_decoder_unavailable')));
93
+ }
94
+ };
95
+ script.onerror = function () {
96
+ reject(new Error(qrText('qr_decoder_unavailable')));
97
+ };
98
+ document.head.appendChild(script);
99
+ }).catch(function (error) {
100
+ window.knxUltimateMatterQrDecoderPromise = null;
101
+ throw error;
102
+ });
103
+ return window.knxUltimateMatterQrDecoderPromise;
104
+ }
105
+
106
+ function createQrReader(ZXingBrowser) {
107
+ return qrUtils.enableInvertedQrFallback(new ZXingBrowser.BrowserQRCodeReader());
108
+ }
109
+
110
+ function scanQrImage(file) {
111
+ if (!file) return;
112
+ stopQrCamera();
113
+ setQrButtonsDisabled(true);
114
+ loadQrDecoder().then(function (ZXingBrowser) {
115
+ var imageUrl = URL.createObjectURL(file);
116
+ var reader = createQrReader(ZXingBrowser);
117
+ return reader.decodeFromImageUrl(imageUrl).then(function (result) {
118
+ applyQrResult(result);
119
+ }).finally(function () {
120
+ URL.revokeObjectURL(imageUrl);
121
+ });
122
+ }).catch(function (error) {
123
+ notifyQrError('qr_image_failed', error);
124
+ }).finally(function () {
125
+ setQrButtonsDisabled(false);
126
+ });
127
+ }
128
+
129
+ function startQrCamera() {
130
+ var cameraStatus = qrUtils ? qrUtils.getCameraStatus(window, navigator) : { available: false, reason: 'unsupported' };
131
+ if (!cameraStatus.available) {
132
+ var warningKey = cameraStatus.reason === 'insecure' ? 'qr_camera_insecure' : 'qr_camera_unsupported';
133
+ RED.notify(qrText(warningKey), { type: 'warning', fixed: true });
134
+ return;
135
+ }
136
+ stopQrCamera();
137
+ qrScannerStarting = true;
138
+ setQrButtonsDisabled(true);
139
+ $qrCameraPanel.show();
140
+ loadQrDecoder().then(function (ZXingBrowser) {
141
+ if (!qrScannerStarting) return null;
142
+ var reader = createQrReader(ZXingBrowser);
143
+ return reader.decodeFromConstraints({
144
+ audio: false,
145
+ video: { facingMode: { ideal: 'environment' } }
146
+ }, $qrCameraVideo[0], function (result, error, controls) {
147
+ if (error) { /* ZXing keeps trying while the QR is outside the frame. */ }
148
+ if (result) applyQrResult(result, controls);
149
+ });
150
+ }).then(function (controls) {
151
+ if (!controls) return;
152
+ if (!qrScannerStarting) {
153
+ try { controls.stop(); } catch (error) { }
154
+ return;
155
+ }
156
+ qrScannerControls = controls;
157
+ qrScannerStarting = false;
158
+ $qrCameraButton.prop('disabled', true);
159
+ $qrImageButton.prop('disabled', false);
160
+ }).catch(function (error) {
161
+ stopQrCamera();
162
+ if (qrUtils && qrUtils.isCameraPermissionError(error)) {
163
+ notifyQrError('qr_camera_permission_denied');
164
+ } else {
165
+ notifyQrError('qr_camera_failed', error);
166
+ }
167
+ });
168
+ }
169
+
170
+ var initialCameraStatus = qrUtils ? qrUtils.getCameraStatus(window, navigator) : { available: false, reason: 'unsupported' };
171
+ if (!initialCameraStatus.available) {
172
+ $qrCameraWarning.text(qrText(initialCameraStatus.reason === 'insecure' ? 'qr_camera_insecure' : 'qr_camera_unsupported')).show();
173
+ }
174
+
175
+ $qrCameraButton.on('click', startQrCamera);
176
+ $qrCameraStop.on('click', stopQrCamera);
177
+ $qrImageButton.on('click', function () {
178
+ stopQrCamera();
179
+ $qrImageFile.trigger('click');
180
+ });
181
+ $qrImageFile.on('change', function () {
182
+ var file = this.files && this.files[0];
183
+ this.value = '';
184
+ scanQrImage(file);
185
+ });
186
+ node._matterQrCleanup = stopQrCamera;
27
187
 
28
188
  function storageExportUrl() {
29
189
  var adminRoot = (RED.settings && typeof RED.settings.httpAdminRoot === 'string') ? RED.settings.httpAdminRoot : '/';
@@ -134,11 +294,6 @@
134
294
  }
135
295
  $pairButton.prop('disabled', true);
136
296
  $pairSpinner.show();
137
- var myNotification = RED.notify(RED._("node-red-contrib-knx-ultimate/matter-config:matter-config.properties.pairing_wait"), {
138
- modal: false,
139
- fixed: true,
140
- type: 'info'
141
- });
142
297
  $.ajax({
143
298
  url: 'KNXUltimateMatterPair?serverId=' + node.id + '&code=' + encodeURIComponent(code) + (desiredName !== '' ? '&name=' + encodeURIComponent(desiredName) : ''),
144
299
  dataType: 'json',
@@ -159,7 +314,6 @@
159
314
  try { msg = jqXHR.responseJSON.error; } catch (e) { msg = errorThrown || textStatus; }
160
315
  notifyPairingError(msg);
161
316
  }).always(function () {
162
- if (myNotification) myNotification.close();
163
317
  $pairButton.prop('disabled', false);
164
318
  $pairSpinner.hide();
165
319
  });
@@ -194,11 +348,21 @@
194
348
  });
195
349
  },
196
350
  oneditsave: function () {
351
+ if (typeof this._matterQrCleanup === 'function') this._matterQrCleanup();
352
+ delete this._matterQrCleanup;
197
353
  // Return to the info tab
198
354
  try {
199
355
  RED.sidebar.show("info");
200
356
  } catch (error) { }
201
357
  },
358
+ oneditcancel: function () {
359
+ if (typeof this._matterQrCleanup === 'function') this._matterQrCleanup();
360
+ delete this._matterQrCleanup;
361
+ },
362
+ oneditdelete: function () {
363
+ if (typeof this._matterQrCleanup === 'function') this._matterQrCleanup();
364
+ delete this._matterQrCleanup;
365
+ },
202
366
  label: function () {
203
367
  return this.name || "Matter Controller";
204
368
  }
@@ -253,16 +417,38 @@
253
417
  <hr/>
254
418
 
255
419
  <div class="form-row">
256
- <b><i class="fa fa-database"></i>&nbsp;<span data-i18n="matter-config.properties.storage_title"></span></b>
257
- <button type="button" id="matter-storage-export" class="red-ui-button" style="margin-left:8px;"><i class="fa fa-download"></i>&nbsp;<span data-i18n="matter-config.properties.storage_export"></span></button>
258
- <button type="button" id="matter-storage-import" class="red-ui-button"><i class="fa fa-upload"></i>&nbsp;<span data-i18n="matter-config.properties.storage_import"></span></button>
259
- <input type="file" id="matter-storage-file" accept="application/json,.json" style="display:none;">
420
+ <b><i class="fa fa-qrcode"></i>&nbsp;<span data-i18n="matter-config.properties.pairing_title"></span></b>
260
421
  </div>
261
422
 
262
- <hr/>
423
+ <div class="form-row">
424
+ <label for="matter-pairing-name">
425
+ <i class="fa fa-tag"></i>
426
+ <span data-i18n="matter-config.properties.pairing_name"></span>
427
+ </label>
428
+ <input type="text" id="matter-pairing-name" placeholder="Kitchen plug" style="width:250px;">
429
+ </div>
263
430
 
264
431
  <div class="form-row">
265
- <b><i class="fa fa-qrcode"></i>&nbsp;<span data-i18n="matter-config.properties.pairing_title"></span></b>
432
+ <label>
433
+ <i class="fa fa-qrcode"></i>
434
+ <span data-i18n="matter-config.properties.qr_acquire"></span>
435
+ </label>
436
+ <button type="button" id="matter-qr-camera" class="red-ui-button">
437
+ <i class="fa fa-camera"></i>&nbsp;<span data-i18n="matter-config.properties.qr_scan_camera"></span>
438
+ </button>
439
+ <button type="button" id="matter-qr-image" class="red-ui-button">
440
+ <i class="fa fa-image"></i>&nbsp;<span data-i18n="matter-config.properties.qr_scan_image"></span>
441
+ </button>
442
+ <input type="file" id="matter-qr-image-file" accept="image/*" style="display:none;">
443
+ </div>
444
+
445
+ <div class="form-row" id="matter-qr-camera-warning" role="status" style="display:none; margin-left:105px; width:calc(100% - 105px); color:#b36b00;"></div>
446
+
447
+ <div class="form-row" id="matter-qr-camera-panel" style="display:none; margin-left:105px; width:calc(100% - 105px);">
448
+ <video id="matter-qr-camera-video" autoplay muted playsinline style="display:block; width:100%; max-width:430px; max-height:300px; background:#111; border-radius:4px; object-fit:cover;"></video>
449
+ <button type="button" id="matter-qr-camera-stop" class="red-ui-button" style="margin-top:6px;">
450
+ <i class="fa fa-stop"></i>&nbsp;<span data-i18n="matter-config.properties.qr_stop_camera"></span>
451
+ </button>
266
452
  </div>
267
453
 
268
454
  <div class="form-row">
@@ -274,17 +460,6 @@
274
460
  <button type="button" id="matterPairButton" class="red-ui-button" style="margin-left:6px; color:#1b7d33; border-color:#1b7d33;">
275
461
  <i class="fa fa-link"></i>&nbsp;<span data-i18n="matter-config.properties.pair_button"></span>
276
462
  </button>
277
- <span id="matterPairSpinner" style="margin-left:6px; display:none; color:#1b7d33;">
278
- <i class="fa fa-circle-notch fa-spin"></i>
279
- </span>
280
- </div>
281
-
282
- <div class="form-row">
283
- <label for="matter-pairing-name">
284
- <i class="fa fa-tag"></i>
285
- <span data-i18n="matter-config.properties.pairing_name"></span>
286
- </label>
287
- <input type="text" id="matter-pairing-name" placeholder="Kitchen plug" style="width:250px;">
288
463
  </div>
289
464
  <hr/>
290
465
 
@@ -310,6 +485,23 @@
310
485
  </table>
311
486
  </div>
312
487
 
488
+ <hr/>
489
+
490
+ <div class="form-row">
491
+ <b><i class="fa fa-database"></i>&nbsp;<span data-i18n="matter-config.properties.storage_title"></span></b>
492
+ <button type="button" id="matter-storage-export" class="red-ui-button" style="margin-left:8px;"><i class="fa fa-download"></i>&nbsp;<span data-i18n="matter-config.properties.storage_export"></span></button>
493
+ <button type="button" id="matter-storage-import" class="red-ui-button"><i class="fa fa-upload"></i>&nbsp;<span data-i18n="matter-config.properties.storage_import"></span></button>
494
+ <input type="file" id="matter-storage-file" accept="application/json,.json" style="display:none;">
495
+ </div>
496
+
497
+ <div id="matterPairSpinner" role="alertdialog" aria-modal="true" aria-labelledby="matter-pairing-overlay-message"
498
+ style="display:none; position:fixed; inset:0; z-index:100000; background:rgba(20, 24, 28, 0.72); cursor:wait;">
499
+ <div style="position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); min-width:280px; max-width:80vw; padding:28px 34px; border-radius:8px; background:#fff; color:#333; text-align:center; box-shadow:0 8px 30px rgba(0,0,0,0.4);">
500
+ <i class="fa fa-circle-notch fa-spin" aria-hidden="true" style="display:block; margin-bottom:16px; color:#1b7d33; font-size:54px;"></i>
501
+ <strong id="matter-pairing-overlay-message" data-i18n="matter-config.properties.pairing_wait" style="font-size:16px;"></strong>
502
+ </div>
503
+ </div>
504
+
313
505
  <br/>
314
506
  <br/>
315
507
  <br/>
@@ -53,14 +53,15 @@ module.exports = (RED) => {
53
53
  }
54
54
  }
55
55
  const clientMatchesNode = (client, nodeId) => client?.matterNodeId === '__UNIVERSAL__' || String(client?.matterNodeId) === String(nodeId)
56
+ const clientOwnsNode = (client, nodeId) => client?.matterNodeId !== '__UNIVERSAL__' && String(client?.matterNodeId) === String(nodeId)
56
57
 
57
58
  node.initMatterConnection = async () => {
58
59
  try {
59
60
  if (node.matterManager !== null) await node.matterManager.close()
60
61
  } catch (error) { /* empty */ }
61
62
  try {
62
- const { classMatter } = await import('./utils/matterEngine.mjs')
63
- node.matterManager = new classMatter(node.matterStoragePath, node.matterInstanceId, node.fabricLabel, node.sysLogger)
63
+ const { classMatter: MatterControllerEngine } = await import('./utils/matterEngine.mjs')
64
+ node.matterManager = new MatterControllerEngine(node.matterStoragePath, node.matterInstanceId, node.fabricLabel, node.sysLogger)
64
65
  } catch (error) {
65
66
  node.sysLogger?.error(`Errore matter-config: node.initMatterConnection: ${error.message}`)
66
67
  throw (error)
@@ -70,6 +71,7 @@ module.exports = (RED) => {
70
71
  node.matterManager.on('event', (_event) => {
71
72
  node.nodeClients.forEach((_oClient) => {
72
73
  try {
74
+ if (clientOwnsNode(_oClient, _event?.nodeId)) _oClient.clearMatterCommandBlock?.('connected')
73
75
  if (_oClient.handleSendMatter !== undefined) _oClient.handleSendMatter(_event)
74
76
  } catch (error) {
75
77
  node.sysLogger?.error(`Errore node.matterManager.on(event): ${error.message}`)
@@ -81,6 +83,7 @@ module.exports = (RED) => {
81
83
  node.matterManager.on('matterEvent', (_event) => {
82
84
  node.nodeClients.forEach((_oClient) => {
83
85
  try {
86
+ if (clientOwnsNode(_oClient, _event?.nodeId)) _oClient.clearMatterCommandBlock?.('connected')
84
87
  if (_oClient.handleMatterClusterEvent !== undefined) _oClient.handleMatterClusterEvent(_event)
85
88
  } catch (error) {
86
89
  node.sysLogger?.error(`Errore node.matterManager.on(matterEvent): ${error.message}`)
@@ -92,6 +95,9 @@ module.exports = (RED) => {
92
95
  node.matterManager.on('nodeInitialized', (_nodeId) => {
93
96
  node.nodeClients.forEach((_oClient) => {
94
97
  if (clientMatchesNode(_oClient, _nodeId)) {
98
+ if (clientOwnsNode(_oClient, _nodeId)) {
99
+ safeClientCall(_oClient, () => _oClient.clearMatterCommandBlock?.('connected'), 'clearMatterCommandBlock')
100
+ }
95
101
  safeClientCall(_oClient, () => _oClient.handleMatterNodeInitialized(_nodeId), 'handleMatterNodeInitialized')
96
102
  }
97
103
  })
@@ -101,6 +107,13 @@ module.exports = (RED) => {
101
107
  node.matterManager.on('nodeState', (_nodeId, _state) => {
102
108
  node.nodeClients.forEach((_oClient) => {
103
109
  if (clientMatchesNode(_oClient, _nodeId)) {
110
+ if (clientOwnsNode(_oClient, _nodeId)) {
111
+ if (_state === 'connected') {
112
+ safeClientCall(_oClient, () => _oClient.clearMatterCommandBlock?.('connected'), 'clearMatterCommandBlock')
113
+ } else {
114
+ safeClientCall(_oClient, () => _oClient.blockMatterCommands?.('Matter device unavailable'), 'blockMatterCommands')
115
+ }
116
+ }
104
117
  safeClientCall(_oClient, () => _oClient.setNodeStatusMatter({
105
118
  fill: _state === 'connected' ? 'green' : 'yellow',
106
119
  shape: _state === 'connected' ? 'dot' : 'ring',
@@ -114,6 +127,39 @@ module.exports = (RED) => {
114
127
  })
115
128
  })
116
129
 
130
+ node.matterManager.on('commandError', ({ item, error, code } = {}) => {
131
+ if (!item?.nodeId) return
132
+ const text = code === 'MATTER_NODE_NOT_COMMISSIONED'
133
+ ? 'Device no longer commissioned'
134
+ : code === 'MATTER_NODE_UNAVAILABLE' || code === 'MATTER_COMMAND_TIMEOUT'
135
+ ? 'Matter device unavailable'
136
+ : `Matter command error: ${error || 'unknown error'}`
137
+ const mustBlock = ['MATTER_NODE_NOT_COMMISSIONED', 'MATTER_NODE_UNAVAILABLE', 'MATTER_COMMAND_TIMEOUT'].includes(code)
138
+ if (mustBlock) {
139
+ const queued = node.matterManager.deleteMatterQueue(item.nodeId)
140
+ if (queued && typeof queued.catch === 'function') queued.catch((queueError) => node.sysLogger?.warn(`Matter queue purge error: ${queueError.message}`))
141
+ }
142
+ node.nodeClients.forEach((_oClient) => {
143
+ if (String(_oClient?.matterNodeId) !== String(item.nodeId)) return
144
+ safeClientCall(_oClient, () => {
145
+ if (mustBlock && typeof _oClient.blockMatterCommands === 'function') {
146
+ _oClient.blockMatterCommands(text)
147
+ return
148
+ }
149
+ const status = {
150
+ fill: 'red',
151
+ shape: 'ring',
152
+ text,
153
+ payload: ''
154
+ }
155
+ if (typeof _oClient.setNodeStatusMatter === 'function') return _oClient.setNodeStatusMatter(status)
156
+ // The legacy light profile's setNodeStatusHue deliberately recolours normal
157
+ // state updates blue, so use the Node-RED status API directly for errors.
158
+ if (typeof _oClient.status === 'function') return _oClient.status(status)
159
+ }, 'Matter command error status')
160
+ })
161
+ })
162
+
117
163
  node.matterManager.on('connected', () => {
118
164
  node.nodeClients.forEach((_oClient) => {
119
165
  safeClientCall(_oClient, () => _oClient.setNodeStatusMatter({
@@ -0,0 +1,47 @@
1
+ 'use strict'
2
+
3
+ const COMMAND_TIMER_KEYS = [
4
+ 'timerBlink',
5
+ 'timerColorCycle',
6
+ 'timerStepDim',
7
+ 'timerStepDimHSV_H',
8
+ 'timerStepDimHSV_S',
9
+ 'timerStepDimTunableWhite'
10
+ ]
11
+
12
+ const setupMatterControllerCommandGate = (node) => {
13
+ node.matterCommandBlocked = false
14
+ node.matterCommandBlockReason = ''
15
+
16
+ node.isMatterCommandBlocked = () => node.matterCommandBlocked === true
17
+
18
+ node.blockMatterCommands = (reason = 'Matter device unavailable') => {
19
+ node.matterCommandBlocked = true
20
+ node.matterCommandBlockReason = String(reason || 'Matter device unavailable')
21
+ COMMAND_TIMER_KEYS.forEach((key) => {
22
+ if (node[key] !== undefined && node[key] !== null) clearInterval(node[key])
23
+ node[key] = undefined
24
+ })
25
+ node.status({
26
+ fill: 'red',
27
+ shape: 'ring',
28
+ text: node.matterCommandBlockReason
29
+ })
30
+ }
31
+
32
+ node.clearMatterCommandBlock = (source = 'editor') => {
33
+ if (node.matterCommandBlocked !== true) return false
34
+ node.matterCommandBlocked = false
35
+ node.matterCommandBlockReason = ''
36
+ node.status({
37
+ fill: source === 'connected' ? 'green' : 'yellow',
38
+ shape: 'ring',
39
+ text: source === 'connected' ? 'Matter device reconnected' : 'Matter retry enabled'
40
+ })
41
+ return true
42
+ }
43
+
44
+ return node
45
+ }
46
+
47
+ module.exports = { setupMatterControllerCommandGate }