diff --git a/server/package-lock.json b/server/package-lock.json index 55ef046d84..047b10e7fe 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -53,7 +53,7 @@ "ws": "^6.2.2" }, "devDependencies": { - "@matter/main": "^0.13.0", + "@matter/main": "^0.16.10", "apidoc": "^1.0.3", "chai": "^4.3.7", "chai-as-promised": "^7.1.1", @@ -1062,93 +1062,93 @@ } }, "node_modules/@matter/general": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.13.0.tgz", - "integrity": "sha512-PZ+FVJotKWgtoBvorqN+PLCuTBBbTCJTCss2P5C6n9r/ZAIcCgW7LDTFmRXNNNMJEri8JUVR0REvHaa92zVj2Q==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.16.10.tgz", + "integrity": "sha512-/qytvaxvDDhEdHLaEoxlEFVBWg982jL+XXvOmAFgIv92yGDQvN4U+VcNW7S5dueJuv/L+gi0zDqhl8LUzHHAlg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@noble/curves": "^1.8.2" + "@noble/curves": "^2.0.1" } }, "node_modules/@matter/main": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.13.0.tgz", - "integrity": "sha512-Qu60G05f821bEtp2yU+rJ7Xpe66GHDn9NdSPGrAn1EJH/iWplmVeLS1nSXzyGE28iPVPYNLcMX0edY/FejAhmQ==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.16.10.tgz", + "integrity": "sha512-QKoQrmMnt6cB893+Oezk3DdIVgQJj5spt/ikEszF8pjyTuvB69zlzq1wnrn52N3mi57R6UWwk6+BPEbQE95FSw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.13.0", - "@matter/model": "0.13.0", - "@matter/node": "0.13.0", - "@matter/protocol": "0.13.0", - "@matter/types": "0.13.0" + "@matter/general": "0.16.10", + "@matter/model": "0.16.10", + "@matter/node": "0.16.10", + "@matter/protocol": "0.16.10", + "@matter/types": "0.16.10" }, "optionalDependencies": { - "@matter/nodejs": "0.13.0" + "@matter/nodejs": "0.16.10" } }, "node_modules/@matter/model": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.13.0.tgz", - "integrity": "sha512-rcXu7OdMctlOGVOkClH6/+11ct6FMDSK2/Yu05+J7BJfohJY5mQbo0jnz8l0FPbwRrk5ADbqfAJ5jh/1OKHR2Q==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.16.10.tgz", + "integrity": "sha512-6Ei8gETAkcKGEMRW+z8Mak55Y1Jl1TKGQIboC/4vvsrqcvB8zhIvGBS3GaAllxzvF0qjE7ihCPpgXXr6HuTLyg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.13.0" + "@matter/general": "0.16.10" } }, "node_modules/@matter/node": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.13.0.tgz", - "integrity": "sha512-HQkzpRnhAUfj9u2ijkT4qgyFzEfaNqyAxh+dgMpLKnbbGQoHTskJ/wEgkRRI7B0Q57mr6VJ5KgXYdbXUYA7xFA==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.16.10.tgz", + "integrity": "sha512-Hb2AxuEf0DlfN8yHxeahZGYurUUu/UDWJkmdvpDKuwSR0eIHhMweOG2RBO55/anyRFObANaUr1gr3DnyocB/0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.13.0", - "@matter/model": "0.13.0", - "@matter/protocol": "0.13.0", - "@matter/types": "0.13.0" + "@matter/general": "0.16.10", + "@matter/model": "0.16.10", + "@matter/protocol": "0.16.10", + "@matter/types": "0.16.10" } }, "node_modules/@matter/nodejs": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.13.0.tgz", - "integrity": "sha512-ThWrLZJo7UH4Ebanf3gxkhe2p8vq4X3PxyRG+ICDRGPfzSAJZ3znh2hD/zdvcdyzQlSoXeLpFbLpTkJu7aRMHg==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.16.10.tgz", + "integrity": "sha512-o8e9tGZVsiqmEtD1osSL9+gRMIuNjtwXt29I3YDRzSqb3N5Dvd4Khj3HN68YhaFFeBlM4YEV2TS0/9VF3C06/w==", "dev": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "@matter/general": "0.13.0", - "@matter/node": "0.13.0", - "@matter/protocol": "0.13.0", - "@matter/types": "0.13.0" + "@matter/general": "0.16.10", + "@matter/node": "0.16.10", + "@matter/protocol": "0.16.10", + "@matter/types": "0.16.10" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.19.0 <22.0.0 || >=22.13.0" } }, "node_modules/@matter/protocol": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.13.0.tgz", - "integrity": "sha512-wFxU6+LG5ygzruOV5JF30hmLkk88hz8PqAMkQ6ow81ZvoDFBevsW0OyqxXMCNwYd/zmGXmvr3mpC0mi9/troHg==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.16.10.tgz", + "integrity": "sha512-vPQEMl8Wf4vc3tauwsGlLZRrDx+VrCPfccw3n50Lvyucws4UBt+SuBszSyIO2+QPnJcr4MB4L+EgCRI14U4zRA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.13.0", - "@matter/model": "0.13.0", - "@matter/types": "0.13.0" + "@matter/general": "0.16.10", + "@matter/model": "0.16.10", + "@matter/types": "0.16.10" } }, "node_modules/@matter/types": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.13.0.tgz", - "integrity": "sha512-8mH7hRC4MBSy4KUs8zb6uDTr0MfRYGtGBFq9t2uedlsiHA5lMUP3L1fitszoeCbpJh4EeqKHWSvF6RxWzKNueA==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.16.10.tgz", + "integrity": "sha512-AW86tGgL3sN1Vb76+03VPdQMsW9vWsaVGHe9agcED1sjtWRJBV17tcLDymAQ8k7fEeVqiy3qf9CUwFK1bhfXKQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.13.0", - "@matter/model": "0.13.0" + "@matter/general": "0.16.10", + "@matter/model": "0.16.10" } }, "node_modules/@microsoft/recognizers-text": { @@ -1776,29 +1776,29 @@ } }, "node_modules/@noble/curves": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.0.tgz", - "integrity": "sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", "dev": true, "license": "MIT", "dependencies": { - "@noble/hashes": "1.8.0" + "@noble/hashes": "2.0.1" }, "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", "dev": true, "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -13146,81 +13146,81 @@ } }, "@matter/general": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.13.0.tgz", - "integrity": "sha512-PZ+FVJotKWgtoBvorqN+PLCuTBBbTCJTCss2P5C6n9r/ZAIcCgW7LDTFmRXNNNMJEri8JUVR0REvHaa92zVj2Q==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.16.10.tgz", + "integrity": "sha512-/qytvaxvDDhEdHLaEoxlEFVBWg982jL+XXvOmAFgIv92yGDQvN4U+VcNW7S5dueJuv/L+gi0zDqhl8LUzHHAlg==", "dev": true, "requires": { - "@noble/curves": "^1.8.2" + "@noble/curves": "^2.0.1" } }, "@matter/main": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.13.0.tgz", - "integrity": "sha512-Qu60G05f821bEtp2yU+rJ7Xpe66GHDn9NdSPGrAn1EJH/iWplmVeLS1nSXzyGE28iPVPYNLcMX0edY/FejAhmQ==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.16.10.tgz", + "integrity": "sha512-QKoQrmMnt6cB893+Oezk3DdIVgQJj5spt/ikEszF8pjyTuvB69zlzq1wnrn52N3mi57R6UWwk6+BPEbQE95FSw==", "dev": true, "requires": { - "@matter/general": "0.13.0", - "@matter/model": "0.13.0", - "@matter/node": "0.13.0", - "@matter/nodejs": "0.13.0", - "@matter/protocol": "0.13.0", - "@matter/types": "0.13.0" + "@matter/general": "0.16.10", + "@matter/model": "0.16.10", + "@matter/node": "0.16.10", + "@matter/nodejs": "0.16.10", + "@matter/protocol": "0.16.10", + "@matter/types": "0.16.10" } }, "@matter/model": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.13.0.tgz", - "integrity": "sha512-rcXu7OdMctlOGVOkClH6/+11ct6FMDSK2/Yu05+J7BJfohJY5mQbo0jnz8l0FPbwRrk5ADbqfAJ5jh/1OKHR2Q==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.16.10.tgz", + "integrity": "sha512-6Ei8gETAkcKGEMRW+z8Mak55Y1Jl1TKGQIboC/4vvsrqcvB8zhIvGBS3GaAllxzvF0qjE7ihCPpgXXr6HuTLyg==", "dev": true, "requires": { - "@matter/general": "0.13.0" + "@matter/general": "0.16.10" } }, "@matter/node": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.13.0.tgz", - "integrity": "sha512-HQkzpRnhAUfj9u2ijkT4qgyFzEfaNqyAxh+dgMpLKnbbGQoHTskJ/wEgkRRI7B0Q57mr6VJ5KgXYdbXUYA7xFA==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.16.10.tgz", + "integrity": "sha512-Hb2AxuEf0DlfN8yHxeahZGYurUUu/UDWJkmdvpDKuwSR0eIHhMweOG2RBO55/anyRFObANaUr1gr3DnyocB/0w==", "dev": true, "requires": { - "@matter/general": "0.13.0", - "@matter/model": "0.13.0", - "@matter/protocol": "0.13.0", - "@matter/types": "0.13.0" + "@matter/general": "0.16.10", + "@matter/model": "0.16.10", + "@matter/protocol": "0.16.10", + "@matter/types": "0.16.10" } }, "@matter/nodejs": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.13.0.tgz", - "integrity": "sha512-ThWrLZJo7UH4Ebanf3gxkhe2p8vq4X3PxyRG+ICDRGPfzSAJZ3znh2hD/zdvcdyzQlSoXeLpFbLpTkJu7aRMHg==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.16.10.tgz", + "integrity": "sha512-o8e9tGZVsiqmEtD1osSL9+gRMIuNjtwXt29I3YDRzSqb3N5Dvd4Khj3HN68YhaFFeBlM4YEV2TS0/9VF3C06/w==", "dev": true, "optional": true, "requires": { - "@matter/general": "0.13.0", - "@matter/node": "0.13.0", - "@matter/protocol": "0.13.0", - "@matter/types": "0.13.0" + "@matter/general": "0.16.10", + "@matter/node": "0.16.10", + "@matter/protocol": "0.16.10", + "@matter/types": "0.16.10" } }, "@matter/protocol": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.13.0.tgz", - "integrity": "sha512-wFxU6+LG5ygzruOV5JF30hmLkk88hz8PqAMkQ6ow81ZvoDFBevsW0OyqxXMCNwYd/zmGXmvr3mpC0mi9/troHg==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.16.10.tgz", + "integrity": "sha512-vPQEMl8Wf4vc3tauwsGlLZRrDx+VrCPfccw3n50Lvyucws4UBt+SuBszSyIO2+QPnJcr4MB4L+EgCRI14U4zRA==", "dev": true, "requires": { - "@matter/general": "0.13.0", - "@matter/model": "0.13.0", - "@matter/types": "0.13.0" + "@matter/general": "0.16.10", + "@matter/model": "0.16.10", + "@matter/types": "0.16.10" } }, "@matter/types": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.13.0.tgz", - "integrity": "sha512-8mH7hRC4MBSy4KUs8zb6uDTr0MfRYGtGBFq9t2uedlsiHA5lMUP3L1fitszoeCbpJh4EeqKHWSvF6RxWzKNueA==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.16.10.tgz", + "integrity": "sha512-AW86tGgL3sN1Vb76+03VPdQMsW9vWsaVGHe9agcED1sjtWRJBV17tcLDymAQ8k7fEeVqiy3qf9CUwFK1bhfXKQ==", "dev": true, "requires": { - "@matter/general": "0.13.0", - "@matter/model": "0.13.0" + "@matter/general": "0.16.10", + "@matter/model": "0.16.10" } }, "@microsoft/recognizers-text": { @@ -13824,18 +13824,18 @@ } }, "@noble/curves": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.0.tgz", - "integrity": "sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", "dev": true, "requires": { - "@noble/hashes": "1.8.0" + "@noble/hashes": "2.0.1" } }, "@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", "dev": true }, "@nodelib/fs.scandir": { diff --git a/server/package.json b/server/package.json index 594741446b..b5ad5cc1e0 100644 --- a/server/package.json +++ b/server/package.json @@ -46,7 +46,7 @@ ] }, "devDependencies": { - "@matter/main": "^0.13.0", + "@matter/main": "^0.16.10", "apidoc": "^1.0.3", "chai": "^4.3.7", "chai-as-promised": "^7.1.1", diff --git a/server/services/matter/lib/matter.getNodes.js b/server/services/matter/lib/matter.getNodes.js index 5ec7a37a83..bdbe3c9591 100644 --- a/server/services/matter/lib/matter.getNodes.js +++ b/server/services/matter/lib/matter.getNodes.js @@ -5,7 +5,8 @@ const convertDevice = (device) => { const clusterClients = []; // Each cluster client is a feature of the device - device.clusterClients.forEach((clusterClient, clusterIndex) => { + const allClusterClients = device.getAllClusterClients(); + allClusterClients.forEach((clusterClient) => { clusterClients.push({ id: clusterClient.id.toString(), name: clusterClient.name, @@ -16,7 +17,8 @@ const convertDevice = (device) => { }); // Convert child endpoints (child devices) - const childEndpoints = device.childEndpoints.map((childDeviceEndpoint) => { + const childEndpointsList = device.getChildEndpoints(); + const childEndpoints = childEndpointsList.map((childDeviceEndpoint) => { return convertDevice(childDeviceEndpoint); }); @@ -37,8 +39,8 @@ const convertDevice = (device) => { async function getNodes() { const nodeDetails = this.commissioningController.getCommissionedNodesDetails(); const filteredNodeDetails = nodeDetails.filter((nodeDetail) => { - if (!nodeDetail.deviceData) { - logger.warn(`Matter: Node ${nodeDetail.nodeId} has no device data`); + if (!nodeDetail.deviceData || !nodeDetail.deviceData.basicInformation) { + logger.warn(`Matter: Node ${nodeDetail.nodeId} has no device data or basic information`); return false; } return true; diff --git a/server/services/matter/lib/matter.handleNode.js b/server/services/matter/lib/matter.handleNode.js index 85cc866658..fe63214158 100644 --- a/server/services/matter/lib/matter.handleNode.js +++ b/server/services/matter/lib/matter.handleNode.js @@ -1,6 +1,8 @@ const Promise = require('bluebird'); // eslint-disable-next-line import/no-unresolved const { BridgedDeviceBasicInformation } = require('@matter/main/clusters'); +// eslint-disable-next-line import/no-unresolved +const { NodeStates } = require('@project-chip/matter.js/device'); const logger = require('../../../utils/logger'); const { convertToGladysDevice } = require('../utils/convertToGladysDevice'); @@ -14,6 +16,7 @@ const handleDevice = async ( listenToStateChange, serviceId, devicePath, + deviceInfos, ) => { // Create a completely new object for child informations const childInformations = { @@ -26,40 +29,64 @@ const handleDevice = async ( }; // If we have this cluster, it means we are in a bridge device - const bridgedDeviceBasicInformationClusterClient = device.clusterClients.get( + const bridgedDeviceBasicInformationClusterClient = device.getClusterClientById( BridgedDeviceBasicInformation.Complete.id, ); // We get all attributes for this child endpoint if (bridgedDeviceBasicInformationClusterClient) { if (Object.prototype.hasOwnProperty.call(bridgedDeviceBasicInformationClusterClient.attributes, 'vendorName')) { - const vendorName = await bridgedDeviceBasicInformationClusterClient.attributes.vendorName.get(); - childInformations.vendorName = vendorName; + try { + const vendorName = await bridgedDeviceBasicInformationClusterClient.attributes.vendorName.get(); + childInformations.vendorName = vendorName; + } catch (e) { + logger.warn(`Matter: Unable to read bridged vendorName for node ${nodeId}: ${e.message}`); + } } if (Object.prototype.hasOwnProperty.call(bridgedDeviceBasicInformationClusterClient.attributes, 'nodeLabel')) { - const nodeLabel = await bridgedDeviceBasicInformationClusterClient.attributes.nodeLabel.get(); - childInformations.nodeLabel = nodeLabel; + try { + const nodeLabel = await bridgedDeviceBasicInformationClusterClient.attributes.nodeLabel.get(); + childInformations.nodeLabel = nodeLabel; + } catch (e) { + logger.warn(`Matter: Unable to read bridged nodeLabel for node ${nodeId}: ${e.message}`); + } } if (Object.prototype.hasOwnProperty.call(bridgedDeviceBasicInformationClusterClient.attributes, 'productLabel')) { - const productLabel = await bridgedDeviceBasicInformationClusterClient.attributes.productLabel.get(); - childInformations.productLabel = productLabel; + try { + const productLabel = await bridgedDeviceBasicInformationClusterClient.attributes.productLabel.get(); + childInformations.productLabel = productLabel; + } catch (e) { + logger.warn(`Matter: Unable to read bridged productLabel for node ${nodeId}: ${e.message}`); + } } if (Object.prototype.hasOwnProperty.call(bridgedDeviceBasicInformationClusterClient.attributes, 'productName')) { - const productName = await bridgedDeviceBasicInformationClusterClient.attributes.productName.get(); - childInformations.productName = productName; + try { + const productName = await bridgedDeviceBasicInformationClusterClient.attributes.productName.get(); + childInformations.productName = productName; + } catch (e) { + logger.warn(`Matter: Unable to read bridged productName for node ${nodeId}: ${e.message}`); + } } if (Object.prototype.hasOwnProperty.call(bridgedDeviceBasicInformationClusterClient.attributes, 'uniqueId')) { - const uniqueId = await bridgedDeviceBasicInformationClusterClient.attributes.uniqueId.get(); - childInformations.uniqueId = uniqueId; + try { + const uniqueId = await bridgedDeviceBasicInformationClusterClient.attributes.uniqueId.get(); + childInformations.uniqueId = uniqueId; + } catch (e) { + logger.warn(`Matter: Unable to read bridged uniqueId for node ${nodeId}: ${e.message}`); + } } if (Object.prototype.hasOwnProperty.call(bridgedDeviceBasicInformationClusterClient.attributes, 'serialNumber')) { - const serialNumber = await bridgedDeviceBasicInformationClusterClient.attributes.serialNumber.get(); - childInformations.serialNumber = serialNumber; + try { + const serialNumber = await bridgedDeviceBasicInformationClusterClient.attributes.serialNumber.get(); + childInformations.serialNumber = serialNumber; + } catch (e) { + logger.warn(`Matter: Unable to read bridged serialNumber for node ${nodeId}: ${e.message}`); + } } } @@ -78,12 +105,14 @@ const handleDevice = async ( // If the device has features that Gladys can handle, we add it to Gladys, otherwise we don't add it // to avoid bloating Gladys if (gladysDevice.features.length > 0) { - listenToStateChange(nodeId, newDevicePath, device); + await listenToStateChange(nodeId, newDevicePath, device); devices.push(gladysDevice); + deviceInfos.push({ device, path: newDevicePath }); } - if (device.childEndpoints) { - await Promise.each(device.childEndpoints, async (childDevice, index) => { + const childEndpoints = device.getChildEndpoints(); + if (childEndpoints && childEndpoints.length > 0) { + await Promise.each(childEndpoints, async (childDevice) => { await handleDevice( nodeId, childInformations, @@ -93,6 +122,7 @@ const handleDevice = async ( listenToStateChange, serviceId, `${newDevicePath}:child_endpoint`, + deviceInfos, ); }); } @@ -105,19 +135,36 @@ const handleDevice = async ( * await handleNode(nodeDetail); */ async function handleNode(nodeDetail) { - logger.debug(`Matter: Handling node ${nodeDetail.nodeId}`); + const { nodeId } = nodeDetail; + logger.debug(`Matter: Handling node ${nodeId}`); if (!nodeDetail.deviceData) { - logger.warn(`Matter: Node ${nodeDetail.nodeId} has no device data`); + logger.warn(`Matter: Node ${nodeId} has no device data`); return; } - const node = await this.commissioningController.getNode(nodeDetail.nodeId); - this.nodesMap.set(nodeDetail.nodeId, node); + + const node = await this.commissioningController.getNode(nodeId); + this.nodesMap.set(nodeId, node); + + // Start background connection if not already connected + if (!node.isConnected) { + logger.info(`Matter: Node ${nodeId} not connected, starting connection...`); + node.connect(); + } + + // Wait for local initialization from cached data before reading devices + if (!node.initialized) { + logger.info(`Matter: Node ${nodeId} waiting for local initialization...`); + await node.events.initialized; + } + const devices = node.getDevices(); const boundListenToStateChange = this.listenToStateChange.bind(this); + const deviceInfos = []; + await Promise.each(devices, async (device) => { logger.debug(`Matter: Handling device ${device.number}`); await handleDevice( - nodeDetail.nodeId, + nodeId, nodeDetail.deviceData.basicInformation, node, device, @@ -125,8 +172,23 @@ async function handleNode(nodeDetail) { boundListenToStateChange, this.serviceId, '', + deviceInfos, ); }); + + // When node (re)connects, re-emit current cached state for all known devices + node.events.stateChanged.on(async (nodeState) => { + if (nodeState === NodeStates.Connected) { + logger.info(`Matter: Node ${nodeId} connected, refreshing device states from cache`); + await Promise.each(deviceInfos, async ({ device: d, path }) => { + try { + await boundListenToStateChange(nodeId, path, d); + } catch (e) { + logger.warn(`Matter: Error refreshing state for node ${nodeId}, path ${path}: ${e.message}`); + } + }); + } + }); } module.exports = { diff --git a/server/services/matter/lib/matter.init.js b/server/services/matter/lib/matter.init.js index 0b566e76a0..f244994bbe 100644 --- a/server/services/matter/lib/matter.init.js +++ b/server/services/matter/lib/matter.init.js @@ -45,14 +45,17 @@ async function init() { environment, id: 'matter-controller-data', }, - autoConnect: true, + autoConnect: false, adminFabricLabel: 'Gladys Assistant', - storage: storageService, }); await this.commissioningController.start(); logger.info('Matter controller started'); - await this.refreshDevices(); + // Refresh devices in background to avoid blocking Gladys startup + // Node connections can take a long time if devices are offline + this.refreshDevicesPromise = this.refreshDevices().catch((e) => { + logger.warn(`Matter: Error during background device refresh: ${e.message}`); + }); // Schedule reccurent job if not already scheduled if (!this.backupScheduledJob) { this.backupScheduledJob = this.gladys.scheduler.scheduleJob('0 0 4 * * *', () => this.backupController()); diff --git a/server/services/matter/lib/matter.listenToStateChange.js b/server/services/matter/lib/matter.listenToStateChange.js index 30318e989d..f1da712c42 100644 --- a/server/services/matter/lib/matter.listenToStateChange.js +++ b/server/services/matter/lib/matter.listenToStateChange.js @@ -29,311 +29,511 @@ const { EVENTS, STATE } = require('../../../utils/constants'); * @example matter.listenToStateChange(nodeId, device); */ async function listenToStateChange(nodeId, devicePath, device) { - // Get the OnOff cluster from clusterClients map - const onOff = device.clusterClients.get(OnOff.Complete.id); - - // We only add the listener if it's not already added - if (onOff && !this.stateChangeListeners.has(onOff)) { - logger.debug(`Matter: Adding state change listener for OnOff cluster ${onOff.name}`); - this.stateChangeListeners.add(onOff); - // Subscribe to OnOff attribute changes - onOff.addOnOffAttributeListener((value) => { - logger.debug(`Matter: OnOff attribute changed to ${value}`); - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${OnOff.Complete.id}`, - state: value ? STATE.ON : STATE.OFF, + // OnOff + const onOff = device.getClusterClientById(OnOff.Complete.id); + if (onOff) { + try { + const cached = await onOff.attributes.onOff.get(false); + if (cached !== undefined && cached !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${OnOff.Complete.id}`, + state: cached ? STATE.ON : STATE.OFF, + }); + } + } catch (e) { + logger.debug(`Matter: No cached OnOff state for ${devicePath}`); + } + if (!this.stateChangeListeners.has(onOff)) { + logger.debug(`Matter: Adding state change listener for OnOff cluster ${onOff.name}`); + this.stateChangeListeners.add(onOff); + onOff.addOnOffAttributeListener((value) => { + logger.debug(`Matter: OnOff attribute changed to ${value}`); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${OnOff.Complete.id}`, + state: value ? STATE.ON : STATE.OFF, + }); }); - }); + } } - const occupancy = device.clusterClients.get(OccupancySensing.Complete.id); - if (occupancy && !this.stateChangeListeners.has(occupancy)) { - logger.debug(`Matter: Adding state change listener for OccupancySensing cluster ${occupancy.name}`); - this.stateChangeListeners.add(occupancy); - // Subscribe to OccupancySensing attribute changes - occupancy.addOccupancyAttributeListener((value) => { - logger.debug(`Matter: Occupancy attribute changed to ${value}`); - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${OccupancySensing.Complete.id}`, - state: value.occupied ? STATE.ON : STATE.OFF, + // OccupancySensing + const occupancy = device.getClusterClientById(OccupancySensing.Complete.id); + if (occupancy) { + try { + const cached = await occupancy.attributes.occupancy.get(false); + if (cached !== undefined && cached !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${OccupancySensing.Complete.id}`, + state: cached.occupied ? STATE.ON : STATE.OFF, + }); + } + } catch (e) { + logger.debug(`Matter: No cached OccupancySensing state for ${devicePath}`); + } + if (!this.stateChangeListeners.has(occupancy)) { + logger.debug(`Matter: Adding state change listener for OccupancySensing cluster ${occupancy.name}`); + this.stateChangeListeners.add(occupancy); + occupancy.addOccupancyAttributeListener((value) => { + logger.debug(`Matter: Occupancy attribute changed to ${value}`); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${OccupancySensing.Complete.id}`, + state: value.occupied ? STATE.ON : STATE.OFF, + }); }); - }); + } } - const illuminance = device.clusterClients.get(IlluminanceMeasurement.Complete.id); - if (illuminance && !this.stateChangeListeners.has(illuminance)) { - logger.debug(`Matter: Adding state change listener for IlluminanceMeasurement cluster ${illuminance.name}`); - this.stateChangeListeners.add(illuminance); - // Subscribe to IlluminanceMeasurement attribute changes - illuminance.addMeasuredValueAttributeListener((value) => { - const luxValue = Math.round(10 ** ((value - 1) / 10000)); - logger.debug(`Matter: Illuminance attribute changed to ${value} (Converted to ${luxValue} lux)`); - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${IlluminanceMeasurement.Complete.id}`, - state: luxValue, + // IlluminanceMeasurement + const illuminance = device.getClusterClientById(IlluminanceMeasurement.Complete.id); + if (illuminance) { + try { + const cached = await illuminance.attributes.measuredValue.get(false); + if (cached !== undefined && cached !== null) { + const luxValue = Math.round(10 ** ((cached - 1) / 10000)); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${IlluminanceMeasurement.Complete.id}`, + state: luxValue, + }); + } + } catch (e) { + logger.debug(`Matter: No cached IlluminanceMeasurement state for ${devicePath}`); + } + if (!this.stateChangeListeners.has(illuminance)) { + logger.debug(`Matter: Adding state change listener for IlluminanceMeasurement cluster ${illuminance.name}`); + this.stateChangeListeners.add(illuminance); + illuminance.addMeasuredValueAttributeListener((value) => { + const luxValue = Math.round(10 ** ((value - 1) / 10000)); + logger.debug(`Matter: Illuminance attribute changed to ${value} (Converted to ${luxValue} lux)`); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${IlluminanceMeasurement.Complete.id}`, + state: luxValue, + }); }); - }); + } } - const temperatureSensor = device.clusterClients.get(TemperatureMeasurement.Complete.id); - if (temperatureSensor && !this.stateChangeListeners.has(temperatureSensor)) { - logger.debug(`Matter: Adding state change listener for TemperatureMeasurement cluster ${temperatureSensor.name}`); - this.stateChangeListeners.add(temperatureSensor); - // Subscribe to TemperatureMeasurement attribute changes - temperatureSensor.addMeasuredValueAttributeListener((value) => { - logger.debug(`Matter: Temperature attribute changed to ${value}`); - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${TemperatureMeasurement.Complete.id}`, - state: value / 100, + // TemperatureMeasurement + const temperatureSensor = device.getClusterClientById(TemperatureMeasurement.Complete.id); + if (temperatureSensor) { + try { + const cached = await temperatureSensor.attributes.measuredValue.get(false); + if (cached !== undefined && cached !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${TemperatureMeasurement.Complete.id}`, + state: cached / 100, + }); + } + } catch (e) { + logger.debug(`Matter: No cached TemperatureMeasurement state for ${devicePath}`); + } + if (!this.stateChangeListeners.has(temperatureSensor)) { + logger.debug(`Matter: Adding state change listener for TemperatureMeasurement cluster ${temperatureSensor.name}`); + this.stateChangeListeners.add(temperatureSensor); + temperatureSensor.addMeasuredValueAttributeListener((value) => { + logger.debug(`Matter: Temperature attribute changed to ${value}`); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${TemperatureMeasurement.Complete.id}`, + state: value / 100, + }); }); - }); + } } - const windowCover = device.clusterClients.get(WindowCovering.Complete.id); - - if (windowCover && !this.stateChangeListeners.has(windowCover)) { - logger.debug(`Matter: Adding state change listener for WindowCovering cluster ${windowCover.name}`); - this.stateChangeListeners.add(windowCover); - // Subscribe to change in position - windowCover.addCurrentPositionLiftPercent100thsAttributeListener((value) => { - logger.debug(`Matter: WindowCovering attribute changed to ${value}`); - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${WindowCovering.Complete.id}:position`, - state: value / 100, + // WindowCovering + const windowCover = device.getClusterClientById(WindowCovering.Complete.id); + if (windowCover) { + try { + const cached = await windowCover.attributes.currentPositionLiftPercent100ths.get(false); + if (cached !== undefined && cached !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${WindowCovering.Complete.id}:position`, + state: cached / 100, + }); + } + } catch (e) { + logger.debug(`Matter: No cached WindowCovering state for ${devicePath}`); + } + if (!this.stateChangeListeners.has(windowCover)) { + logger.debug(`Matter: Adding state change listener for WindowCovering cluster ${windowCover.name}`); + this.stateChangeListeners.add(windowCover); + windowCover.addCurrentPositionLiftPercent100thsAttributeListener((value) => { + logger.debug(`Matter: WindowCovering attribute changed to ${value}`); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${WindowCovering.Complete.id}:position`, + state: value / 100, + }); }); - }); + } } - const levelControl = device.clusterClients.get(LevelControl.Complete.id); - if (levelControl && !this.stateChangeListeners.has(levelControl)) { - logger.debug(`Matter: Adding state change listener for LevelControl cluster ${levelControl.name}`); - this.stateChangeListeners.add(levelControl); - // Subscribe to change in brightness - levelControl.addCurrentLevelAttributeListener((value) => { - logger.debug(`Matter: LevelControl attribute changed to ${value}`); - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${LevelControl.Complete.id}`, - state: value, + // LevelControl + const levelControl = device.getClusterClientById(LevelControl.Complete.id); + if (levelControl) { + try { + const cached = await levelControl.attributes.currentLevel.get(false); + if (cached !== undefined && cached !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${LevelControl.Complete.id}`, + state: cached, + }); + } + } catch (e) { + logger.debug(`Matter: No cached LevelControl state for ${devicePath}`); + } + if (!this.stateChangeListeners.has(levelControl)) { + logger.debug(`Matter: Adding state change listener for LevelControl cluster ${levelControl.name}`); + this.stateChangeListeners.add(levelControl); + levelControl.addCurrentLevelAttributeListener((value) => { + logger.debug(`Matter: LevelControl attribute changed to ${value}`); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${LevelControl.Complete.id}`, + state: value, + }); }); - }); + } } - const colorControl = device.clusterClients.get(ColorControl.Complete.id); - if (colorControl && !this.stateChangeListeners.has(colorControl)) { - logger.debug(`Matter: Adding state change listener for ColorControl cluster ${colorControl.name}`); - this.stateChangeListeners.add(colorControl); - // Function to convert HSB to integer and emit state change + // ColorControl + const colorControl = device.getClusterClientById(ColorControl.Complete.id); + if (colorControl) { const emitColorState = async () => { - logger.debug(`Matter: Emitting color state`); - // Get current hue and saturation values - const currentHue = await colorControl.getCurrentHueAttribute(); - const currentSaturation = await colorControl.getCurrentSaturationAttribute(); - const currentBrightness = 100; // Default to full brightness - - // Convert HSB to RGB - // Matter uses hue in range 0-254, saturation in range 0-254 - // Our hsbToRgb expects hue in degrees (0-360) and saturation in percent (0-100) - const hue = Math.round((currentHue / 254) * 360); - const saturation = Math.round((currentSaturation / 254) * 100); - - // Convert HSB to RGB - const rgb = hsbToRgb([hue, saturation, currentBrightness]); - - // Convert RGB to integer - const intColor = rgbToInt(rgb); - - // Emit the state change event with the integer color - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${ColorControl.Complete.id}:color`, - state: intColor, - }); + try { + const currentHue = await colorControl.attributes.currentHue.get(false); + const currentSaturation = await colorControl.attributes.currentSaturation.get(false); + if ( + currentHue === undefined || + currentHue === null || + currentSaturation === undefined || + currentSaturation === null + ) { + return; + } + logger.debug(`Matter: Emitting color state hue=${currentHue} sat=${currentSaturation}`); + const hue = Math.round((currentHue / 254) * 360); + const saturation = Math.round((currentSaturation / 254) * 100); + const rgb = hsbToRgb([hue, saturation, 100]); + const intColor = rgbToInt(rgb); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${ColorControl.Complete.id}:color`, + state: intColor, + }); + } catch (e) { + logger.debug(`Matter: No cached ColorControl state for ${devicePath}`); + } }; - - if (colorControl.supportedFeatures.hueSaturation) { - // Listen for hue changes - colorControl.addCurrentHueAttributeListener(() => { - emitColorState(); - }); - - // Listen for saturation changes - colorControl.addCurrentSaturationAttributeListener(() => { - emitColorState(); - }); + await emitColorState(); + if (!this.stateChangeListeners.has(colorControl)) { + logger.debug(`Matter: Adding state change listener for ColorControl cluster ${colorControl.name}`); + this.stateChangeListeners.add(colorControl); + if (colorControl.supportedFeatures.hueSaturation) { + colorControl.addCurrentHueAttributeListener(() => { + emitColorState(); + }); + colorControl.addCurrentSaturationAttributeListener(() => { + emitColorState(); + }); + } } } - const relativeHumidityMeasurement = device.clusterClients.get(RelativeHumidityMeasurement.Complete.id); - if (relativeHumidityMeasurement && !this.stateChangeListeners.has(relativeHumidityMeasurement)) { - logger.debug( - `Matter: Adding state change listener for RelativeHumidityMeasurement cluster ${relativeHumidityMeasurement.name}`, - ); - this.stateChangeListeners.add(relativeHumidityMeasurement); - // Subscribe to RelativeHumidityMeasurement attribute changes - relativeHumidityMeasurement.addMeasuredValueAttributeListener((value) => { - logger.debug(`Matter: RelativeHumidityMeasurement attribute changed to ${value}`); - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${RelativeHumidityMeasurement.Complete.id}`, - state: value / 100, + // RelativeHumidityMeasurement + const relativeHumidityMeasurement = device.getClusterClientById(RelativeHumidityMeasurement.Complete.id); + if (relativeHumidityMeasurement) { + try { + const cached = await relativeHumidityMeasurement.attributes.measuredValue.get(false); + if (cached !== undefined && cached !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${RelativeHumidityMeasurement.Complete.id}`, + state: cached / 100, + }); + } + } catch (e) { + logger.debug(`Matter: No cached RelativeHumidityMeasurement state for ${devicePath}`); + } + if (!this.stateChangeListeners.has(relativeHumidityMeasurement)) { + logger.debug( + `Matter: Adding state change listener for RelativeHumidityMeasurement cluster ${relativeHumidityMeasurement.name}`, + ); + this.stateChangeListeners.add(relativeHumidityMeasurement); + relativeHumidityMeasurement.addMeasuredValueAttributeListener((value) => { + logger.debug(`Matter: RelativeHumidityMeasurement attribute changed to ${value}`); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${RelativeHumidityMeasurement.Complete.id}`, + state: value / 100, + }); }); - }); + } } - const pm25ConcentrationMeasurement = device.clusterClients.get(Pm25ConcentrationMeasurement.Complete.id); - if (pm25ConcentrationMeasurement && !this.stateChangeListeners.has(pm25ConcentrationMeasurement)) { - logger.debug( - `Matter: Adding state change listener for Pm25ConcentrationMeasurement cluster ${pm25ConcentrationMeasurement.name}`, - ); - this.stateChangeListeners.add(pm25ConcentrationMeasurement); - // Subscribe to Pm25ConcentrationMeasurement attribute changes - pm25ConcentrationMeasurement.addMeasuredValueAttributeListener((value) => { - logger.debug(`Matter: Pm25ConcentrationMeasurement attribute changed to ${value}`); - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${Pm25ConcentrationMeasurement.Complete.id}`, - state: value, + // Pm25ConcentrationMeasurement + const pm25ConcentrationMeasurement = device.getClusterClientById(Pm25ConcentrationMeasurement.Complete.id); + if (pm25ConcentrationMeasurement) { + try { + const cached = await pm25ConcentrationMeasurement.attributes.measuredValue.get(false); + if (cached !== undefined && cached !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${Pm25ConcentrationMeasurement.Complete.id}`, + state: cached, + }); + } + } catch (e) { + logger.debug(`Matter: No cached Pm25ConcentrationMeasurement state for ${devicePath}`); + } + if (!this.stateChangeListeners.has(pm25ConcentrationMeasurement)) { + logger.debug( + `Matter: Adding state change listener for Pm25ConcentrationMeasurement cluster ${pm25ConcentrationMeasurement.name}`, + ); + this.stateChangeListeners.add(pm25ConcentrationMeasurement); + pm25ConcentrationMeasurement.addMeasuredValueAttributeListener((value) => { + logger.debug(`Matter: Pm25ConcentrationMeasurement attribute changed to ${value}`); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${Pm25ConcentrationMeasurement.Complete.id}`, + state: value, + }); }); - }); + } } - const pm10ConcentrationMeasurement = device.clusterClients.get(Pm10ConcentrationMeasurement.Complete.id); - if (pm10ConcentrationMeasurement && !this.stateChangeListeners.has(pm10ConcentrationMeasurement)) { - logger.debug( - `Matter: Adding state change listener for Pm10ConcentrationMeasurement cluster ${pm10ConcentrationMeasurement.name}`, - ); - this.stateChangeListeners.add(pm10ConcentrationMeasurement); - // Subscribe to Pm10ConcentrationMeasurement attribute changes - pm10ConcentrationMeasurement.addMeasuredValueAttributeListener((value) => { - logger.debug(`Matter: Pm10ConcentrationMeasurement attribute changed to ${value}`); - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${Pm10ConcentrationMeasurement.Complete.id}`, - state: value, + // Pm10ConcentrationMeasurement + const pm10ConcentrationMeasurement = device.getClusterClientById(Pm10ConcentrationMeasurement.Complete.id); + if (pm10ConcentrationMeasurement) { + try { + const cached = await pm10ConcentrationMeasurement.attributes.measuredValue.get(false); + if (cached !== undefined && cached !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${Pm10ConcentrationMeasurement.Complete.id}`, + state: cached, + }); + } + } catch (e) { + logger.debug(`Matter: No cached Pm10ConcentrationMeasurement state for ${devicePath}`); + } + if (!this.stateChangeListeners.has(pm10ConcentrationMeasurement)) { + logger.debug( + `Matter: Adding state change listener for Pm10ConcentrationMeasurement cluster ${pm10ConcentrationMeasurement.name}`, + ); + this.stateChangeListeners.add(pm10ConcentrationMeasurement); + pm10ConcentrationMeasurement.addMeasuredValueAttributeListener((value) => { + logger.debug(`Matter: Pm10ConcentrationMeasurement attribute changed to ${value}`); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${Pm10ConcentrationMeasurement.Complete.id}`, + state: value, + }); }); - }); + } } - const totalVolatileOrganicCompoundsConcentrationMeasurement = device.clusterClients.get( + // TotalVolatileOrganicCompoundsConcentrationMeasurement + const totalVolatileOrganicCompoundsConcentrationMeasurement = device.getClusterClientById( TotalVolatileOrganicCompoundsConcentrationMeasurement.Complete.id, ); - if ( - totalVolatileOrganicCompoundsConcentrationMeasurement && - !this.stateChangeListeners.has(totalVolatileOrganicCompoundsConcentrationMeasurement) - ) { - logger.debug( - `Matter: Adding state change listener for totalVolatileOrganicCompoundsConcentrationMeasurement cluster ${totalVolatileOrganicCompoundsConcentrationMeasurement.name}`, - ); - this.stateChangeListeners.add(totalVolatileOrganicCompoundsConcentrationMeasurement); - // Subscribe to TotalVolatileOrganicCompoundsConcentrationMeasurement attribute changes - totalVolatileOrganicCompoundsConcentrationMeasurement.addLevelValueAttributeListener((value) => { - logger.debug(`Matter: TotalVolatileOrganicCompoundsConcentrationMeasurement attribute changed to ${value}`); - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${TotalVolatileOrganicCompoundsConcentrationMeasurement.Complete.id}`, - state: value, + if (totalVolatileOrganicCompoundsConcentrationMeasurement) { + try { + const cached = await totalVolatileOrganicCompoundsConcentrationMeasurement.attributes.levelValue.get(false); + if (cached !== undefined && cached !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${TotalVolatileOrganicCompoundsConcentrationMeasurement.Complete.id}`, + state: cached, + }); + } + } catch (e) { + logger.debug(`Matter: No cached TVOC state for ${devicePath}`); + } + if (!this.stateChangeListeners.has(totalVolatileOrganicCompoundsConcentrationMeasurement)) { + logger.debug( + `Matter: Adding state change listener for totalVolatileOrganicCompoundsConcentrationMeasurement cluster ${totalVolatileOrganicCompoundsConcentrationMeasurement.name}`, + ); + this.stateChangeListeners.add(totalVolatileOrganicCompoundsConcentrationMeasurement); + totalVolatileOrganicCompoundsConcentrationMeasurement.addLevelValueAttributeListener((value) => { + logger.debug(`Matter: TotalVolatileOrganicCompoundsConcentrationMeasurement attribute changed to ${value}`); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${TotalVolatileOrganicCompoundsConcentrationMeasurement.Complete.id}`, + state: value, + }); }); - }); + } } - const formaldehydeConcentrationMeasurement = device.clusterClients.get( + // FormaldehydeConcentrationMeasurement + const formaldehydeConcentrationMeasurement = device.getClusterClientById( FormaldehydeConcentrationMeasurement.Complete.id, ); - if (formaldehydeConcentrationMeasurement && !this.stateChangeListeners.has(formaldehydeConcentrationMeasurement)) { - logger.debug( - `Matter: Adding state change listener for FormaldehydeConcentrationMeasurement cluster ${formaldehydeConcentrationMeasurement.name}`, - ); - this.stateChangeListeners.add(formaldehydeConcentrationMeasurement); - // Subscribe to FormaldehydeConcentrationMeasurement attribute changes - formaldehydeConcentrationMeasurement.addMeasuredValueAttributeListener((value) => { - logger.debug(`Matter: FormaldehydeConcentrationMeasurement attribute changed to ${value}`); - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${FormaldehydeConcentrationMeasurement.Complete.id}`, - state: value, - }); - }); - } - - const thermostat = device.clusterClients.get(Thermostat.Complete.id); - if (thermostat && !this.stateChangeListeners.has(thermostat)) { - logger.debug(`Matter: Adding state change listener for Thermostat cluster ${thermostat.name}`); - this.stateChangeListeners.add(thermostat); - // Subscribe to thermostat attribute changes - if (thermostat.supportedFeatures.heating) { - thermostat.addOccupiedHeatingSetpointAttributeListener((value) => { - logger.debug(`Matter: Thermostat heating attribute changed to ${value}`); + if (formaldehydeConcentrationMeasurement) { + try { + const cached = await formaldehydeConcentrationMeasurement.attributes.measuredValue.get(false); + if (cached !== undefined && cached !== null) { this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${Thermostat.Complete.id}:heating`, - state: value / 100, + device_feature_external_id: `matter:${nodeId}:${devicePath}:${FormaldehydeConcentrationMeasurement.Complete.id}`, + state: cached, }); - }); + } + } catch (e) { + logger.debug(`Matter: No cached FormaldehydeConcentrationMeasurement state for ${devicePath}`); } - if (thermostat.supportedFeatures.cooling) { - thermostat.addOccupiedCoolingSetpointAttributeListener((value) => { - logger.debug(`Matter: Thermostat cooling attribute changed to ${value}`); + if (!this.stateChangeListeners.has(formaldehydeConcentrationMeasurement)) { + logger.debug( + `Matter: Adding state change listener for FormaldehydeConcentrationMeasurement cluster ${formaldehydeConcentrationMeasurement.name}`, + ); + this.stateChangeListeners.add(formaldehydeConcentrationMeasurement); + formaldehydeConcentrationMeasurement.addMeasuredValueAttributeListener((value) => { + logger.debug(`Matter: FormaldehydeConcentrationMeasurement attribute changed to ${value}`); this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${Thermostat.Complete.id}:cooling`, - state: value / 100, + device_feature_external_id: `matter:${nodeId}:${devicePath}:${FormaldehydeConcentrationMeasurement.Complete.id}`, + state: value, }); }); } } - const electricalPowerMeasurement = device.clusterClients.get(ElectricalPowerMeasurement.Complete.id); - if (electricalPowerMeasurement && !this.stateChangeListeners.has(electricalPowerMeasurement)) { - logger.debug( - `Matter: Adding state change listener for ElectricalPowerMeasurement cluster ${electricalPowerMeasurement.name}`, - ); - this.stateChangeListeners.add(electricalPowerMeasurement); - // Subscribe to ActivePower attribute changes - electricalPowerMeasurement.addActivePowerAttributeListener((value) => { - logger.debug(`Matter: ElectricalPowerMeasurement ActivePower attribute changed to ${value}`); - // Value is in milliwatts, convert to watts - const powerInWatts = value !== null ? value / 1000 : null; - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${ElectricalPowerMeasurement.Complete.id}:power`, - state: powerInWatts, - }); - }); - // Subscribe to Voltage attribute changes if available - if (electricalPowerMeasurement.addVoltageAttributeListener) { - electricalPowerMeasurement.addVoltageAttributeListener((value) => { - logger.debug(`Matter: ElectricalPowerMeasurement Voltage attribute changed to ${value}`); - // Value is in millivolts, convert to volts - const voltageInVolts = value !== null ? value / 1000 : null; + // Thermostat + const thermostat = device.getClusterClientById(Thermostat.Complete.id); + if (thermostat) { + try { + if (thermostat.supportedFeatures.heating) { + const cached = await thermostat.attributes.occupiedHeatingSetpoint.get(false); + if (cached !== undefined && cached !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${Thermostat.Complete.id}:heating`, + state: cached / 100, + }); + } + } + if (thermostat.supportedFeatures.cooling) { + const cached = await thermostat.attributes.occupiedCoolingSetpoint.get(false); + if (cached !== undefined && cached !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${Thermostat.Complete.id}:cooling`, + state: cached / 100, + }); + } + } + } catch (e) { + logger.debug(`Matter: No cached Thermostat state for ${devicePath}`); + } + if (!this.stateChangeListeners.has(thermostat)) { + logger.debug(`Matter: Adding state change listener for Thermostat cluster ${thermostat.name}`); + this.stateChangeListeners.add(thermostat); + if (thermostat.supportedFeatures.heating) { + thermostat.addOccupiedHeatingSetpointAttributeListener((value) => { + logger.debug(`Matter: Thermostat heating attribute changed to ${value}`); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${Thermostat.Complete.id}:heating`, + state: value / 100, + }); + }); + } + if (thermostat.supportedFeatures.cooling) { + thermostat.addOccupiedCoolingSetpointAttributeListener((value) => { + logger.debug(`Matter: Thermostat cooling attribute changed to ${value}`); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${Thermostat.Complete.id}:cooling`, + state: value / 100, + }); + }); + } + } + } + + // ElectricalPowerMeasurement + const electricalPowerMeasurement = device.getClusterClientById(ElectricalPowerMeasurement.Complete.id); + if (electricalPowerMeasurement) { + try { + const cachedPower = await electricalPowerMeasurement.attributes.activePower.get(false); + if (cachedPower !== undefined && cachedPower !== null) { this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${ElectricalPowerMeasurement.Complete.id}:voltage`, - state: voltageInVolts, + device_feature_external_id: `matter:${nodeId}:${devicePath}:${ElectricalPowerMeasurement.Complete.id}:power`, + state: cachedPower / 1000, }); - }); + } + if (electricalPowerMeasurement.attributes.voltage) { + const cachedVoltage = await electricalPowerMeasurement.attributes.voltage.get(false); + if (cachedVoltage !== undefined && cachedVoltage !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${ElectricalPowerMeasurement.Complete.id}:voltage`, + state: cachedVoltage / 1000, + }); + } + } + if (electricalPowerMeasurement.attributes.activeCurrent) { + const cachedCurrent = await electricalPowerMeasurement.attributes.activeCurrent.get(false); + if (cachedCurrent !== undefined && cachedCurrent !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${ElectricalPowerMeasurement.Complete.id}:current`, + state: cachedCurrent / 1000, + }); + } + } + } catch (e) { + logger.debug(`Matter: No cached ElectricalPowerMeasurement state for ${devicePath}`); } - // Subscribe to ActiveCurrent attribute changes if available - if (electricalPowerMeasurement.addActiveCurrentAttributeListener) { - electricalPowerMeasurement.addActiveCurrentAttributeListener((value) => { - logger.debug(`Matter: ElectricalPowerMeasurement ActiveCurrent attribute changed to ${value}`); - // Value is in milliamps, convert to amps - const currentInAmps = value !== null ? value / 1000 : null; + if (!this.stateChangeListeners.has(electricalPowerMeasurement)) { + logger.debug( + `Matter: Adding state change listener for ElectricalPowerMeasurement cluster ${electricalPowerMeasurement.name}`, + ); + this.stateChangeListeners.add(electricalPowerMeasurement); + electricalPowerMeasurement.addActivePowerAttributeListener((value) => { + logger.debug(`Matter: ElectricalPowerMeasurement ActivePower attribute changed to ${value}`); + const powerInWatts = value !== null ? value / 1000 : null; this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${ElectricalPowerMeasurement.Complete.id}:current`, - state: currentInAmps, + device_feature_external_id: `matter:${nodeId}:${devicePath}:${ElectricalPowerMeasurement.Complete.id}:power`, + state: powerInWatts, }); }); + if (electricalPowerMeasurement.addVoltageAttributeListener) { + electricalPowerMeasurement.addVoltageAttributeListener((value) => { + logger.debug(`Matter: ElectricalPowerMeasurement Voltage attribute changed to ${value}`); + const voltageInVolts = value !== null ? value / 1000 : null; + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${ElectricalPowerMeasurement.Complete.id}:voltage`, + state: voltageInVolts, + }); + }); + } + if (electricalPowerMeasurement.addActiveCurrentAttributeListener) { + electricalPowerMeasurement.addActiveCurrentAttributeListener((value) => { + logger.debug(`Matter: ElectricalPowerMeasurement ActiveCurrent attribute changed to ${value}`); + const currentInAmps = value !== null ? value / 1000 : null; + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${ElectricalPowerMeasurement.Complete.id}:current`, + state: currentInAmps, + }); + }); + } } } - const electricalEnergyMeasurement = device.clusterClients.get(ElectricalEnergyMeasurement.Complete.id); - if (electricalEnergyMeasurement && !this.stateChangeListeners.has(electricalEnergyMeasurement)) { - logger.debug( - `Matter: Adding state change listener for ElectricalEnergyMeasurement cluster ${electricalEnergyMeasurement.name}`, - ); - this.stateChangeListeners.add(electricalEnergyMeasurement); - // Subscribe to CumulativeEnergyImported attribute changes if CumulativeEnergy feature is supported - if (electricalEnergyMeasurement.addCumulativeEnergyImportedAttributeListener) { - electricalEnergyMeasurement.addCumulativeEnergyImportedAttributeListener((value) => { - logger.debug(`Matter: ElectricalEnergyMeasurement CumulativeEnergyImported attribute changed to`, value); - // Value is an object with energy field in milliwatt-hours, convert to kilowatt-hours - const energyInKwh = value && value.energy !== null ? value.energy / 1000000 : null; - const externalId = `matter:${nodeId}:${devicePath}:${ElectricalEnergyMeasurement.Complete.id}:energy`; - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: externalId, - state: energyInKwh, + // ElectricalEnergyMeasurement + const electricalEnergyMeasurement = device.getClusterClientById(ElectricalEnergyMeasurement.Complete.id); + if (electricalEnergyMeasurement) { + try { + if (electricalEnergyMeasurement.attributes.cumulativeEnergyImported) { + const cached = await electricalEnergyMeasurement.attributes.cumulativeEnergyImported.get(false); + if (cached !== undefined && cached !== null && cached.energy !== null) { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${ElectricalEnergyMeasurement.Complete.id}:energy`, + state: cached.energy / 1000000, + }); + } + } + } catch (e) { + logger.debug(`Matter: No cached ElectricalEnergyMeasurement state for ${devicePath}`); + } + if (!this.stateChangeListeners.has(electricalEnergyMeasurement)) { + logger.debug( + `Matter: Adding state change listener for ElectricalEnergyMeasurement cluster ${electricalEnergyMeasurement.name}`, + ); + this.stateChangeListeners.add(electricalEnergyMeasurement); + if (electricalEnergyMeasurement.addCumulativeEnergyImportedAttributeListener) { + electricalEnergyMeasurement.addCumulativeEnergyImportedAttributeListener((value) => { + logger.debug(`Matter: ElectricalEnergyMeasurement CumulativeEnergyImported attribute changed to`, value); + const energyInKwh = value && value.energy !== null ? value.energy / 1000000 : null; + const externalId = `matter:${nodeId}:${devicePath}:${ElectricalEnergyMeasurement.Complete.id}:energy`; + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: externalId, + state: energyInKwh, + }); }); - }); + } } } } diff --git a/server/services/matter/lib/matter.setValue.js b/server/services/matter/lib/matter.setValue.js index 7e74b8de79..f54c98fdfa 100644 --- a/server/services/matter/lib/matter.setValue.js +++ b/server/services/matter/lib/matter.setValue.js @@ -27,8 +27,9 @@ function findDeviceRecursively(parentDevice, path) { const deviceNumber = Number(currentNumber); // Look in child endpoints - if (parentDevice.childEndpoints) { - const childDevice = parentDevice.childEndpoints.find((child) => child.number === deviceNumber); + const childEndpoints = parentDevice.getChildEndpoints(); + if (childEndpoints && childEndpoints.length > 0) { + const childDevice = childEndpoints.find((child) => child.number === deviceNumber); if (childDevice) { return findDeviceRecursively(childDevice, remainingPath); } @@ -79,13 +80,13 @@ async function setValue(gladysDevice, gladysFeature, value) { if (!node.isConnected) { logger.warn(`Matter: Node ${nodeId} is not connected, connecting...`); node.connect(); - await node.events.initialized; + await node.events.initializedFromRemote; logger.info(`Matter: Node ${nodeId} connected.`); } // Handle binary device if (gladysFeature.type === DEVICE_FEATURE_TYPES.SWITCH.BINARY) { - const onOff = targetDevice.clusterClients.get(OnOff.Complete.id); + const onOff = targetDevice.getClusterClientById(OnOff.Complete.id); if (!onOff) { throw new Error('Device does not support OnOff cluster'); @@ -101,7 +102,7 @@ async function setValue(gladysDevice, gladysFeature, value) { // Handle shutters if (gladysFeature.category === DEVICE_FEATURE_CATEGORIES.SHUTTER) { - const windowCovering = targetDevice.clusterClients.get(WindowCovering.Complete.id); + const windowCovering = targetDevice.getClusterClientById(WindowCovering.Complete.id); // Handle device feature shutter position if (gladysFeature.type === DEVICE_FEATURE_TYPES.SHUTTER.POSITION) { await windowCovering.goToLiftPercentage({ @@ -125,8 +126,8 @@ async function setValue(gladysDevice, gladysFeature, value) { gladysFeature.category === DEVICE_FEATURE_CATEGORIES.LIGHT && gladysFeature.type === DEVICE_FEATURE_TYPES.LIGHT.BRIGHTNESS ) { - const levelControl = targetDevice.clusterClients.get(LevelControl.Complete.id); - const onOff = targetDevice.clusterClients.get(OnOff.Complete.id); + const levelControl = targetDevice.getClusterClientById(LevelControl.Complete.id); + const onOff = targetDevice.getClusterClientById(OnOff.Complete.id); await levelControl.moveToLevel({ level: value, transitionTime: null, @@ -147,8 +148,8 @@ async function setValue(gladysDevice, gladysFeature, value) { gladysFeature.category === DEVICE_FEATURE_CATEGORIES.LIGHT && gladysFeature.type === DEVICE_FEATURE_TYPES.LIGHT.COLOR ) { - const colorControl = targetDevice.clusterClients.get(ColorControl.Complete.id); - const onOff = targetDevice.clusterClients.get(OnOff.Complete.id); + const colorControl = targetDevice.getClusterClientById(ColorControl.Complete.id); + const onOff = targetDevice.getClusterClientById(OnOff.Complete.id); const [hue, saturation] = intToHsb(value); // Convert from standard HSB ranges to Matter ranges @@ -175,7 +176,7 @@ async function setValue(gladysDevice, gladysFeature, value) { gladysFeature.category === DEVICE_FEATURE_CATEGORIES.THERMOSTAT && gladysFeature.type === DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE ) { - const thermostat = targetDevice.clusterClients.get(Thermostat.Complete.id); + const thermostat = targetDevice.getClusterClientById(Thermostat.Complete.id); await thermostat.setOccupiedHeatingSetpointAttribute(value * 100); } @@ -184,7 +185,7 @@ async function setValue(gladysDevice, gladysFeature, value) { gladysFeature.category === DEVICE_FEATURE_CATEGORIES.AIR_CONDITIONING && gladysFeature.type === DEVICE_FEATURE_TYPES.AIR_CONDITIONING.TARGET_TEMPERATURE ) { - const thermostat = targetDevice.clusterClients.get(Thermostat.Complete.id); + const thermostat = targetDevice.getClusterClientById(Thermostat.Complete.id); await thermostat.setOccupiedCoolingSetpointAttribute(value * 100); } } diff --git a/server/services/matter/package-lock.json b/server/services/matter/package-lock.json index 328c3bd4e0..210445bb14 100644 --- a/server/services/matter/package-lock.json +++ b/server/services/matter/package-lock.json @@ -16,133 +16,133 @@ "win32" ], "dependencies": { - "@matter/main": "^0.13.0", - "@project-chip/matter.js": "^0.13.0", + "@matter/main": "^0.16.10", + "@project-chip/matter.js": "^0.16.10", "bluebird": "^3.7.2", "fs-extra": "^11.3.0" } }, "node_modules/@matter/general": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.13.0.tgz", - "integrity": "sha512-PZ+FVJotKWgtoBvorqN+PLCuTBBbTCJTCss2P5C6n9r/ZAIcCgW7LDTFmRXNNNMJEri8JUVR0REvHaa92zVj2Q==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.16.10.tgz", + "integrity": "sha512-/qytvaxvDDhEdHLaEoxlEFVBWg982jL+XXvOmAFgIv92yGDQvN4U+VcNW7S5dueJuv/L+gi0zDqhl8LUzHHAlg==", "license": "Apache-2.0", "dependencies": { - "@noble/curves": "^1.8.2" + "@noble/curves": "^2.0.1" } }, "node_modules/@matter/main": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.13.0.tgz", - "integrity": "sha512-Qu60G05f821bEtp2yU+rJ7Xpe66GHDn9NdSPGrAn1EJH/iWplmVeLS1nSXzyGE28iPVPYNLcMX0edY/FejAhmQ==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.16.10.tgz", + "integrity": "sha512-QKoQrmMnt6cB893+Oezk3DdIVgQJj5spt/ikEszF8pjyTuvB69zlzq1wnrn52N3mi57R6UWwk6+BPEbQE95FSw==", "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.13.0", - "@matter/model": "0.13.0", - "@matter/node": "0.13.0", - "@matter/protocol": "0.13.0", - "@matter/types": "0.13.0" + "@matter/general": "0.16.10", + "@matter/model": "0.16.10", + "@matter/node": "0.16.10", + "@matter/protocol": "0.16.10", + "@matter/types": "0.16.10" }, "optionalDependencies": { - "@matter/nodejs": "0.13.0" + "@matter/nodejs": "0.16.10" } }, "node_modules/@matter/model": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.13.0.tgz", - "integrity": "sha512-rcXu7OdMctlOGVOkClH6/+11ct6FMDSK2/Yu05+J7BJfohJY5mQbo0jnz8l0FPbwRrk5ADbqfAJ5jh/1OKHR2Q==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.16.10.tgz", + "integrity": "sha512-6Ei8gETAkcKGEMRW+z8Mak55Y1Jl1TKGQIboC/4vvsrqcvB8zhIvGBS3GaAllxzvF0qjE7ihCPpgXXr6HuTLyg==", "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.13.0" + "@matter/general": "0.16.10" } }, "node_modules/@matter/node": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.13.0.tgz", - "integrity": "sha512-HQkzpRnhAUfj9u2ijkT4qgyFzEfaNqyAxh+dgMpLKnbbGQoHTskJ/wEgkRRI7B0Q57mr6VJ5KgXYdbXUYA7xFA==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.16.10.tgz", + "integrity": "sha512-Hb2AxuEf0DlfN8yHxeahZGYurUUu/UDWJkmdvpDKuwSR0eIHhMweOG2RBO55/anyRFObANaUr1gr3DnyocB/0w==", "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.13.0", - "@matter/model": "0.13.0", - "@matter/protocol": "0.13.0", - "@matter/types": "0.13.0" + "@matter/general": "0.16.10", + "@matter/model": "0.16.10", + "@matter/protocol": "0.16.10", + "@matter/types": "0.16.10" } }, "node_modules/@matter/nodejs": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.13.0.tgz", - "integrity": "sha512-ThWrLZJo7UH4Ebanf3gxkhe2p8vq4X3PxyRG+ICDRGPfzSAJZ3znh2hD/zdvcdyzQlSoXeLpFbLpTkJu7aRMHg==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.16.10.tgz", + "integrity": "sha512-o8e9tGZVsiqmEtD1osSL9+gRMIuNjtwXt29I3YDRzSqb3N5Dvd4Khj3HN68YhaFFeBlM4YEV2TS0/9VF3C06/w==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@matter/general": "0.13.0", - "@matter/node": "0.13.0", - "@matter/protocol": "0.13.0", - "@matter/types": "0.13.0" + "@matter/general": "0.16.10", + "@matter/node": "0.16.10", + "@matter/protocol": "0.16.10", + "@matter/types": "0.16.10" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.19.0 <22.0.0 || >=22.13.0" } }, "node_modules/@matter/protocol": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.13.0.tgz", - "integrity": "sha512-wFxU6+LG5ygzruOV5JF30hmLkk88hz8PqAMkQ6ow81ZvoDFBevsW0OyqxXMCNwYd/zmGXmvr3mpC0mi9/troHg==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.16.10.tgz", + "integrity": "sha512-vPQEMl8Wf4vc3tauwsGlLZRrDx+VrCPfccw3n50Lvyucws4UBt+SuBszSyIO2+QPnJcr4MB4L+EgCRI14U4zRA==", "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.13.0", - "@matter/model": "0.13.0", - "@matter/types": "0.13.0" + "@matter/general": "0.16.10", + "@matter/model": "0.16.10", + "@matter/types": "0.16.10" } }, "node_modules/@matter/types": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.13.0.tgz", - "integrity": "sha512-8mH7hRC4MBSy4KUs8zb6uDTr0MfRYGtGBFq9t2uedlsiHA5lMUP3L1fitszoeCbpJh4EeqKHWSvF6RxWzKNueA==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.16.10.tgz", + "integrity": "sha512-AW86tGgL3sN1Vb76+03VPdQMsW9vWsaVGHe9agcED1sjtWRJBV17tcLDymAQ8k7fEeVqiy3qf9CUwFK1bhfXKQ==", "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.13.0", - "@matter/model": "0.13.0" + "@matter/general": "0.16.10", + "@matter/model": "0.16.10" } }, "node_modules/@noble/curves": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.0.tgz", - "integrity": "sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", "license": "MIT", "dependencies": { - "@noble/hashes": "1.8.0" + "@noble/hashes": "2.0.1" }, "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@project-chip/matter.js": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@project-chip/matter.js/-/matter.js-0.13.0.tgz", - "integrity": "sha512-QWmfuDorGBW8dpgYk+3L9V4SdW80PQ4pW90s+ORxtWnpBcJoiP6a5skS7NJuW3kNRSMLtXQmpG+yI8oDXcULSg==", + "version": "0.16.10", + "resolved": "https://registry.npmjs.org/@project-chip/matter.js/-/matter.js-0.16.10.tgz", + "integrity": "sha512-5SpMsaNftqrqHSppP8J1RTdWdtJgehs3k9CVf0cM0IrZLQMK67W2cWVlTbsJElV7zOfKatjU56FdRA2UgkbvMQ==", "license": "Apache-2.0", "dependencies": { - "@matter/general": "0.13.0", - "@matter/model": "0.13.0", - "@matter/node": "0.13.0", - "@matter/protocol": "0.13.0", - "@matter/types": "0.13.0" + "@matter/general": "0.16.10", + "@matter/model": "0.16.10", + "@matter/node": "0.16.10", + "@matter/protocol": "0.16.10", + "@matter/types": "0.16.10" } }, "node_modules/bluebird": { diff --git a/server/services/matter/package.json b/server/services/matter/package.json index c4965473c9..b696541e9d 100644 --- a/server/services/matter/package.json +++ b/server/services/matter/package.json @@ -12,8 +12,8 @@ "arm64" ], "dependencies": { - "@matter/main": "^0.13.0", - "@project-chip/matter.js": "^0.13.0", + "@matter/main": "^0.16.10", + "@project-chip/matter.js": "^0.16.10", "bluebird": "^3.7.2", "fs-extra": "^11.3.0" } diff --git a/server/services/matter/utils/convertToGladysDevice.js b/server/services/matter/utils/convertToGladysDevice.js index ae9f763a9c..6a71390944 100644 --- a/server/services/matter/utils/convertToGladysDevice.js +++ b/server/services/matter/utils/convertToGladysDevice.js @@ -98,8 +98,10 @@ async function convertToGladysDevice(serviceId, nodeId, device, nodeDetailDevice // Add endpoint number to the name so the user can identify the device gladysDevice.name += ` ${device.number}`; - if (device.clusterClients) { - await Promise.each(Array.from(device.clusterClients.entries()), async ([clusterIndex, clusterClient]) => { + const allClusterClients = device.getAllClusterClients(); + if (allClusterClients && allClusterClients.length > 0) { + await Promise.each(allClusterClients, async (clusterClient) => { + const clusterIndex = clusterClient.id; const commonNewFeature = { name: `${clusterClient.name} - ${clusterClient.endpointId}`, selector: slugify(`matter-${device.name}-${clusterClient.name}`, true), diff --git a/server/test/services/matter/lib/listenToStateChange.test.js b/server/test/services/matter/lib/listenToStateChange.test.js index f5d48a1a84..5629d61a8c 100644 --- a/server/test/services/matter/lib/listenToStateChange.test.js +++ b/server/test/services/matter/lib/listenToStateChange.test.js @@ -47,13 +47,18 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change (ON)', async () => { const clusterClients = new Map(); clusterClients.set(OnOff.Complete.id, { + attributes: { + onOff: { + get: fake.resolves(true), + }, + }, addOnOffAttributeListener: (callback) => { callback(true); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -64,13 +69,18 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change (OFF)', async () => { const clusterClients = new Map(); clusterClients.set(OnOff.Complete.id, { + attributes: { + onOff: { + get: fake.resolves(false), + }, + }, addOnOffAttributeListener: (callback) => { callback(false); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -81,13 +91,18 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change in nested child endpoint (OFF)', async () => { const clusterClients = new Map(); clusterClients.set(OnOff.Complete.id, { + attributes: { + onOff: { + get: fake.resolves(false), + }, + }, addOnOffAttributeListener: (callback) => { callback(false); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1:child_endpoint:2', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -98,13 +113,18 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change (Occupancy = true)', async () => { const clusterClients = new Map(); clusterClients.set(OccupancySensing.Complete.id, { + attributes: { + occupancy: { + get: fake.resolves({ occupied: true }), + }, + }, addOccupancyAttributeListener: (callback) => { callback({ occupied: true }); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -115,13 +135,18 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change (Occupancy = false)', async () => { const clusterClients = new Map(); clusterClients.set(OccupancySensing.Complete.id, { + attributes: { + occupancy: { + get: fake.resolves({ occupied: false }), + }, + }, addOccupancyAttributeListener: (callback) => { callback({ occupied: false }); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -133,13 +158,18 @@ describe('Matter.listenToStateChange', () => { const clusterClients = new Map(); // Matter: Illuminance attribute changed to 21327 (Converted to 136 lux) clusterClients.set(IlluminanceMeasurement.Complete.id, { + attributes: { + measuredValue: { + get: fake.resolves(21327), + }, + }, addMeasuredValueAttributeListener: (callback) => { callback(21327); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -150,13 +180,18 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change (TemperatureMeasurement)', async () => { const clusterClients = new Map(); clusterClients.set(TemperatureMeasurement.Complete.id, { + attributes: { + measuredValue: { + get: fake.resolves(2150), + }, + }, addMeasuredValueAttributeListener: (callback) => { callback(2150); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -167,13 +202,18 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change (RelativeHumidityMeasurement)', async () => { const clusterClients = new Map(); clusterClients.set(RelativeHumidityMeasurement.Complete.id, { + attributes: { + measuredValue: { + get: fake.resolves(5000), + }, + }, addMeasuredValueAttributeListener: (callback) => { callback(5000); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -184,13 +224,18 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change (Pm25ConcentrationMeasurement)', async () => { const clusterClients = new Map(); clusterClients.set(Pm25ConcentrationMeasurement.Complete.id, { + attributes: { + measuredValue: { + get: fake.resolves(100), + }, + }, addMeasuredValueAttributeListener: (callback) => { callback(100); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -201,13 +246,18 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change (Pm10ConcentrationMeasurement)', async () => { const clusterClients = new Map(); clusterClients.set(Pm10ConcentrationMeasurement.Complete.id, { + attributes: { + measuredValue: { + get: fake.resolves(100), + }, + }, addMeasuredValueAttributeListener: (callback) => { callback(100); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -218,13 +268,18 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change (TotalVolatileOrganicCompoundsConcentrationMeasurement)', async () => { const clusterClients = new Map(); clusterClients.set(TotalVolatileOrganicCompoundsConcentrationMeasurement.Complete.id, { + attributes: { + levelValue: { + get: fake.resolves(3), + }, + }, addLevelValueAttributeListener: (callback) => { callback(3); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -235,13 +290,18 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change (FormaldehydeConcentrationMeasurement)', async () => { const clusterClients = new Map(); clusterClients.set(FormaldehydeConcentrationMeasurement.Complete.id, { + attributes: { + measuredValue: { + get: fake.resolves(100), + }, + }, addMeasuredValueAttributeListener: (callback) => { callback(100); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -255,13 +315,18 @@ describe('Matter.listenToStateChange', () => { supportedFeatures: { heating: true, }, + attributes: { + occupiedHeatingSetpoint: { + get: fake.resolves(2000), + }, + }, addOccupiedHeatingSetpointAttributeListener: (callback) => { callback(2000); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -275,13 +340,18 @@ describe('Matter.listenToStateChange', () => { supportedFeatures: { cooling: true, }, + attributes: { + occupiedCoolingSetpoint: { + get: fake.resolves(2000), + }, + }, addOccupiedCoolingSetpointAttributeListener: (callback) => { callback(2000); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -292,13 +362,18 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change (WindowCovering)', async () => { const clusterClients = new Map(); clusterClients.set(WindowCovering.Complete.id, { + attributes: { + currentPositionLiftPercent100ths: { + get: fake.resolves(8400), + }, + }, addCurrentPositionLiftPercent100thsAttributeListener: (callback) => { callback(8400); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -309,13 +384,18 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change (LevelControl)', async () => { const clusterClients = new Map(); clusterClients.set(LevelControl.Complete.id, { + attributes: { + currentLevel: { + get: fake.resolves(99), + }, + }, addCurrentLevelAttributeListener: (callback) => { callback(99); }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -325,48 +405,33 @@ describe('Matter.listenToStateChange', () => { }); it('should listen to state change (ColorControl)', async () => { const clusterClients = new Map(); - const promise = new Promise((resolve) => { - let callCount = 0; - const checkThatEveryThingWasCalled = () => { - callCount += 1; - if (callCount === 4) { - resolve(); - } - }; - clusterClients.set(ColorControl.Complete.id, { - supportedFeatures: { - hueSaturation: true, - }, - addCurrentHueAttributeListener: (callback) => { - callback(100); - checkThatEveryThingWasCalled(); - }, - addCurrentSaturationAttributeListener: (callback) => { - callback(40); - checkThatEveryThingWasCalled(); - }, - getCurrentHueAttribute: () => { - checkThatEveryThingWasCalled(); - return 100; + clusterClients.set(ColorControl.Complete.id, { + supportedFeatures: { + hueSaturation: true, + }, + attributes: { + currentHue: { + get: fake.resolves(100), }, - getCurrentSaturationAttribute: () => { - checkThatEveryThingWasCalled(); - return 40; + currentSaturation: { + get: fake.resolves(40), }, - }); + }, + addCurrentHueAttributeListener: (callback) => { + callback(100); + }, + addCurrentSaturationAttributeListener: (callback) => { + callback(40); + }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); - // We need to make sure that we called all 4 functions before checking the events - await promise; - assert.calledTwice(gladys.event.emit); - assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: 'matter:1234:1:768:color', - state: 14090213, - }); + // Initial cached state emit + 2 listener callbacks = 3 calls total + // But the listener callbacks also call emitColorState which reads from cache + assert.called(gladys.event.emit); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { device_feature_external_id: 'matter:1234:1:768:color', state: 14090213, @@ -375,6 +440,11 @@ describe('Matter.listenToStateChange', () => { it('should listen to state change (ElectricalPowerMeasurement - ActivePower)', async () => { const clusterClients = new Map(); clusterClients.set(ElectricalPowerMeasurement.Complete.id, { + attributes: { + activePower: { + get: fake.resolves(1500000), + }, + }, addActivePowerAttributeListener: (callback) => { callback(1500000); // 1500000 mW = 1500 W }, @@ -383,7 +453,7 @@ describe('Matter.listenToStateChange', () => { }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -397,6 +467,14 @@ describe('Matter.listenToStateChange', () => { supportedFeatures: { voltage: true, }, + attributes: { + activePower: { + get: fake.resolves(0), + }, + voltage: { + get: fake.resolves(230000), + }, + }, addActivePowerAttributeListener: () => {}, addVoltageAttributeListener: (callback) => { callback(230000); // 230000 mV = 230 V @@ -405,7 +483,7 @@ describe('Matter.listenToStateChange', () => { }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -419,6 +497,14 @@ describe('Matter.listenToStateChange', () => { supportedFeatures: { current: true, }, + attributes: { + activePower: { + get: fake.resolves(0), + }, + activeCurrent: { + get: fake.resolves(6500), + }, + }, addActivePowerAttributeListener: () => {}, addVoltageAttributeListener: () => {}, addActiveCurrentAttributeListener: (callback) => { @@ -427,7 +513,7 @@ describe('Matter.listenToStateChange', () => { }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -441,13 +527,18 @@ describe('Matter.listenToStateChange', () => { supportedFeatures: { cumulativeEnergy: true, }, + attributes: { + cumulativeEnergyImported: { + get: fake.resolves({ energy: 1500000000 }), + }, + }, addCumulativeEnergyImportedAttributeListener: (callback) => { callback({ energy: 1500000000 }); // 1500000000 mWh = 1500 kWh }, }); const device = { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }; await matterHandler.listenToStateChange(1234n, '1', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { @@ -455,4 +546,370 @@ describe('Matter.listenToStateChange', () => { state: 1500, }); }); + + // Test cached read error handling + it('should handle cached read error gracefully (OnOff)', async () => { + const clusterClients = new Map(); + clusterClients.set(OnOff.Complete.id, { + attributes: { + onOff: { + get: fake.rejects(new Error('Cache read failed')), + }, + }, + addOnOffAttributeListener: fake.returns(null), + }); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + // Should not throw + await matterHandler.listenToStateChange(1234n, '1', device); + // Listener should still be added + assert.calledOnce(clusterClients.get(OnOff.Complete.id).addOnOffAttributeListener); + }); + + // Test null cached value + it('should not emit state when cached value is null (OnOff)', async () => { + const clusterClients = new Map(); + clusterClients.set(OnOff.Complete.id, { + attributes: { + onOff: { + get: fake.resolves(null), + }, + }, + addOnOffAttributeListener: fake.returns(null), + }); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + // Should not emit any state since cached is null + assert.notCalled(gladys.event.emit); + }); + + // Test undefined cached value + it('should not emit state when cached value is undefined (Occupancy)', async () => { + const clusterClients = new Map(); + clusterClients.set(OccupancySensing.Complete.id, { + attributes: { + occupancy: { + get: fake.resolves(undefined), + }, + }, + addOccupancyAttributeListener: fake.returns(null), + }); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + assert.notCalled(gladys.event.emit); + }); + + // Test stateChangeListeners already has cluster (skip adding listener) + it('should not add listener twice for same cluster', async () => { + const onOffCluster = { + attributes: { + onOff: { + get: fake.resolves(true), + }, + }, + addOnOffAttributeListener: fake.returns(null), + }; + const clusterClients = new Map(); + clusterClients.set(OnOff.Complete.id, onOffCluster); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + // First call - should add listener + await matterHandler.listenToStateChange(1234n, '1', device); + assert.calledOnce(onOffCluster.addOnOffAttributeListener); + // Second call - should NOT add listener again + await matterHandler.listenToStateChange(1234n, '1', device); + assert.calledOnce(onOffCluster.addOnOffAttributeListener); + }); + + // Test ColorControl with null hue/saturation + it('should not emit color state when hue is null', async () => { + const clusterClients = new Map(); + clusterClients.set(ColorControl.Complete.id, { + supportedFeatures: { + hueSaturation: true, + }, + attributes: { + currentHue: { + get: fake.resolves(null), + }, + currentSaturation: { + get: fake.resolves(40), + }, + }, + addCurrentHueAttributeListener: fake.returns(null), + addCurrentSaturationAttributeListener: fake.returns(null), + }); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + // Should not emit because hue is null + assert.notCalled(gladys.event.emit); + }); + + // Test ColorControl with hueSaturation feature disabled + it('should not add hue/saturation listeners when feature not supported', async () => { + const colorCluster = { + supportedFeatures: { + hueSaturation: false, + }, + attributes: { + currentHue: { + get: fake.rejects(new Error('Not supported')), + }, + currentSaturation: { + get: fake.rejects(new Error('Not supported')), + }, + }, + addCurrentHueAttributeListener: fake.returns(null), + addCurrentSaturationAttributeListener: fake.returns(null), + }; + const clusterClients = new Map(); + clusterClients.set(ColorControl.Complete.id, colorCluster); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + // Should not add listeners because hueSaturation is false + assert.notCalled(colorCluster.addCurrentHueAttributeListener); + assert.notCalled(colorCluster.addCurrentSaturationAttributeListener); + }); + + // Test Thermostat with both heating and cooling + it('should emit both heating and cooling states', async () => { + const clusterClients = new Map(); + clusterClients.set(Thermostat.Complete.id, { + supportedFeatures: { + heating: true, + cooling: true, + }, + attributes: { + occupiedHeatingSetpoint: { + get: fake.resolves(2100), + }, + occupiedCoolingSetpoint: { + get: fake.resolves(2500), + }, + }, + addOccupiedHeatingSetpointAttributeListener: fake.returns(null), + addOccupiedCoolingSetpointAttributeListener: fake.returns(null), + }); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:1:513:heating', + state: 21, + }); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:1:513:cooling', + state: 25, + }); + }); + + // Test ElectricalPowerMeasurement with voltage and current attributes + it('should emit voltage and current states when attributes exist', async () => { + const clusterClients = new Map(); + clusterClients.set(ElectricalPowerMeasurement.Complete.id, { + attributes: { + activePower: { + get: fake.resolves(1500000), + }, + voltage: { + get: fake.resolves(230000), + }, + activeCurrent: { + get: fake.resolves(6500), + }, + }, + addActivePowerAttributeListener: fake.returns(null), + addVoltageAttributeListener: fake.returns(null), + addActiveCurrentAttributeListener: fake.returns(null), + }); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:1:144:power', + state: 1500, + }); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:1:144:voltage', + state: 230, + }); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:1:144:current', + state: 6.5, + }); + }); + + // Test ElectricalEnergyMeasurement with null energy + it('should not emit energy state when energy is null', async () => { + const clusterClients = new Map(); + clusterClients.set(ElectricalEnergyMeasurement.Complete.id, { + supportedFeatures: { + cumulativeEnergy: true, + }, + attributes: { + cumulativeEnergyImported: { + get: fake.resolves({ energy: null }), + }, + }, + addCumulativeEnergyImportedAttributeListener: fake.returns(null), + }); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + assert.notCalled(gladys.event.emit); + }); + + // Test cached read error for all clusters + it('should handle cached read error gracefully (IlluminanceMeasurement)', async () => { + const clusterClients = new Map(); + clusterClients.set(IlluminanceMeasurement.Complete.id, { + attributes: { + measuredValue: { + get: fake.rejects(new Error('Cache read failed')), + }, + }, + addMeasuredValueAttributeListener: fake.returns(null), + }); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + assert.calledOnce(clusterClients.get(IlluminanceMeasurement.Complete.id).addMeasuredValueAttributeListener); + }); + + // Test cached read error for WindowCovering + it('should handle cached read error gracefully (WindowCovering)', async () => { + const clusterClients = new Map(); + clusterClients.set(WindowCovering.Complete.id, { + attributes: { + currentPositionLiftPercent100ths: { + get: fake.rejects(new Error('Cache read failed')), + }, + }, + addCurrentPositionLiftPercent100thsAttributeListener: fake.returns(null), + }); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + assert.calledOnce( + clusterClients.get(WindowCovering.Complete.id).addCurrentPositionLiftPercent100thsAttributeListener, + ); + }); + + // Test cached read error for LevelControl + it('should handle cached read error gracefully (LevelControl)', async () => { + const clusterClients = new Map(); + clusterClients.set(LevelControl.Complete.id, { + attributes: { + currentLevel: { + get: fake.rejects(new Error('Cache read failed')), + }, + }, + addCurrentLevelAttributeListener: fake.returns(null), + }); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + assert.calledOnce(clusterClients.get(LevelControl.Complete.id).addCurrentLevelAttributeListener); + }); + + // Test cached read error for Thermostat + it('should handle cached read error gracefully (Thermostat)', async () => { + const clusterClients = new Map(); + clusterClients.set(Thermostat.Complete.id, { + supportedFeatures: { + heating: true, + cooling: true, + }, + attributes: { + occupiedHeatingSetpoint: { + get: fake.rejects(new Error('Cache read failed')), + }, + occupiedCoolingSetpoint: { + get: fake.rejects(new Error('Cache read failed')), + }, + }, + addOccupiedHeatingSetpointAttributeListener: fake.returns(null), + addOccupiedCoolingSetpointAttributeListener: fake.returns(null), + }); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + assert.calledOnce(clusterClients.get(Thermostat.Complete.id).addOccupiedHeatingSetpointAttributeListener); + assert.calledOnce(clusterClients.get(Thermostat.Complete.id).addOccupiedCoolingSetpointAttributeListener); + }); + + // Test cached read error for ElectricalPowerMeasurement + it('should handle cached read error gracefully (ElectricalPowerMeasurement)', async () => { + const clusterClients = new Map(); + clusterClients.set(ElectricalPowerMeasurement.Complete.id, { + attributes: { + activePower: { + get: fake.rejects(new Error('Cache read failed')), + }, + }, + addActivePowerAttributeListener: fake.returns(null), + addVoltageAttributeListener: fake.returns(null), + addActiveCurrentAttributeListener: fake.returns(null), + }); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + assert.calledOnce(clusterClients.get(ElectricalPowerMeasurement.Complete.id).addActivePowerAttributeListener); + }); + + // Test cached read error for ElectricalEnergyMeasurement + it('should handle cached read error gracefully (ElectricalEnergyMeasurement)', async () => { + const clusterClients = new Map(); + clusterClients.set(ElectricalEnergyMeasurement.Complete.id, { + supportedFeatures: { + cumulativeEnergy: true, + }, + attributes: { + cumulativeEnergyImported: { + get: fake.rejects(new Error('Cache read failed')), + }, + }, + addCumulativeEnergyImportedAttributeListener: fake.returns(null), + }); + const device = { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + assert.calledOnce( + clusterClients.get(ElectricalEnergyMeasurement.Complete.id).addCumulativeEnergyImportedAttributeListener, + ); + }); }); diff --git a/server/test/services/matter/lib/matter.getNodes.test.js b/server/test/services/matter/lib/matter.getNodes.test.js index 2515b02a2d..7e0c377447 100644 --- a/server/test/services/matter/lib/matter.getNodes.test.js +++ b/server/test/services/matter/lib/matter.getNodes.test.js @@ -37,13 +37,13 @@ describe('Matter.getNodes', () => { { number: 1, name: 'Test Device', - clusterClients: new Map(), - childEndpoints: [ + getAllClusterClients: () => [], + getChildEndpoints: () => [ { name: 'Test Device child', number: 2, - clusterClients, - childEndpoints: [], + getAllClusterClients: () => Array.from(clusterClients.values()), + getChildEndpoints: () => [], }, ], }, diff --git a/server/test/services/matter/lib/matter.init.test.js b/server/test/services/matter/lib/matter.init.test.js index cd2969c600..f462432489 100644 --- a/server/test/services/matter/lib/matter.init.test.js +++ b/server/test/services/matter/lib/matter.init.test.js @@ -53,7 +53,9 @@ describe('Matter.init', () => { name: 'OnOff', endpointId: 1, attributes: { - onOff: {}, + onOff: { + get: fake.resolves(true), + }, }, commands: {}, addOnOffAttributeListener: fake.returns(null), @@ -65,7 +67,9 @@ describe('Matter.init', () => { name: 'OccupancySensing', endpointId: 1, attributes: { - occupancy: {}, + occupancy: { + get: fake.resolves({ occupied: true }), + }, }, commands: {}, addOccupancyAttributeListener: fake.returns(null), @@ -77,7 +81,9 @@ describe('Matter.init', () => { name: 'IlluminanceMeasurement', endpointId: 1, attributes: { - measuredValue: {}, + measuredValue: { + get: fake.resolves(10000), + }, }, commands: {}, addMeasuredValueAttributeListener: fake.returns(null), @@ -89,7 +95,9 @@ describe('Matter.init', () => { name: 'TemperatureMeasurement', endpointId: 1, attributes: { - measuredValue: {}, + measuredValue: { + get: fake.resolves(2500), + }, }, commands: {}, addMeasuredValueAttributeListener: fake.returns(null), @@ -101,7 +109,9 @@ describe('Matter.init', () => { name: 'WindowCovering', endpointId: 1, attributes: { - currentPositionLiftPercent100ths: {}, + currentPositionLiftPercent100ths: { + get: fake.resolves(5000), + }, }, commands: {}, addCurrentPositionLiftPercent100thsAttributeListener: fake.returns(null), @@ -112,7 +122,14 @@ describe('Matter.init', () => { id: 768, name: 'ColorControl', endpointId: 1, - attributes: {}, + attributes: { + currentHue: { + get: fake.resolves(128), + }, + currentSaturation: { + get: fake.resolves(128), + }, + }, commands: {}, supportedFeatures: { hueSaturation: true, @@ -128,11 +145,16 @@ describe('Matter.init', () => { id: 8, name: 'LevelControl', endpointId: 1, - attributes: {}, + attributes: { + currentLevel: { + get: fake.resolves(50), + }, + }, commands: {}, supportedFeatures: { lighting: true, }, + addCurrentLevelAttributeListener: fake.returns(null), getMinLevelAttribute: fake.resolves(0), getMaxLevelAttribute: fake.resolves(100), }); @@ -143,7 +165,9 @@ describe('Matter.init', () => { name: 'RelativeHumidityMeasurement', endpointId: 1, attributes: { - measuredValue: {}, + measuredValue: { + get: fake.resolves(5000), + }, }, commands: {}, addMeasuredValueAttributeListener: fake.returns(null), @@ -155,8 +179,12 @@ describe('Matter.init', () => { name: 'Thermostat', endpointId: 1, attributes: { - occupiedHeatingSetpoint: {}, - occupiedCoolingSetpoint: {}, + occupiedHeatingSetpoint: { + get: fake.resolves(2100), + }, + occupiedCoolingSetpoint: { + get: fake.resolves(2500), + }, }, supportedFeatures: { heating: true, @@ -173,7 +201,9 @@ describe('Matter.init', () => { name: 'Pm25ConcentrationMeasurement', endpointId: 1, attributes: { - measuredValue: {}, + measuredValue: { + get: fake.resolves(25), + }, }, commands: {}, addMeasuredValueAttributeListener: fake.returns(null), @@ -188,7 +218,9 @@ describe('Matter.init', () => { name: 'Pm10ConcentrationMeasurement', endpointId: 1, attributes: { - measuredValue: {}, + measuredValue: { + get: fake.resolves(50), + }, }, commands: {}, addMeasuredValueAttributeListener: fake.returns(null), @@ -203,7 +235,9 @@ describe('Matter.init', () => { name: 'TotalVolatileOrganicCompoundsConcentrationMeasurement', endpointId: 1, attributes: { - levelValue: {}, + levelValue: { + get: fake.resolves(3), + }, }, commands: {}, addLevelValueAttributeListener: fake.returns(null), @@ -215,7 +249,9 @@ describe('Matter.init', () => { name: 'FormaldehydeConcentrationMeasurement', endpointId: 1, attributes: { - measuredValue: {}, + measuredValue: { + get: fake.resolves(10), + }, }, commands: {}, addMeasuredValueAttributeListener: fake.returns(null), @@ -230,9 +266,15 @@ describe('Matter.init', () => { name: 'ElectricalPowerMeasurement', endpointId: 1, attributes: { - activePower: {}, - voltage: {}, - activeCurrent: {}, + activePower: { + get: fake.resolves(1500000), + }, + voltage: { + get: fake.resolves(230000), + }, + activeCurrent: { + get: fake.resolves(6500), + }, }, commands: {}, addActivePowerAttributeListener: fake.returns(null), @@ -248,7 +290,9 @@ describe('Matter.init', () => { name: 'ElectricalEnergyMeasurement', endpointId: 1, attributes: { - cumulativeEnergyImported: {}, + cumulativeEnergyImported: { + get: fake.resolves({ energy: 5000000000 }), + }, }, commands: {}, supportedFeatures: { @@ -276,18 +320,30 @@ describe('Matter.init', () => { }, ]), getNode: fake.resolves({ + isConnected: false, + initialized: false, + connect: fake.returns(null), + events: { + initialized: Promise.resolve(), + stateChanged: { + on: fake.returns(null), + }, + }, getDevices: fake.returns([ { id: 'device-1', name: 'Test Device', number: 1, - clusterClients, - childEndpoints: [ + getClusterClientById: (id) => clusterClients.get(id), + getAllClusterClients: () => Array.from(clusterClients.values()), + getChildEndpoints: () => [ { id: 'child-endpoint-1', name: 'Child Endpoint', number: 2, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), + getAllClusterClients: () => Array.from(clusterClients.values()), + getChildEndpoints: () => [], }, ], }, @@ -333,6 +389,8 @@ describe('Matter.init', () => { it('should initialize matter service successfully', async () => { await matterHandler.init(); + // refreshDevices runs in background, wait for it to complete + await matterHandler.refreshDevicesPromise; expect(matterHandler.devices).to.have.lengthOf(2); // Device selector should be a slug of the name with 4 random characters at the end expect(matterHandler.devices[0].selector).to.satisfy( diff --git a/server/test/services/matter/lib/matter.pairDevice.test.js b/server/test/services/matter/lib/matter.pairDevice.test.js index 82ef8a9ce4..e1bc150c08 100644 --- a/server/test/services/matter/lib/matter.pairDevice.test.js +++ b/server/test/services/matter/lib/matter.pairDevice.test.js @@ -26,6 +26,14 @@ describe('Matter.pairDevice', () => { const pairingCode = '1450-134-1614'; const clusterClients = new Map(); clusterClients.set(6, { + id: 6, + name: 'OnOff', + endpointId: 1, + attributes: { + onOff: { + get: fake.resolves(true), + }, + }, addOnOffAttributeListener: fake.returns(null), }); matterHandler.commissioningController = { @@ -42,18 +50,30 @@ describe('Matter.pairDevice', () => { }, ]), getNode: fake.resolves({ + isConnected: false, + initialized: false, + connect: fake.returns(null), + events: { + initialized: Promise.resolve(), + stateChanged: { + on: fake.returns(null), + }, + }, getDevices: fake.returns([ { id: 'device-1', name: 'Test Device', number: 1, - clusterClients: new Map(), - childEndpoints: [ + getClusterClientById: () => undefined, + getAllClusterClients: () => [], + getChildEndpoints: () => [ { id: 'child-endpoint-1', name: 'Child Endpoint', number: 2, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), + getAllClusterClients: () => Array.from(clusterClients.values()), + getChildEndpoints: () => [], }, ], }, @@ -68,6 +88,14 @@ describe('Matter.pairDevice', () => { const pairingCode = '1450-134-1614'; const clusterClients = new Map(); clusterClients.set(6, { + id: 6, + name: 'OnOff', + endpointId: 1, + attributes: { + onOff: { + get: fake.resolves(true), + }, + }, addOnOffAttributeListener: fake.returns(null), }); const bridgeClusterClients = new Map(); @@ -109,18 +137,30 @@ describe('Matter.pairDevice', () => { }, ]), getNode: fake.resolves({ + isConnected: false, + initialized: false, + connect: fake.returns(null), + events: { + initialized: Promise.resolve(), + stateChanged: { + on: fake.returns(null), + }, + }, getDevices: fake.returns([ { id: 'device-1', name: 'Test Device', number: 1, - clusterClients: bridgeClusterClients, - childEndpoints: [ + getClusterClientById: (id) => bridgeClusterClients.get(id), + getAllClusterClients: () => Array.from(bridgeClusterClients.values()), + getChildEndpoints: () => [ { id: 'child-endpoint-1', name: 'Child Endpoint', number: 2, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), + getAllClusterClients: () => Array.from(clusterClients.values()), + getChildEndpoints: () => [], }, ], }, @@ -128,13 +168,16 @@ describe('Matter.pairDevice', () => { id: 'device-2', name: 'Test Device 2', number: 2, - clusterClients: bridgeClusterClients, - childEndpoints: [ + getClusterClientById: (id) => bridgeClusterClients.get(id), + getAllClusterClients: () => Array.from(bridgeClusterClients.values()), + getChildEndpoints: () => [ { id: 'child-endpoint-2', name: 'Child Endpoint 2', number: 2, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), + getAllClusterClients: () => Array.from(clusterClients.values()), + getChildEndpoints: () => [], }, ], }, @@ -162,4 +205,154 @@ describe('Matter.pairDevice', () => { expect(matterHandler.devices).to.have.lengthOf(0); expect(matterHandler.nodesMap.size).to.equal(0); }); + it('should handle bridged device attribute read errors gracefully', async () => { + const pairingCode = '1450-134-1614'; + const clusterClients = new Map(); + clusterClients.set(6, { + id: 6, + name: 'OnOff', + endpointId: 1, + attributes: { + onOff: { + get: fake.resolves(true), + }, + }, + addOnOffAttributeListener: fake.returns(null), + }); + const bridgeClusterClients = new Map(); + // All bridged attributes throw errors + bridgeClusterClients.set(BridgedDeviceBasicInformation.Complete.id, { + attributes: { + vendorName: { + get: fake.rejects(new Error('Read failed')), + }, + productName: { + get: fake.rejects(new Error('Read failed')), + }, + productLabel: { + get: fake.rejects(new Error('Read failed')), + }, + nodeLabel: { + get: fake.rejects(new Error('Read failed')), + }, + uniqueId: { + get: fake.rejects(new Error('Read failed')), + }, + serialNumber: { + get: fake.rejects(new Error('Read failed')), + }, + }, + }); + matterHandler.commissioningController = { + commissionNode: fake.resolves(12345n), + getCommissionedNodesDetails: fake.returns([ + { + nodeId: 12345n, + deviceData: { + basicInformation: { + vendorName: 'Test Vendor', + productName: 'Test Product', + }, + }, + }, + ]), + getNode: fake.resolves({ + isConnected: true, + initialized: true, + connect: fake.returns(null), + events: { + initialized: Promise.resolve(), + stateChanged: { + on: fake.returns(null), + }, + }, + getDevices: fake.returns([ + { + id: 'device-1', + name: 'Test Device', + number: 1, + getClusterClientById: (id) => bridgeClusterClients.get(id), + getAllClusterClients: () => Array.from(bridgeClusterClients.values()), + getChildEndpoints: () => [ + { + id: 'child-endpoint-1', + name: 'Child Endpoint', + number: 2, + getClusterClientById: (id) => clusterClients.get(id), + getAllClusterClients: () => Array.from(clusterClients.values()), + getChildEndpoints: () => [], + }, + ], + }, + ]), + }), + }; + // Should not throw despite attribute read errors + await matterHandler.pairDevice(pairingCode); + expect(matterHandler.devices).to.have.lengthOf(1); + }); + it('should handle node already connected and initialized', async () => { + const pairingCode = '1450-134-1614'; + const clusterClients = new Map(); + clusterClients.set(6, { + id: 6, + name: 'OnOff', + endpointId: 1, + attributes: { + onOff: { + get: fake.resolves(true), + }, + }, + addOnOffAttributeListener: fake.returns(null), + }); + const connectFake = fake.returns(null); + matterHandler.commissioningController = { + commissionNode: fake.resolves(12345n), + getCommissionedNodesDetails: fake.returns([ + { + nodeId: 12345n, + deviceData: { + basicInformation: { + vendorName: 'Test Vendor', + productName: 'Test Product', + }, + }, + }, + ]), + getNode: fake.resolves({ + isConnected: true, // Already connected + initialized: true, // Already initialized + connect: connectFake, + events: { + initialized: Promise.resolve(), + stateChanged: { + on: fake.returns(null), + }, + }, + getDevices: fake.returns([ + { + id: 'device-1', + name: 'Test Device', + number: 1, + getClusterClientById: () => undefined, + getAllClusterClients: () => [], + getChildEndpoints: () => [ + { + id: 'child-endpoint-1', + name: 'Child Endpoint', + number: 2, + getClusterClientById: (id) => clusterClients.get(id), + getAllClusterClients: () => Array.from(clusterClients.values()), + getChildEndpoints: () => [], + }, + ], + }, + ]), + }), + }; + await matterHandler.pairDevice(pairingCode); + // connect() should NOT be called since isConnected is true + sinon.assert.notCalled(connectFake); + expect(matterHandler.devices).to.have.lengthOf(1); + }); }); diff --git a/server/test/services/matter/lib/matter.refreshDevices.test.js b/server/test/services/matter/lib/matter.refreshDevices.test.js index 1e867e5fb6..09b81021e7 100644 --- a/server/test/services/matter/lib/matter.refreshDevices.test.js +++ b/server/test/services/matter/lib/matter.refreshDevices.test.js @@ -3,6 +3,14 @@ const { expect } = require('chai'); const { fake } = sinon; +// NodeStates enum values from @project-chip/matter.js/device +const NodeStates = { + Connected: 0, + Disconnected: 1, + Reconnecting: 2, + WaitingForDeviceDiscovery: 3, +}; + const MatterHandler = require('../../../../services/matter/lib'); describe('Matter.refreshDevices', () => { @@ -26,7 +34,9 @@ describe('Matter.refreshDevices', () => { name: 'OnOff', endpointId: 1, attributes: { - onOff: {}, + onOff: { + get: fake.resolves(true), + }, }, commands: {}, addOnOffAttributeListener: fake.returns(null), @@ -48,18 +58,30 @@ describe('Matter.refreshDevices', () => { }, ]), getNode: fake.resolves({ + isConnected: false, + initialized: false, + connect: fake.returns(null), + events: { + initialized: Promise.resolve(), + stateChanged: { + on: fake.returns(null), + }, + }, getDevices: fake.returns([ { id: 'device-1', name: 'Test Device', number: 1, - clusterClients, - childEndpoints: [ + getClusterClientById: (id) => clusterClients.get(id), + getAllClusterClients: () => Array.from(clusterClients.values()), + getChildEndpoints: () => [ { id: 'child-endpoint-1', name: 'Child Endpoint', number: 2, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), + getAllClusterClients: () => Array.from(clusterClients.values()), + getChildEndpoints: () => [], }, ], }, @@ -76,4 +98,229 @@ describe('Matter.refreshDevices', () => { expect(matterHandler.devices).to.have.lengthOf(2); expect(matterHandler.nodesMap.size).to.equal(1); }); + + it('should trigger stateChanged handler on reconnect', async () => { + let stateChangedCallback; + const gladys = { + event: { + emit: fake.returns(null), + }, + job: { + wrapper: fake.returns(null), + }, + }; + const MatterMain = {}; + const ProjectChipMatter = {}; + const handler = new MatterHandler(gladys, MatterMain, ProjectChipMatter, 'service-1'); + + const onOffCluster = { + id: 6, + name: 'OnOff', + endpointId: 1, + attributes: { + onOff: { + get: fake.resolves(true), + }, + }, + commands: {}, + addOnOffAttributeListener: fake.returns(null), + }; + const testClusterClients = new Map(); + testClusterClients.set(6, onOffCluster); + + handler.commissioningController = { + getCommissionedNodesDetails: fake.returns([ + { + nodeId: 12345n, + deviceData: { + basicInformation: { + vendorName: 'Test Vendor', + productName: 'Test Product', + }, + }, + }, + ]), + getNode: fake.resolves({ + isConnected: false, + initialized: false, + connect: fake.returns(null), + events: { + initialized: Promise.resolve(), + stateChanged: { + on: (callback) => { + stateChangedCallback = callback; + }, + }, + }, + getDevices: fake.returns([ + { + id: 'device-1', + name: 'Test Device', + number: 1, + getClusterClientById: (id) => testClusterClients.get(id), + getAllClusterClients: () => Array.from(testClusterClients.values()), + getChildEndpoints: () => [], + }, + ]), + }), + }; + + await handler.refreshDevices(); + expect(handler.devices).to.have.lengthOf(1); + + // Simulate reconnect by calling the stateChanged callback with NodeStates.Connected + await stateChangedCallback(NodeStates.Connected); + + // Should have emitted state for the device + sinon.assert.called(gladys.event.emit); + }); + + it('should handle error in stateChanged reconnect handler gracefully', async () => { + let stateChangedCallback; + const gladys = { + event: { + emit: fake.throws(new Error('Emit failed')), + }, + job: { + wrapper: fake.returns(null), + }, + }; + const MatterMain = {}; + const ProjectChipMatter = {}; + const handler = new MatterHandler(gladys, MatterMain, ProjectChipMatter, 'service-1'); + + const onOffCluster = { + id: 6, + name: 'OnOff', + endpointId: 1, + attributes: { + onOff: { + get: fake.resolves(true), + }, + }, + commands: {}, + addOnOffAttributeListener: fake.returns(null), + }; + const testClusterClients = new Map(); + testClusterClients.set(6, onOffCluster); + + handler.commissioningController = { + getCommissionedNodesDetails: fake.returns([ + { + nodeId: 12345n, + deviceData: { + basicInformation: { + vendorName: 'Test Vendor', + productName: 'Test Product', + }, + }, + }, + ]), + getNode: fake.resolves({ + isConnected: false, + initialized: false, + connect: fake.returns(null), + events: { + initialized: Promise.resolve(), + stateChanged: { + on: (callback) => { + stateChangedCallback = callback; + }, + }, + }, + getDevices: fake.returns([ + { + id: 'device-1', + name: 'Test Device', + number: 1, + getClusterClientById: (id) => testClusterClients.get(id), + getAllClusterClients: () => Array.from(testClusterClients.values()), + getChildEndpoints: () => [], + }, + ]), + }), + }; + + await handler.refreshDevices(); + + // Simulate reconnect - should not throw despite emit error + await stateChangedCallback(NodeStates.Connected); + }); + + it('should not refresh states when node state is not Connected', async () => { + let stateChangedCallback; + const gladys = { + event: { + emit: fake.returns(null), + }, + job: { + wrapper: fake.returns(null), + }, + }; + const MatterMain = {}; + const ProjectChipMatter = {}; + const handler = new MatterHandler(gladys, MatterMain, ProjectChipMatter, 'service-1'); + + const onOffCluster = { + id: 6, + name: 'OnOff', + endpointId: 1, + attributes: { + onOff: { + get: fake.resolves(true), + }, + }, + commands: {}, + addOnOffAttributeListener: fake.returns(null), + }; + const testClusterClients = new Map(); + testClusterClients.set(6, onOffCluster); + + handler.commissioningController = { + getCommissionedNodesDetails: fake.returns([ + { + nodeId: 12345n, + deviceData: { + basicInformation: { + vendorName: 'Test Vendor', + productName: 'Test Product', + }, + }, + }, + ]), + getNode: fake.resolves({ + isConnected: false, + initialized: false, + connect: fake.returns(null), + events: { + initialized: Promise.resolve(), + stateChanged: { + on: (callback) => { + stateChangedCallback = callback; + }, + }, + }, + getDevices: fake.returns([ + { + id: 'device-1', + name: 'Test Device', + number: 1, + getClusterClientById: (id) => testClusterClients.get(id), + getAllClusterClients: () => Array.from(testClusterClients.values()), + getChildEndpoints: () => [], + }, + ]), + }), + }; + + await handler.refreshDevices(); + // Reset emit call count after initial setup + gladys.event.emit.resetHistory(); + + // Simulate state change to Disconnected (not Connected) + await stateChangedCallback(NodeStates.Disconnected); + + // Should NOT have emitted any state since not Connected + sinon.assert.notCalled(gladys.event.emit); + }); }); diff --git a/server/test/services/matter/lib/matter.setValue.test.js b/server/test/services/matter/lib/matter.setValue.test.js index d05e24e9d0..3320c6db87 100644 --- a/server/test/services/matter/lib/matter.setValue.test.js +++ b/server/test/services/matter/lib/matter.setValue.test.js @@ -44,14 +44,14 @@ describe('Matter.setValue', () => { isConnected: false, connect: fake.resolves(null), events: { - initialized: new Promise((resolve) => { + initializedFromRemote: new Promise((resolve) => { resolve(); }), }, getDevices: fake.returns([ { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }, ]), }); @@ -83,14 +83,14 @@ describe('Matter.setValue', () => { isConnected: false, connect: fake.resolves(null), events: { - initialized: new Promise((resolve) => { + initializedFromRemote: new Promise((resolve) => { resolve(); }), }, getDevices: fake.returns([ { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }, ]), }); @@ -123,20 +123,21 @@ describe('Matter.setValue', () => { isConnected: false, connect: fake.resolves(null), events: { - initialized: new Promise((resolve) => { + initializedFromRemote: new Promise((resolve) => { resolve(); }), }, getDevices: fake.returns([ { number: 1, - childEndpoints: [ + getChildEndpoints: () => [ { number: 2, - childEndpoints: [ + getChildEndpoints: () => [ { number: 2, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), + getChildEndpoints: () => [], }, ], }, @@ -172,14 +173,14 @@ describe('Matter.setValue', () => { isConnected: false, connect: fake.resolves(null), events: { - initialized: new Promise((resolve) => { + initializedFromRemote: new Promise((resolve) => { resolve(); }), }, getDevices: fake.returns([ { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }, ]), }); @@ -211,14 +212,14 @@ describe('Matter.setValue', () => { isConnected: false, connect: fake.resolves(null), events: { - initialized: new Promise((resolve) => { + initializedFromRemote: new Promise((resolve) => { resolve(); }), }, getDevices: fake.returns([ { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }, ]), }); @@ -258,14 +259,14 @@ describe('Matter.setValue', () => { isConnected: false, connect: fake.resolves(null), events: { - initialized: new Promise((resolve) => { + initializedFromRemote: new Promise((resolve) => { resolve(); }), }, getDevices: fake.returns([ { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }, ]), }); @@ -312,14 +313,14 @@ describe('Matter.setValue', () => { isConnected: false, connect: fake.resolves(null), events: { - initialized: new Promise((resolve) => { + initializedFromRemote: new Promise((resolve) => { resolve(); }), }, getDevices: fake.returns([ { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }, ]), }); @@ -359,14 +360,14 @@ describe('Matter.setValue', () => { isConnected: false, connect: fake.resolves(null), events: { - initialized: new Promise((resolve) => { + initializedFromRemote: new Promise((resolve) => { resolve(); }), }, getDevices: fake.returns([ { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }, ]), }); @@ -397,14 +398,14 @@ describe('Matter.setValue', () => { isConnected: false, connect: fake.resolves(null), events: { - initialized: new Promise((resolve) => { + initializedFromRemote: new Promise((resolve) => { resolve(); }), }, getDevices: fake.returns([ { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }, ]), }); @@ -441,7 +442,7 @@ describe('Matter.setValue', () => { isConnected: false, connect: fake.resolves(null), events: { - initialized: new Promise((resolve) => { + initializedFromRemote: new Promise((resolve) => { resolve(); }), }, @@ -468,14 +469,14 @@ describe('Matter.setValue', () => { isConnected: false, connect: fake.resolves(null), events: { - initialized: new Promise((resolve) => { + initializedFromRemote: new Promise((resolve) => { resolve(); }), }, getDevices: fake.returns([ { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }, ]), }); @@ -500,14 +501,14 @@ describe('Matter.setValue', () => { isConnected: false, connect: fake.resolves(null), events: { - initialized: new Promise((resolve) => { + initializedFromRemote: new Promise((resolve) => { resolve(); }), }, getDevices: fake.returns([ { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), }, ]), }); @@ -532,17 +533,18 @@ describe('Matter.setValue', () => { isConnected: false, connect: fake.resolves(null), events: { - initialized: new Promise((resolve) => { + initializedFromRemote: new Promise((resolve) => { resolve(); }), }, getDevices: fake.returns([ { number: 1, - childEndpoints: [ + getChildEndpoints: () => [ { number: 1, - clusterClients, + getClusterClientById: (id) => clusterClients.get(id), + getChildEndpoints: () => [], }, ], },