diff --git a/codegen/src/generate-forwards.ts b/codegen/src/generate-forwards.ts index f0bfe1783..db3f09505 100644 --- a/codegen/src/generate-forwards.ts +++ b/codegen/src/generate-forwards.ts @@ -19,4 +19,4 @@ await progress.run("@project-chip/matter.js", generateProjectChipMatterjsForward await progress.run("@matter/main", generateMatterjsMainForwards); -progress.shutdown(); +progress.close(); diff --git a/package-lock.json b/package-lock.json index f6575b356..5197b71be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -188,9 +188,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.5.tgz", - "integrity": "sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", "license": "MIT", "peer": true, "engines": { @@ -198,22 +198,23 @@ } }, "node_modules/@babel/core": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.7.tgz", - "integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.8.tgz", + "integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==", "license": "MIT", "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.5", + "@babel/generator": "^7.26.8", "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", "@babel/helpers": "^7.26.7", - "@babel/parser": "^7.26.7", - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.26.7", - "@babel/types": "^7.26.7", + "@babel/parser": "^7.26.8", + "@babel/template": "^7.26.8", + "@babel/traverse": "^7.26.8", + "@babel/types": "^7.26.8", + "@types/gensync": "^1.0.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -252,14 +253,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.5.tgz", - "integrity": "sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.8.tgz", + "integrity": "sha512-ef383X5++iZHWAXX0SXQR6ZyQhw/0KtTkrTz61WXRhFM6dhpHulO/RJz79L8S6ugZHJkOOkUrUdxgdF2YiPFnA==", "license": "MIT", "peer": true, "dependencies": { - "@babel/parser": "^7.26.5", - "@babel/types": "^7.26.5", + "@babel/parser": "^7.26.8", + "@babel/types": "^7.26.8", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -526,7 +527,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -536,7 +536,6 @@ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -581,13 +580,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", - "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.8.tgz", + "integrity": "sha512-TZIQ25pkSoaKEYYaHbbxkfL36GNsQ6iFiBbeuzAkLnXayKR1yP1zFe+NxuZWWsUyvt8icPU9CCq0sgWGXR1GEw==", "license": "MIT", - "peer": true, "dependencies": { - "@babel/types": "^7.26.7" + "@babel/types": "^7.26.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -1043,15 +1041,15 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", - "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz", + "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-remap-async-to-generator": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/traverse": "^7.26.8" }, "engines": { "node": ">=6.9.0" @@ -1811,14 +1809,14 @@ } }, "node_modules/@babel/plugin-transform-runtime": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.25.9.tgz", - "integrity": "sha512-nZp7GlEl+yULJrClz0SwHPqir3lc0zsPrDHQUcxGspSL7AKrexNSEfTbfqnDNJUO13bgKyfuOLMF8Xqtu8j3YQ==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.26.8.tgz", + "integrity": "sha512-H0jlQxFMI0Q8SyGPsj9pO3ygVQRxPkIGytsL3m1Zqca8KrCPpMlvh+e2dxknqdfS8LFwBw+PpiYPD9qy/FPQpA==", "license": "MIT", "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-plugin-utils": "^7.26.5", "babel-plugin-polyfill-corejs2": "^0.4.10", "babel-plugin-polyfill-corejs3": "^0.10.6", "babel-plugin-polyfill-regenerator": "^0.6.1", @@ -1831,6 +1829,20 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", + "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.2", + "core-js-compat": "^3.38.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -1891,13 +1903,13 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", - "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz", + "integrity": "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.26.5" }, "engines": { "node": ">=6.9.0" @@ -1923,9 +1935,9 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.7.tgz", - "integrity": "sha512-5cJurntg+AT+cgelGP9Bt788DKiAw9gIMSMU2NJrLAilnj0m8WZWUNZPSLOmadYsujHutpgElO+50foX+ib/Wg==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.8.tgz", + "integrity": "sha512-bME5J9AC8ChwA7aEPJ6zym3w7aObZULHhbNLU0bKUhKsAkylkzUdq+0kdymh9rzi8nlNFl2bmldFBCKNJBUpuw==", "license": "MIT", "peer": true, "dependencies": { @@ -2010,13 +2022,13 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.7.tgz", - "integrity": "sha512-Ycg2tnXwixaXOVb29rana8HNPgLVBof8qqtNQ9LE22IoyZboQbGSxI6ZySMdW3K5nAe6gu35IaJefUJflhUFTQ==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.8.tgz", + "integrity": "sha512-um7Sy+2THd697S4zJEfv/U5MHGJzkN2xhtsR3T/SWRbVSic62nbISh51VVfU9JiO/L/Z97QczHTaFVkOU8IzNg==", "license": "MIT", "peer": true, "dependencies": { - "@babel/compat-data": "^7.26.5", + "@babel/compat-data": "^7.26.8", "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-validator-option": "^7.25.9", @@ -2030,7 +2042,7 @@ "@babel/plugin-syntax-import-attributes": "^7.26.0", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.25.9", - "@babel/plugin-transform-async-generator-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.26.8", "@babel/plugin-transform-async-to-generator": "^7.25.9", "@babel/plugin-transform-block-scoped-functions": "^7.26.5", "@babel/plugin-transform-block-scoping": "^7.25.9", @@ -2073,7 +2085,7 @@ "@babel/plugin-transform-shorthand-properties": "^7.25.9", "@babel/plugin-transform-spread": "^7.25.9", "@babel/plugin-transform-sticky-regex": "^7.25.9", - "@babel/plugin-transform-template-literals": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.26.8", "@babel/plugin-transform-typeof-symbol": "^7.26.7", "@babel/plugin-transform-unicode-escapes": "^7.25.9", "@babel/plugin-transform-unicode-property-regex": "^7.25.9", @@ -2081,9 +2093,9 @@ "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-corejs3": "^0.11.0", "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.38.1", + "core-js-compat": "^3.40.0", "semver": "^6.3.1" }, "engines": { @@ -2221,32 +2233,32 @@ "peer": true }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.8.tgz", + "integrity": "sha512-iNKaX3ZebKIsCvJ+0jd6embf+Aulaa3vNBqZ41kM7iTWjx5qzWKXGHiJUW3+nTpQ18SG11hdF8OAzKrpXkb96Q==", "license": "MIT", "peer": true, "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.8", + "@babel/types": "^7.26.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.7.tgz", - "integrity": "sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.8.tgz", + "integrity": "sha512-nic9tRkjYH0oB2dzr/JoGIm+4Q6SuYeLEiIiZDwBscRMYFJ+tMAz98fuel9ZnbXViA2I0HVSSRRK8DW5fjXStA==", "license": "MIT", "peer": true, "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.5", - "@babel/parser": "^7.26.7", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.7", + "@babel/generator": "^7.26.8", + "@babel/parser": "^7.26.8", + "@babel/template": "^7.26.8", + "@babel/types": "^7.26.8", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2256,17 +2268,17 @@ }, "node_modules/@babel/traverse--for-generate-function-map": { "name": "@babel/traverse", - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.7.tgz", - "integrity": "sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.8.tgz", + "integrity": "sha512-nic9tRkjYH0oB2dzr/JoGIm+4Q6SuYeLEiIiZDwBscRMYFJ+tMAz98fuel9ZnbXViA2I0HVSSRRK8DW5fjXStA==", "license": "MIT", "peer": true, "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.5", - "@babel/parser": "^7.26.7", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.7", + "@babel/generator": "^7.26.8", + "@babel/parser": "^7.26.8", + "@babel/template": "^7.26.8", + "@babel/types": "^7.26.8", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2295,11 +2307,10 @@ } }, "node_modules/@babel/types": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", - "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.8.tgz", + "integrity": "sha512-eUuWapzEGWFEpHFxgEaBG8e3n6S8L3MSu0oda755rOfabWPnh0Our1AozNFVUxGFIhbKgd1ksprsoDGMinTOTA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" @@ -2927,9 +2938,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", - "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.11.0.tgz", + "integrity": "sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -2978,9 +2989,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.19.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz", - "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==", + "version": "9.20.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.20.0.tgz", + "integrity": "sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==", "dev": true, "license": "MIT", "engines": { @@ -3013,6 +3024,20 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", + "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -3032,9 +3057,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.12.5", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.5.tgz", - "integrity": "sha512-d3iiHxdpg5+ZcJ6jnDSOT8Z0O0VMVGy34jAnYLUX8yd36b1qn8f1TwOA/Lc7TsOh03IkPJ38eGI5qD2EjNkoEA==", + "version": "1.12.6", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.6.tgz", + "integrity": "sha512-JXUj6PI0oqqzTGvKtzOkxtpsyPRNsrmhh41TtIz/zEB6J+AUiZZ0dxWzcMwO9Ns5rmSPuMdghlTbUuqIM48d3Q==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.7.13", @@ -3893,9 +3918,9 @@ "license": "BSD-3-Clause" }, "node_modules/@react-native-async-storage/async-storage": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.1.0.tgz", - "integrity": "sha512-eAGQGPTAuFNEoIQSB5j2Jh1zm5NPyBRTfjRMfCN0W1OakC5WIB5vsDyIQhUweKN9XOE2/V07lqTMGsL0dGXNkA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.1.1.tgz", + "integrity": "sha512-UqlnxddwM3rlCHvteFz+HpIXjqhQM7GkBgVQ9sMvMdl8QVOJQDjG7BODCUvabysMDw+9QfMFlLiOI8U6c0VzzQ==", "license": "MIT", "dependencies": { "merge-options": "^3.0.4" @@ -4821,6 +4846,13 @@ "@types/send": "*" } }, + "node_modules/@types/gensync": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.4.tgz", + "integrity": "sha512-C3YYeRQWp2fmq9OryX+FoDy8nXS6scQ7dPptD8LnFDAUNcKWJjXQKDNJD3HVm+kOUsXhTOkpi69vI4EuAr95bA==", + "license": "MIT", + "peer": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -4912,6 +4944,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/madge": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/madge/-/madge-5.0.3.tgz", + "integrity": "sha512-NlQJd0qRAoyu+pawTDhLxkW940QT2dqASfwd2g/xEZu2F4Xjwa7TVRSPdbmZwUF1ygvAh0/nepeN7JjwEuOXCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4940,9 +4982,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.0.tgz", - "integrity": "sha512-ClIbNe36lawluuvq3+YYhnIN2CELi+6q8NpnM7PYp4hBn/TatfboPgVSm2rwKRfnV2M+Ty9GWDFI64KEe+kysA==", + "version": "22.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", + "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "license": "MIT", "dependencies": { "undici-types": "~6.20.0" @@ -5012,9 +5054,9 @@ } }, "node_modules/@types/ssh2/node_modules/@types/node": { - "version": "18.19.74", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.74.tgz", - "integrity": "sha512-HMwEkkifei3L605gFdV+/UwtpxP6JSzM+xFk2Ia6DNFSwSVBRh9qp5Tgf4lNFOMfPVuU0WnkcWpXZpgn5ufO4A==", + "version": "18.19.75", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.75.tgz", + "integrity": "sha512-UIksWtThob6ZVSyxcOqCLOUNg/dyO1Qvx4McgeuhrEtHTLFTf7BBhEazaE4K806FGTPtzd/2sE90qn4fVr7cyw==", "dev": true, "license": "MIT", "dependencies": { @@ -5098,21 +5140,21 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.22.0.tgz", - "integrity": "sha512-4Uta6REnz/xEJMvwf72wdUnC3rr4jAQf5jnTkeRQ9b6soxLxhDEbS/pfMPoJLDfFPNVRdryqWUIV/2GZzDJFZw==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.23.0.tgz", + "integrity": "sha512-vBz65tJgRrA1Q5gWlRfvoH+w943dq9K1p1yDBY2pc+a1nbBLZp7fB9+Hk8DaALUbzjqlMfgaqlVPT1REJdkt/w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.22.0", - "@typescript-eslint/type-utils": "8.22.0", - "@typescript-eslint/utils": "8.22.0", - "@typescript-eslint/visitor-keys": "8.22.0", + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/type-utils": "8.23.0", + "@typescript-eslint/utils": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5269,14 +5311,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.22.0.tgz", - "integrity": "sha512-/lwVV0UYgkj7wPSw0o8URy6YI64QmcOdwHuGuxWIYznO6d45ER0wXUbksr9pYdViAofpUCNJx/tAzNukgvaaiQ==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.23.0.tgz", + "integrity": "sha512-OGqo7+dXHqI7Hfm+WqkZjKjsiRtFUQHPdGMXzk5mYXhJUedO7e/Y7i8AK3MyLMgZR93TX4bIzYrfyVjLC+0VSw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.22.0", - "@typescript-eslint/visitor-keys": "8.22.0" + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5287,16 +5329,16 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.22.0.tgz", - "integrity": "sha512-NzE3aB62fDEaGjaAYZE4LH7I1MUwHooQ98Byq0G0y3kkibPJQIXVUspzlFOmOfHhiDLwKzMlWxaNv+/qcZurJA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.23.0.tgz", + "integrity": "sha512-iIuLdYpQWZKbiH+RkCGc6iu+VwscP5rCtQ1lyQ7TYuKLrcZoeJVpcLiG8DliXVkUxirW/PWlmS+d6yD51L9jvA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.22.0", - "@typescript-eslint/utils": "8.22.0", + "@typescript-eslint/typescript-estree": "8.23.0", + "@typescript-eslint/utils": "8.23.0", "debug": "^4.3.4", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5311,9 +5353,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.22.0.tgz", - "integrity": "sha512-0S4M4baNzp612zwpD4YOieP3VowOARgK2EkN/GBn95hpyF8E2fbMT55sRHWBq+Huaqk3b3XK+rxxlM8sPgGM6A==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.23.0.tgz", + "integrity": "sha512-1sK4ILJbCmZOTt9k4vkoulT6/y5CHJ1qUYxqpF1K/DBAd8+ZUL4LlSCxOssuH5m4rUaaN0uS0HlVPvd45zjduQ==", "dev": true, "license": "MIT", "engines": { @@ -5325,20 +5367,20 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.22.0.tgz", - "integrity": "sha512-SJX99NAS2ugGOzpyhMza/tX+zDwjvwAtQFLsBo3GQxiGcvaKlqGBkmZ+Y1IdiSi9h4Q0Lr5ey+Cp9CGWNY/F/w==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.23.0.tgz", + "integrity": "sha512-LcqzfipsB8RTvH8FX24W4UUFk1bl+0yTOf9ZA08XngFwMg4Kj8A+9hwz8Cr/ZS4KwHrmo9PJiLZkOt49vPnuvQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.22.0", - "@typescript-eslint/visitor-keys": "8.22.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/visitor-keys": "8.23.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5378,16 +5420,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.22.0.tgz", - "integrity": "sha512-T8oc1MbF8L+Bk2msAvCUzjxVB2Z2f+vXYfcucE2wOmYs7ZUwco5Ep0fYZw8quNwOiw9K8GYVL+Kgc2pETNTLOg==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.23.0.tgz", + "integrity": "sha512-uB/+PSo6Exu02b5ZEiVtmY6RVYO7YU5xqgzTIVZwTHvvK3HsL8tZZHFaTLFtRG3CsV4A5mhOv+NZx5BlhXPyIA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.22.0", - "@typescript-eslint/types": "8.22.0", - "@typescript-eslint/typescript-estree": "8.22.0" + "@typescript-eslint/scope-manager": "8.23.0", + "@typescript-eslint/types": "8.23.0", + "@typescript-eslint/typescript-estree": "8.23.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5402,13 +5444,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.22.0.tgz", - "integrity": "sha512-AWpYAXnUgvLNabGTy3uBylkgZoosva/miNd1I8Bz3SjotmQPbVqhO4Cczo8AsZ44XVErEBPr/CRSgaj8sG7g0w==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.23.0.tgz", + "integrity": "sha512-oWWhcWDLwDfu++BGTZcmXWqpwtkwb5o7fxUIGksMQQDSdPW9prsSnfIOZMlsj4vBOSrcnjIUZMiIjODgGosFhQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.22.0", + "@typescript-eslint/types": "8.23.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -5790,6 +5832,16 @@ "node": "*" } }, + "node_modules/ast-module-types": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ast-module-types/-/ast-module-types-6.0.1.tgz", + "integrity": "sha512-WHw67kLXYbZuHTmcdbIrVArCq5wxo6NEuj3hiYAWr8mwJeC+C2mMCIBIWCiDoCye/OF/xelc+teJ1ERoWmnEIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -5982,14 +6034,14 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.10.6", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", - "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", + "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.2", - "core-js-compat": "^3.38.0" + "@babel/helper-define-polyfill-provider": "^0.6.3", + "core-js-compat": "^3.40.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -6650,9 +6702,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001696", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001696.tgz", - "integrity": "sha512-pDCPkvzfa39ehJtJ+OwGT/2yvT2SbjfHhiIW2LWOAcMQ7BzwxT/XuyUp4OTOd0XFWA6BKw0JalnBHgSi5DGJBQ==", + "version": "1.0.30001699", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz", + "integrity": "sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w==", "funding": [ { "type": "opencollective", @@ -7403,6 +7455,24 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detective-typescript": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/detective-typescript/-/detective-typescript-14.0.0.tgz", + "integrity": "sha512-pgN43/80MmWVSEi5LUuiVvO/0a9ss5V7fwVfrJ4QzAQRd3cwqU1SfWGXJFcNKUqoD5cS+uIovhw5t/0rSeC5Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "^8.23.0", + "ast-module-types": "^6.0.1", + "node-source-walk": "^7.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "typescript": "^5.4.4" + } + }, "node_modules/diff": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", @@ -7485,9 +7555,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.90", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.90.tgz", - "integrity": "sha512-C3PN4aydfW91Natdyd449Kw+BzhLmof6tzy5W1pFC5SpQxVXT+oyiyOG9AgYYSN9OdA/ik3YkCrpwqI8ug5Tug==", + "version": "1.5.96", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.96.tgz", + "integrity": "sha512-8AJUW6dh75Fm/ny8+kZKJzI1pgoE8bKLZlzDU2W1ENd+DXKJrx7I7l9hb8UWR4ojlnb5OlixMt00QWiYJoVw1w==", "license": "ISC", "peer": true }, @@ -7570,9 +7640,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz", - "integrity": "sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", "dev": true, "license": "MIT", "dependencies": { @@ -7877,9 +7947,9 @@ } }, "node_modules/eslint": { - "version": "9.19.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz", - "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==", + "version": "9.20.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.0.tgz", + "integrity": "sha512-aL4F8167Hg4IvsW89ejnpTwx+B/UQRzJPGgbIOl+4XqffWsahVVsLEWoZvnrVuwpWmnRd7XeXmQI1zlKcFDteA==", "dev": true, "license": "MIT", "peer": true, @@ -7887,9 +7957,9 @@ "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.10.0", + "@eslint/core": "^0.11.0", "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.19.0", + "@eslint/js": "9.20.0", "@eslint/plugin-kit": "^0.2.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -8280,9 +8350,9 @@ } }, "node_modules/exponential-backoff": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", - "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", + "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", "license": "Apache-2.0" }, "node_modules/express": { @@ -9521,13 +9591,13 @@ } }, "node_modules/is-boolean-object": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.1.tgz", - "integrity": "sha512-l9qO6eFlUETHtuihLcYOaLKByJ1f+N4kthcU9YjHy3N+B3hWv0y/2Nd0mu/7lTFnRQHTrSdXF50HQ3bl5fEnng==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", + "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" }, "engines": { @@ -9896,13 +9966,13 @@ } }, "node_modules/is-weakref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.0.tgz", - "integrity": "sha512-SXM8Nwyys6nT5WP6pltOwKytLV7FqQ4UiibxVmW+EIosHcmCqkkjViTb5SNssDlkCiEYRP1/pdWUKVvZBmsR2Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -11886,6 +11956,19 @@ "license": "MIT", "peer": true }, + "node_modules/node-source-walk": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/node-source-walk/-/node-source-walk-7.0.1.tgz", + "integrity": "sha512-3VW/8JpPqPvnJvseXowjZcirPisssnBuDikk6JIZ8jQzF7KJQX52iPFX4RYYxLycYH7IbMRSPUOga/esVjy5Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.7" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/nopt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", @@ -11956,9 +12039,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", - "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -12531,9 +12614,9 @@ } }, "node_modules/possible-typed-array-names": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", - "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -12809,9 +12892,9 @@ } }, "node_modules/react-devtools-core": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.0.tgz", - "integrity": "sha512-sA8gF/pUhjoGAN3s1Ya43h+F4Q0z7cv9RgqbUfhP7bJI0MbqeshLYFb6hiHgZorovGr8AXqhLi22eQ7V3pru/Q==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.1.tgz", + "integrity": "sha512-TFo1MEnkqE6hzAbaztnyR5uLTMoz6wnEWwWBsCUzNt+sVXJycuRJdDqvL078M4/h65BI/YO5XWTaxZDWVsW0fw==", "license": "MIT", "peer": true, "dependencies": { @@ -12973,9 +13056,9 @@ } }, "node_modules/react-native-quick-crypto": { - "version": "0.7.11", - "resolved": "https://registry.npmjs.org/react-native-quick-crypto/-/react-native-quick-crypto-0.7.11.tgz", - "integrity": "sha512-6YwX/APUf21gKO2R9arB8PFZhPcw+28IHsmwYZuucLDIjgY+8gAPLNny51i9Ov83z2ZFNUinh0ykhy0Pw6bBWA==", + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/react-native-quick-crypto/-/react-native-quick-crypto-0.7.12.tgz", + "integrity": "sha512-3x4SmFz44DN4RjnSWu85f9iGSBqVy8RKj++7eWcGRTir1gMosNH6IxbxQHO4GD/d3tIP21QIijcD+/Z5h7v6DA==", "license": "MIT", "workspaces": [ ".", @@ -13562,9 +13645,9 @@ } }, "node_modules/semver": { - "version": "7.7.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.0.tgz", - "integrity": "sha512-DrfFnPzblFmNrIZzg5RzHegbiRWg7KMR7btwi2yjHwx06zsUbO5g613sVwEV7FTwmzJu+Io0lJe2GJ3LxqpvBQ==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -14438,9 +14521,9 @@ } }, "node_modules/terser": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz", - "integrity": "sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==", + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.38.1.tgz", + "integrity": "sha512-GWANVlPM/ZfYzuPHjq0nxT+EbOEDDN3Jwhwdg1D8TU8oSkktp8w64Uq4auuGLxFSoNTRDncTq2hQHX1Ld9KHkA==", "license": "BSD-2-Clause", "peer": true, "dependencies": { @@ -14548,22 +14631,22 @@ "peer": true }, "node_modules/tldts": { - "version": "6.1.76", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.76.tgz", - "integrity": "sha512-6U2ti64/nppsDxQs9hw8ephA3nO6nSQvVVfxwRw8wLQPFtLI1cFI1a1eP22g+LUP+1TA2pKKjUTwWB+K2coqmQ==", + "version": "6.1.77", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.77.tgz", + "integrity": "sha512-lBpoWgy+kYmuXWQ83+R7LlJCnsd9YW8DGpZSHhrMl4b8Ly/1vzOie3OdtmUJDkKxcgRGOehDu5btKkty+JEe+g==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^6.1.76" + "tldts-core": "^6.1.77" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.76", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.76.tgz", - "integrity": "sha512-uzhJ02RaMzgQR3yPoeE65DrcHI6LoM4saUqXOt/b5hmb3+mc4YWpdSeAQqVqRUlQ14q8ZuLRWyBR1ictK1dzzg==", + "version": "6.1.77", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.77.tgz", + "integrity": "sha512-bCaqm24FPk8OgBkM0u/SrEWJgHnhBWYqeBo6yUmcZJDCHt/IfyWBb+14CXdGi4RInMv4v7eUAin15W0DoA+Ytg==", "dev": true, "license": "MIT" }, @@ -14609,9 +14692,9 @@ } }, "node_modules/tough-cookie": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.0.tgz", - "integrity": "sha512-rvZUv+7MoBYTiDmFPBrhL7Ujx9Sk+q9wwm22x8c8T5IJaR+Wsyc7TNxbVxo84kZoRJZZMazowFLqpankBEQrGg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.1.tgz", + "integrity": "sha512-Ek7HndSVkp10hmHP9V4qZO1u+pn1RU5sI0Fw+jCU3lyvuMZcgqsNgc6CmJJZyByK4Vm/qotGRJlfgAX8q+4JiA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -15943,8 +16026,10 @@ "matter-version": "bin/version.js" }, "devDependencies": { + "@types/madge": "^5.0.3", "@types/minimatch": "^5.1.2", - "@types/node": "^22.10.10" + "@types/node": "^22.10.10", + "detective-typescript": "^14.0.0" }, "optionalDependencies": { "@esbuild/linux-x64": "^0.24.2" diff --git a/packages/general/src/MatterError.ts b/packages/general/src/MatterError.ts index ab5a6f49e..616c0330a 100644 --- a/packages/general/src/MatterError.ts +++ b/packages/general/src/MatterError.ts @@ -223,3 +223,21 @@ function fallbackFormatter(value: unknown, indents = 0) { return formatOne(value, indents, ""); } + +/** + * Indicate an asynchronous operation was canceled. + */ +export class CanceledError extends MatterError { + constructor(message = "Operation canceled", options?: ErrorOptions) { + super(message, options); + } +} + +/** + * Indicates an asynchronous operation was canceled due to timeout. + */ +export class TimeoutError extends CanceledError { + constructor(message = "Operation timed out", options?: ErrorOptions) { + super(message, options); + } +} diff --git a/packages/general/src/index.ts b/packages/general/src/index.ts index 0b2751d06..8e20c951a 100644 --- a/packages/general/src/index.ts +++ b/packages/general/src/index.ts @@ -13,5 +13,6 @@ export * from "./MatterError.js"; export * from "./net/index.js"; export * from "./storage/index.js"; export * from "./time/index.js"; +export * from "./transaction/index.js"; export * from "./util/index.js"; import "./polyfills/index.js"; diff --git a/packages/general/src/log/Logger.ts b/packages/general/src/log/Logger.ts index d565599af..a4214808c 100644 --- a/packages/general/src/log/Logger.ts +++ b/packages/general/src/log/Logger.ts @@ -5,6 +5,7 @@ */ import { Boot } from "#util/Boot.js"; +import { CancelablePromise } from "#util/Cancelable.js"; import { ImplementationError, NotImplementedError } from "../MatterError.js"; import { Time } from "../time/Time.js"; import { Bytes } from "../util/Bytes.js"; @@ -491,3 +492,5 @@ Boot.init(() => { MatterHooks.loggerSetup?.(Logger); } }); + +CancelablePromise.logger = Logger.get("CancelablePromise"); diff --git a/packages/general/src/time/Time.ts b/packages/general/src/time/Time.ts index 167d4cc73..6091d5dde 100644 --- a/packages/general/src/time/Time.ts +++ b/packages/general/src/time/Time.ts @@ -5,7 +5,7 @@ */ import { Boot } from "#util/Boot.js"; -import { CancelablePromise } from "#util/Promises.js"; +import { CancelablePromise } from "#util/Cancelable.js"; import { ImplementationError } from "../MatterError.js"; import { Diagnostic } from "../log/Diagnostic.js"; import { DiagnosticSource } from "../log/DiagnosticSource.js"; diff --git a/packages/node/src/behavior/state/transaction/Participant.ts b/packages/general/src/transaction/Participant.ts similarity index 96% rename from packages/node/src/behavior/state/transaction/Participant.ts rename to packages/general/src/transaction/Participant.ts index a3d48cc8c..490d2d0e3 100644 --- a/packages/node/src/behavior/state/transaction/Participant.ts +++ b/packages/general/src/transaction/Participant.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { MaybePromise } from "#general"; +import { MaybePromise } from "#util/Promises.js"; /** * Components with support for transactionality implement this interface. diff --git a/packages/node/src/behavior/state/transaction/Resource.ts b/packages/general/src/transaction/Resource.ts similarity index 100% rename from packages/node/src/behavior/state/transaction/Resource.ts rename to packages/general/src/transaction/Resource.ts diff --git a/packages/node/src/behavior/state/transaction/ResourceSet.ts b/packages/general/src/transaction/ResourceSet.ts similarity index 97% rename from packages/node/src/behavior/state/transaction/ResourceSet.ts rename to packages/general/src/transaction/ResourceSet.ts index 9d88694a0..6402e6cc7 100644 --- a/packages/node/src/behavior/state/transaction/ResourceSet.ts +++ b/packages/general/src/transaction/ResourceSet.ts @@ -4,8 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describeList, Logger } from "#general"; -import { SynchronousTransactionConflictError, TransactionDeadlockError, TransactionFlowError } from "./Errors.js"; +import { Logger } from "#log/Logger.js"; +import { describeList } from "#util/String.js"; +import { SynchronousTransactionConflictError, TransactionDeadlockError, TransactionFlowError } from "./errors.js"; import { Resource } from "./Resource.js"; import type { Transaction } from "./Transaction.js"; diff --git a/packages/node/src/behavior/state/transaction/Status.ts b/packages/general/src/transaction/Status.ts similarity index 96% rename from packages/node/src/behavior/state/transaction/Status.ts rename to packages/general/src/transaction/Status.ts index cce3e43ec..1d4e64be2 100644 --- a/packages/node/src/behavior/state/transaction/Status.ts +++ b/packages/general/src/transaction/Status.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { TransactionFlowError } from "./Errors.js"; +import { TransactionFlowError } from "./errors.js"; import type { Transaction } from "./Transaction.js"; /** diff --git a/packages/node/src/behavior/state/transaction/Transaction.ts b/packages/general/src/transaction/Transaction.ts similarity index 95% rename from packages/node/src/behavior/state/transaction/Transaction.ts rename to packages/general/src/transaction/Transaction.ts index e3fd9756c..dac28b042 100644 --- a/packages/node/src/behavior/state/transaction/Transaction.ts +++ b/packages/general/src/transaction/Transaction.ts @@ -4,14 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { MaybePromise } from "#general"; +import { MaybePromise } from "#util/Promises.js"; import { Participant } from "./Participant.js"; -import type { Resource } from "./Resource.js"; +import { Resource } from "./Resource.js"; +import { ResourceSet } from "./ResourceSet.js"; import { Status } from "./Status.js"; import { ReadOnlyTransaction, act } from "./Tx.js"; /** - * By default, Matter.js state is transactional. + * . * * Transactions are either shared (for reads) or exclusive (for writes). Exclusive transactions do not block shared * transactions but state updates will not be visible until the transaction completes. @@ -21,7 +22,7 @@ import { ReadOnlyTransaction, act } from "./Tx.js"; * can avoid this by using {@link begin} which will wait for other transactions to complete before acquiring resource * locks. * - * Persistence is implemented by a list of participants. Commits are two phase. If an error is thrown in phase one all + * Persistence is implemented by a list of participants. Commits are two phase. If an error throws in phase one all * participants roll back. An error in phase 2 could result in data inconsistency as we don't have any form of retry as * of yet. * @@ -148,8 +149,8 @@ export interface Transaction { } type StatusType = Status; -const StatusEnum = Status; type ResourceType = Resource; +type ResourceSetType = ResourceSet; type ParticipantType = Participant; export const Transaction = { @@ -166,12 +167,11 @@ export const Transaction = { return act(via, actor); }, - /** - * A read-only transaction you may use without context. - */ ReadOnly: ReadOnlyTransaction, - Status: StatusEnum, + Status, + + Resource, [Symbol.toStringTag]: "Transaction", }; @@ -184,5 +184,7 @@ export namespace Transaction { export type Resource = ResourceType; + export type ResourceSet = ResourceSetType; + export type Participant = ParticipantType; } diff --git a/packages/node/src/behavior/state/transaction/Tx.ts b/packages/general/src/transaction/Tx.ts similarity index 92% rename from packages/node/src/behavior/state/transaction/Tx.ts rename to packages/general/src/transaction/Tx.ts index 7dac9e7da..0157b20d0 100644 --- a/packages/node/src/behavior/state/transaction/Tx.ts +++ b/packages/general/src/transaction/Tx.ts @@ -4,17 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describeList, - Diagnostic, - ImplementationError, - Logger, - MaybePromise, - Observable, - ReadOnlyError, -} from "#general"; -import { StatusResponseError } from "#types"; -import { FinalizationError, TransactionDestroyedError, TransactionFlowError } from "./Errors.js"; +import { Diagnostic } from "#log/Diagnostic.js"; +import { Logger } from "#log/Logger.js"; +import { ImplementationError, ReadOnlyError } from "#MatterError.js"; +import { Observable } from "#util/Observable.js"; +import { MaybePromise } from "#util/Promises.js"; +import { describeList } from "#util/String.js"; +import { FinalizationError, TransactionDestroyedError, TransactionFlowError, UnsettledStateError } from "./errors.js"; import type { Participant } from "./Participant.js"; import type { Resource } from "./Resource.js"; import { ResourceSet } from "./ResourceSet.js"; @@ -99,7 +95,8 @@ export function act(via: string, actor: (transaction: Transaction) => T): T { // If actor is async, chain commit and close asynchronously if (MaybePromise.is(actorResult)) { - isAsync = true; + // If the actor is async mark the transaction as async; this will enable reporting on lock changes + isAsync = tx.isAsync = true; return Promise.resolve(actorResult) .then(commitTransaction, handleTransactionError) .finally(closeTransaction) as T; @@ -139,6 +136,7 @@ class Tx implements Transaction { #via: string; #shared?: Observable<[]>; #closed?: Observable<[]>; + #isAsync = false; constructor(via: string, readonly = false) { this.#via = Diagnostic.via(via); @@ -177,6 +175,19 @@ class Tx implements Transaction { return this.#waitingOn; } + get isAsync(): boolean { + return this.#isAsync; + } + + set isAsync(isAsync: true) { + // When the transaction is noted as async we start reporting locks. A further optimization would be to not even + // acquire locks for synchronous transactions + if (!this.#isAsync) { + this.#locksChanged(this.#resources); + } + this.#isAsync = isAsync; + } + onShared(listener: () => void, once?: boolean) { if (this.#shared === undefined) { this.#shared = Observable(); @@ -335,12 +346,7 @@ class Tx implements Transaction { throw new TransactionFlowError("Attempted wait on a transaction that is already waiting"); } - logger.debug( - "Transaction", - this.via, - "waiting on", - describeList("and", ...[...others].map(other => other.via)), - ); + logger.debug("Tx", this.via, "waiting on", describeList("and", ...[...others].map(other => other.via))); this.#waitingOn = others; return new Promise(resolve => { @@ -414,27 +420,33 @@ class Tx implements Transaction { let cycles = 1; const errorRollback = (error?: any) => { + logger.error( + "Rolling back", + this.via, + "due to pre-commit error:", + Diagnostic.weak(error?.message || `${error}`), + ); + const result = this.#finalize(Status.RollingBack, "rolled back", () => this.#executeRollback()); if (MaybePromise.is(result)) { return result.then(() => { - StatusResponseError.reject(error); - throw new FinalizationError("Rolled back due to pre-commit error"); + throw error; }); } - StatusResponseError.reject(error); - throw new FinalizationError("Rolled back due to pre-commit error"); + throw error; }; const nextCycle = () => { // Guard against infinite loops cycles++; if (cycles > MAX_PRECOMMIT_CYCLES) { - logger.error( - `State has not settled after ${MAX_PRECOMMIT_CYCLES} pre-commit cycles which likely indicates an infinite loop`, + return errorRollback( + new UnsettledStateError( + `State has not settled after ${MAX_PRECOMMIT_CYCLES} pre-commit cycles which likely indicates an infinite loop`, + ), ); - return errorRollback(); } // Restart iteration at the first participant @@ -479,7 +491,6 @@ class Tx implements Transaction { // When an error occurs this function performs rollback then throws const handleError = (error: any) => { abortedDueToError = true; - logger.error(`Error pre-commit of ${participant}:`, error); return errorRollback(error); }; @@ -625,7 +636,6 @@ class Tx implements Transaction { * Rollback logic passed to #finish. */ #executeRollback() { - //this.#log("rollback"); this.#status = Status.RollingBack; let errored: undefined | Array; let ongoing: undefined | Array>; @@ -670,12 +680,8 @@ class Tx implements Transaction { } } - #log(...message: unknown[]) { - logger.debug("Transaction", this.#via, message); - } - #locksChanged(resources: Set, how = "locked") { - if (!resources.size) { + if (!resources.size || !this.isAsync) { return; } @@ -685,7 +691,7 @@ class Tx implements Transaction { } else { resourceDescription = `${resources.size} resource${resources.size === 1 ? "" : "s"}`; } - this.#log(how, resourceDescription); + logger.debug(this.via, how, resourceDescription); } #assertAvailable() { @@ -703,6 +709,9 @@ class Tx implements Transaction { } } +/** + * A read-only offline transaction you may use without context. + */ export const ReadOnlyTransaction = new Tx("readonly", true); function throwIfErrored(errored: undefined | Array, when: string) { diff --git a/packages/node/src/behavior/state/transaction/Errors.ts b/packages/general/src/transaction/errors.ts similarity index 65% rename from packages/node/src/behavior/state/transaction/Errors.ts rename to packages/general/src/transaction/errors.ts index c22a63a4b..0e0450edd 100644 --- a/packages/node/src/behavior/state/transaction/Errors.ts +++ b/packages/general/src/transaction/errors.ts @@ -4,10 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { MatterError } from "#general"; -import type { Behavior } from "../../Behavior.js"; -import type { ActionContext } from "../../context/ActionContext.js"; -import type { OfflineContext } from "../../context/server/OfflineContext.js"; +import { MatterError } from "#MatterError.js"; import type { Transaction } from "./Transaction.js"; /** @@ -36,10 +33,21 @@ export class FinalizationError extends MatterError {} /** * Thrown if a {@link Transaction} is accessed after it has been destroyed. * - * If you see this error, you have probably kept a reference to a contextual object such as a {@link Behavior} after its - * {@link ActionContext} exited. You may need to create a new context using {@link OfflineContext.act}. + * If you see this error, you have probably kept a reference to a contextual object its exited. You may need to create + * a new, independent transaction context. * * A possible cause of this error is forgetting to use await on an async function. The context will remain open so long * as there is an unresolved {@link Promise} it can await. */ export class TransactionDestroyedError extends MatterError {} + +/** + * Thrown if a {@link Transaction} cannot commit because state has mutated continuously for too many pre-commit cycles. + * + * "Pre-commit" is a commit event triggered by {@link Transaction} before stage 1 commit. During pre-commit listeners + * may mutate state. If state does change, the transaction re-runs pre-commit so all listeners see the same state. + * + * If state continues to mutate for too many of these cycles then the transaction will abort. This likely indicates a + * logic error that will result in an infinite loop. + */ +export class UnsettledStateError extends FinalizationError {} diff --git a/packages/general/src/transaction/index.ts b/packages/general/src/transaction/index.ts new file mode 100644 index 000000000..3506c4ece --- /dev/null +++ b/packages/general/src/transaction/index.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./errors.js"; +export * from "./Transaction.js"; diff --git a/packages/general/src/util/Cancelable.ts b/packages/general/src/util/Cancelable.ts new file mode 100644 index 000000000..6e24fa564 --- /dev/null +++ b/packages/general/src/util/Cancelable.ts @@ -0,0 +1,380 @@ +/** + * @license + * Copyright 2022-2024 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Logger } from "#log/Logger.js"; +import { CanceledError } from "#MatterError.js"; +import { errorOf } from "./Error.js"; +import { createPromise, MaybePromise } from "./Promises.js"; + +/** + * An operation that may be canceled. + */ +export interface Cancelable { + /** + * Cancel the operation. + */ + cancel(reason: any): void; +} + +/** + * A {@link PromiseLike} that may be canceled. + * + * Behaves like a normal promise but does not actually extend {@link Promise} because that's a huge PITA. + */ +export class CancelablePromise implements Promise, Cancelable { + #reject!: (cause: any) => void; + #promise: Promise; + #isSettled = false; + + // Cancelable cannot create its own logger because that would create a circular dependency + static #logger: Logger | Console = console; + + /** + * Create a new cancelable promise. + * + * If the promise is rejected due to cancelation, the {@link executor} callbacks have no effect. + * + * If you supply {@link onCancel} it overwrites the {@link CancelablePromise#onCancel} method. + * + * @param executor the normal executor supplied to a {@link Promise} constructor + * @param onCancel rejection handler supplied with a reason and a callback for optionally rejecting the promise + */ + constructor( + executor: (resolve: (value: T | PromiseLike) => void, reject: (reason?: any) => void) => void, + onCancel?: (reason: Error) => void, + ) { + if (onCancel !== undefined) { + this.onCancel = onCancel; + } + + this.#promise = new Promise((resolve, reject) => { + this.#reject = (reason?: any) => { + this.#isSettled = true; + reject(errorOf(reason)); + }; + + executor( + (value: T | PromiseLike) => { + if (this.#isSettled) { + return; + } + + this.#isSettled = true; + resolve(value); + }, + + (reason?: any) => { + if (this.#isSettled) { + CancelablePromise.logger.warn(`Cancelable promise rejected after settle:`, reason); + return; + } + + this.#reject(reason); + }, + ); + }); + } + + /** + * Cancel the operation. + */ + cancel(reason: any = new CanceledError()) { + if (this.#isSettled) { + return; + } + + try { + this.onCancel(reason); + } catch (e) { + this.#reject(e); + } + } + + /** + * Implement cancelation. This is only invoked if the promise has not resolved. + * + * Throwing causes the promise to reject with the error thrown. The default implementation rethrows {@link reason}. + * + * This is overwritten if there is an "onCancel" argument to the constructor. + */ + protected onCancel(reason: Error) { + throw reason; + } + + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, + ): CancelablePromise { + const result = this.#promise.then(onfulfilled, onrejected) as CancelablePromise; + result.cancel = this.cancel.bind(this); + return result; + } + + catch( + onrejected?: ((reason: any) => TResult | PromiseLike) | null, + ): CancelablePromise { + return this.then(onrejected); + } + + finally(onfinally?: (() => void) | null): CancelablePromise { + const handler = (result: any) => { + onfinally?.(); + return result; + }; + return this.then(handler, handler); + } + + get [Symbol.toStringTag]() { + return this.#promise[Symbol.toStringTag]; + } + + static is(value: MaybePromise): value is CancelablePromise { + return MaybePromise.is(value) && typeof (value as CancelablePromise).cancel === "function"; + } + + static resolve(value: T): CancelablePromise { + const result = Promise.resolve(value) as CancelablePromise; + result.cancel = () => {}; + return result; + } + + static reject(cause: any): CancelablePromise { + const result = Promise.reject(errorOf(cause)) as CancelablePromise; + result.cancel = () => {}; + return result; + } + + static set logger(logger: Logger | Console) { + this.#logger = logger; + } + + static get logger() { + return this.#logger; + } +} + +/** + * An {@link AsyncIterator} that may be canceled. + */ +export class CancelableAsyncIterator + implements AsyncIterator, Cancelable +{ + // The result of the final iteration + #settled?: boolean; + + // The input next implementation + #next: (...[value]: [] | [TNext]) => Promise>; + + // We race against this promise to detect cancelation during next() + #canceled: Promise>; + + // Rejects #canceled + #reject!: (reason: any) => void; + + /** + * Create a new instance. + * + * @param next the function that produces results + * @param onCancel if provided this will overwrite {@link onCancel} + */ + constructor( + next: (...[value]: [] | [TNext]) => Promise>, + onCancel?: (reason: Error) => void, + ) { + this.#next = next; + this.#canceled = new Promise((_resolve, reject) => (this.#reject = reject)); + if (onCancel !== undefined) { + this.onCancel = onCancel; + } + } + + next(...[value]: [] | [TNext]): Promise> { + if (this.#settled) { + // This type is a lie if TReturn does not allow undefined but TS doesn't give us any options here, and + // calling next after the final iteration doesn't make semantic sense anyway. The spec does say subsequent + // calls should return done = true + return Promise.resolve({ done: true } as IteratorResult); + } + + const next = value === undefined ? this.#next() : this.#next(value); + + // If next resolves after we've canceled we ignore it, but if we were to do that with an error it would be + // unhandled. Errors from next will still cause an unhandled error if not caught before we set this.#final + // because we rethrow from the race below where this.#final cannot be set + next.catch(reason => { + if (this.#settled) { + CancelablePromise.logger.warn(`Cancelable async iterator rejected after return:`, reason); + } + }); + + return Promise.race([next, this.#canceled]).then( + result => { + // next resolved (this.#canceled can only be rejected) + if (result.done) { + this.#settled = true; + } + return result; + }, + reason => { + // next or this.#canceled rejected; regardless we use this for all subsequent calls to next() since we + // will never receive TReturn + this.#settled = true; + throw reason; + }, + ); + } + + cancel(reason: any): void { + if (this.#settled) { + return; + } + + try { + this.onCancel(reason); + } catch (e) { + this.#reject(e); + } + } + + /** + * Handle cancelation. + * + * If the underlying operation supports cancelation then it is better to use that. Otherwise throwing + * {@link reason} (the default behavior) will reject the current (or next) iteration regardless of the state of the + * underyling operation. + * + * @param reason the reason provided to {@link cancel} + */ + protected onCancel(reason: any) { + throw reason; + } + + static is(value: unknown): value is CancelableAsyncIterator { + return ( + typeof value === "object" && + value !== null && + Symbol.asyncIterator in value && + typeof value[Symbol.asyncIterator] === "function" && + "cancel" in value && + typeof value["cancel"] === "function" + ); + } +} + +/** + * Create a function that returns a {@link CancelablePromise} and delegates cancelation internally to other async logic. + * + * The output function invokes the supplied {@link executor} with an additional "cancelable" argument. This function + * wraps supported types (currently {@link CancelablePromise}, {@link CancelableAsyncIterator} and {@link Promise}) with + * cancelation logic. + * + * Any such wrapped object behaves normally but will throw with the cancelation reason on cancel. + */ +export function Cancelable( + executor: Cancelable.Executor, +) { + // The proxy that invokes the executor with a "canceable" argument used for delegation + return function cancelable(this: ThisT, ...args: ArgsT): CancelablePromise { + // Active delegates register here + let delegates: undefined | Set<(reason: any) => void>; + + // The return value from the proxy function + return new CancelablePromise( + (resolve, reject) => { + // Invoke the executor + try { + const result = executor.call(this, cancelable, ...args); + + if (MaybePromise.is(result)) { + result.then(resolve, reject); + return; + } + + resolve(result); + } catch (e) { + reject(e); + } + + // We pass this function to the executor; the executor invokes on supported objects to perform + // delegation + function cancelable(value: T): T { + // Delegation to cancelable promise - just cancel when we are canceled; unregister when the target + // resolves + if (CancelablePromise.is(value)) { + const undelegate = addDelegate(reason => value.cancel(reason)); + return value.finally(undelegate) as T; + } + + // Delegation to cancelable async iterators - cancel when we cancel; unregister when iteration + // completes + if (CancelableAsyncIterator.is(value)) { + const undelegate = addDelegate(reason => value.cancel(reason)); + const next = value.next.bind(value); + value.next = () => { + return next().then( + result => { + if (result.done) { + undelegate(); + } + return result; + }, + reason => { + undelegate(); + throw reason; + }, + ); + }; + + return value; + } + + // Delegation to non-cancelable promises - use a race to abort the wait but operation will proceed + if (MaybePromise.is(value)) { + const { promise, rejecter } = createPromise(); + const undelegate = addDelegate(reason => rejecter(reason)); + return Promise.race([promise, value]).finally(() => undelegate) as T; + } + + // No delegation possible; just return the original value + return value; + } + }, + + // Our "onCancel" that delegates to any registered delegators or simply rethrows if no delegation is active + reason => { + if (!delegates?.size) { + throw reason; + } + + for (const delegate of delegates) { + delegate(reason); + } + }, + ); + + // Register a delegate + function addDelegate(delegate: (reason: any) => void) { + if (!delegates) { + delegates = new Set(); + } + delegates.add(delegate); + + return () => { + delegates?.delete(delegate); + }; + } + }; +} + +export namespace Cancelable { + export interface Delegator { + (value: T): T; + } + + export interface Executor { + (this: ThisT, cancelable: Delegator, ...args: ArgsT): MaybePromise; + } +} diff --git a/packages/general/src/util/Observable.ts b/packages/general/src/util/Observable.ts index 2e33be6b0..cfca02c15 100644 --- a/packages/general/src/util/Observable.ts +++ b/packages/general/src/util/Observable.ts @@ -434,7 +434,7 @@ export class ObservableProxy extends BasicObservable { Object.defineProperty(this.#emitter, observant, { get() { - return this.isObserved; + return super.isObserved; }, }); diff --git a/packages/general/src/util/Promises.ts b/packages/general/src/util/Promises.ts index 61358e3b9..c12303335 100644 --- a/packages/general/src/util/Promises.ts +++ b/packages/general/src/util/Promises.ts @@ -291,55 +291,3 @@ export const MaybePromise = { }; MaybePromise.toString = () => "MaybePromise"; - -/** - * A "promise" that may be canceled. - * - * Behaviors like a normal promise but does not actually extend {@link Promise} because that makes extension a PITA. - */ -export class CancelablePromise implements Promise { - #promise: Promise; - - constructor( - executor: (resolve: (value: T | PromiseLike) => void, reject: (reason?: any) => void) => void, - onCancel?: () => void, - ) { - this.#promise = new Promise(executor); - if (onCancel !== undefined) { - this.cancel = onCancel; - } - } - - cancel() {} - - then( - onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, - ): CancelablePromise { - const result = this.#promise.then(onfulfilled, onrejected) as CancelablePromise; - result.cancel = this.cancel.bind(this); - return result; - } - - catch( - onrejected?: ((reason: any) => TResult | PromiseLike) | null, - ): CancelablePromise { - return this.then(onrejected); - } - - finally(onfinally?: (() => void) | null): CancelablePromise { - const handler = (result: any) => { - onfinally?.(); - return result; - }; - return this.then(handler, handler); - } - - get [Symbol.toStringTag]() { - return this.#promise[Symbol.toStringTag]; - } - - static is(value: MaybePromise): value is CancelablePromise { - return MaybePromise.is(value) && typeof (value as CancelablePromise).cancel === "function"; - } -} diff --git a/packages/general/src/util/index.ts b/packages/general/src/util/index.ts index d99e31358..5cf969695 100644 --- a/packages/general/src/util/index.ts +++ b/packages/general/src/util/index.ts @@ -8,6 +8,7 @@ export * from "./Array.js"; export * from "./Boot.js"; export * from "./Bytes.js"; export * from "./Cache.js"; +export * from "./Cancelable.js"; export * from "./Construction.js"; export * from "./DataReader.js"; export * from "./DataReadQueue.js"; diff --git a/packages/node/test/behavior/state/transaction/TransactionTest.ts b/packages/general/test/transaction/TransactionTest.ts similarity index 81% rename from packages/node/test/behavior/state/transaction/TransactionTest.ts rename to packages/general/test/transaction/TransactionTest.ts index 460966158..06f6b19e5 100644 --- a/packages/node/test/behavior/state/transaction/TransactionTest.ts +++ b/packages/general/test/transaction/TransactionTest.ts @@ -8,15 +8,11 @@ import { FinalizationError, SynchronousTransactionConflictError, TransactionDeadlockError, -} from "#behavior/state/transaction/Errors.js"; -import { Participant } from "#behavior/state/transaction/Participant.js"; -import { Resource } from "#behavior/state/transaction/Resource.js"; -import { Status } from "#behavior/state/transaction/Status.js"; -import { Transaction } from "#behavior/state/transaction/Transaction.js"; -import { MaybePromise } from "#general"; -import { StatusCode, StatusResponseError } from "#types"; - -class TestResource implements Resource { +} from "#transaction/errors.js"; +import { Transaction } from "#transaction/Transaction.js"; +import { MaybePromise } from "#util/Promises.js"; + +class TestResource implements Transaction.Resource { lockedBy?: Transaction; constructor(public description = "TestResource") {} @@ -26,13 +22,15 @@ class TestResource implements Resource { } } -interface TestParticipant extends Participant { +interface TestParticipant extends Transaction.Participant { invoked: string[]; expect(...invokes: string[]): void; } -function TestParticipant(options?: Partial) { +class SomeError extends Error {} + +function TestParticipant(options?: Partial) { return { toString() { return "TestParticipant"; @@ -75,7 +73,7 @@ function validateUnlocked(transaction: Transaction) { } } -export interface JoinOptions extends Partial { +export interface JoinOptions extends Partial { transaction?: Transaction; postCommit?: () => MaybePromise; } @@ -233,11 +231,11 @@ describe("Transaction", () => { }, }); - expect(transaction.status).equals(Status.Shared); + expect(transaction.status).equals(Transaction.Status.Shared); await transaction.begin(); - expect(transaction.status).equals(Status.Exclusive); + expect(transaction.status).equals(Transaction.Status.Exclusive); await transaction.commit(); - expect(transaction.status).equals(Status.Shared); + expect(transaction.status).equals(Transaction.Status.Shared); p.expect("commit1", "commit2", "postCommit"); validateUnlocked(transaction); @@ -246,11 +244,11 @@ describe("Transaction", () => { test("flows through rollback correctly", async () => { const p = join(); - expect(transaction.status).equals(Status.Shared); + expect(transaction.status).equals(Transaction.Status.Shared); await transaction.begin(); - expect(transaction.status).equals(Status.Exclusive); + expect(transaction.status).equals(Transaction.Status.Exclusive); await transaction.rollback(); - expect(transaction.status).equals(Status.Shared); + expect(transaction.status).equals(Transaction.Status.Shared); p.expect("rollback"); validateUnlocked(transaction); @@ -284,13 +282,13 @@ describe("Transaction", () => { test("synchronously", () => { const p = join({ preCommit: () => { - throw new Error("oops in sync participant"); + throw new SomeError("oops in sync participant"); }, }); transaction.beginSync(); - expect(() => transaction.commit()).throws(FinalizationError); + expect(() => transaction.commit()).throws(SomeError); p.expect("rollback"); validateUnlocked(transaction); @@ -299,7 +297,7 @@ describe("Transaction", () => { test("asychonously", async () => { const p = join({ preCommit: () => { - throw new Error("oops in sync participant"); + throw new SomeError("oops in sync participant"); }, async rollback() {}, @@ -307,39 +305,7 @@ describe("Transaction", () => { await transaction.begin(); - await expect(transaction.commit()).rejectedWith(FinalizationError); - - p.expect("rollback"); - validateUnlocked(transaction); - }); - - test("synchronously with StatusResponseError", () => { - const p = join({ - preCommit: () => { - throw new StatusResponseError("oops in sync participant", StatusCode.ResourceExhausted); - }, - }); - - transaction.beginSync(); - - expect(() => transaction.commit()).throws(StatusResponseError); - - p.expect("rollback"); - validateUnlocked(transaction); - }); - - test("asychonously with StatusResponseError", async () => { - const p = join({ - preCommit: () => { - throw new StatusResponseError("oops in sync participant", StatusCode.ResourceExhausted); - }, - - async rollback() {}, - }); - - await transaction.begin(); - - await expect(transaction.commit()).rejectedWith(StatusResponseError); + await expect(transaction.commit()).rejectedWith(SomeError); p.expect("rollback"); validateUnlocked(transaction); @@ -415,27 +381,7 @@ describe("Transaction", () => { test("on becoming exclusive & committing", async () => { join({ preCommit: async () => { - throw new Error("oops in async participant"); - }, - }); - - const resource = new TestResource(); - await transaction.addResources(resource); - - await transaction.begin(); - - expect(resource.lockedBy).equals(transaction); - - await expect(transaction.commit()).rejectedWith(FinalizationError); - expect(resource.lockedBy).undefined; - }); - }); - - describe("asynchronously with precommit StatusResponseError", () => { - test("on becoming exclusive & committing", async () => { - join({ - preCommit: async () => { - throw new StatusResponseError("oops in async participant", StatusCode.ResourceExhausted); + throw new SomeError("oops in async participant"); }, }); @@ -446,7 +392,7 @@ describe("Transaction", () => { expect(resource.lockedBy).equals(transaction); - await expect(transaction.commit()).rejectedWith(StatusResponseError); + await expect(transaction.commit()).rejectedWith(SomeError); expect(resource.lockedBy).undefined; }); }); @@ -485,27 +431,7 @@ describe("Transaction", () => { test("on adding to exclusive & committing", async () => { join({ preCommit: async () => { - throw new Error("oops in async participant"); - }, - }); - - transaction.beginSync(); - - const resource = new TestResource(); - transaction.addResourcesSync(resource); - - expect(resource.lockedBy).equals(transaction); - - await expect(transaction.commit()).rejectedWith(FinalizationError); - expect(resource.lockedBy).undefined; - }); - }); - - describe("synchronously with precommit StatusResponseError", () => { - test("on adding to exclusive & committing", async () => { - join({ - preCommit: async () => { - throw new StatusResponseError("oops in async participant", StatusCode.ResourceExhausted); + throw new SomeError("oops in async participant"); }, }); @@ -516,7 +442,7 @@ describe("Transaction", () => { expect(resource.lockedBy).equals(transaction); - await expect(transaction.commit()).rejectedWith(StatusResponseError); + await expect(transaction.commit()).rejectedWith(SomeError); expect(resource.lockedBy).undefined; }); }); diff --git a/packages/matter.js/src/compat/behavior.ts b/packages/matter.js/src/compat/behavior.ts index 630925cbb..81a02d048 100644 --- a/packages/matter.js/src/compat/behavior.ts +++ b/packages/matter.js/src/compat/behavior.ts @@ -5,7 +5,6 @@ */ export { - AccessControl, Behavior, ClusterBehavior, CommissioningServer as CommissioningBehavior, diff --git a/packages/matter.js/src/device/CachedClientNodeStore.ts b/packages/matter.js/src/device/CachedClientNodeStore.ts index 8da03e0e2..f2e9d5ba2 100644 --- a/packages/matter.js/src/device/CachedClientNodeStore.ts +++ b/packages/matter.js/src/device/CachedClientNodeStore.ts @@ -6,8 +6,7 @@ import type { StorageContext } from "#general"; import { Construction, Logger } from "#general"; -import { Val } from "#node"; -import { DecodedAttributeReportValue, PeerDataStore } from "#protocol"; +import { DecodedAttributeReportValue, PeerDataStore, Val } from "#protocol"; import { AttributeId, ClusterId, EndpointNumber, EventNumber } from "#types"; import { ClientEndpointStore } from "./ClientEndpointStore.js"; diff --git a/packages/matter.js/src/device/ClientEndpointStore.ts b/packages/matter.js/src/device/ClientEndpointStore.ts index 68344f317..d41632a32 100644 --- a/packages/matter.js/src/device/ClientEndpointStore.ts +++ b/packages/matter.js/src/device/ClientEndpointStore.ts @@ -6,7 +6,8 @@ import { isDeepEqual } from "#compat/util.js"; import { Construction, StorageContext } from "#general"; -import { EndpointStore, Val } from "#node"; +import { EndpointStore } from "#node"; +import { Val } from "@matter/protocol"; /** * This is a simple and pragmatic adoption for EndpointStore for Client usecases to cache subscription data. diff --git a/packages/matter.js/src/device/LegacyInteractionServer.ts b/packages/matter.js/src/device/LegacyInteractionServer.ts index 63943deff..80ebd6aa0 100644 --- a/packages/matter.js/src/device/LegacyInteractionServer.ts +++ b/packages/matter.js/src/device/LegacyInteractionServer.ts @@ -81,7 +81,7 @@ export class LegacyInteractionServer extends InteractionServer { if ( !aclManager.allowsPrivilege( exchange.session as SecureSession, - { number: endpoint.number, deviceTypes: [endpoint.deviceType] }, + { id: endpoint.number, deviceTypes: [endpoint.deviceType] }, clusterId, desiredAccessLevel, ) diff --git a/packages/model/package.json b/packages/model/package.json index 5e1373bf9..5ad7e5d98 100644 --- a/packages/model/package.json +++ b/packages/model/package.json @@ -37,8 +37,8 @@ "@noble/curves": "^1.8.1" }, "devDependencies": { - "@matter/tools": "*", - "@matter/testing": "*" + "@matter/testing": "*", + "@matter/tools": "*" }, "files": [ "dist/**/*", diff --git a/packages/model/src/common/Schema.ts b/packages/model/src/common/Schema.ts new file mode 100644 index 000000000..ca6d0fe5d --- /dev/null +++ b/packages/model/src/common/Schema.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ClusterModel } from "#models/ClusterModel.js"; +import { DatatypeModel } from "#models/DatatypeModel.js"; +import { ValueModel } from "#models/ValueModel.js"; + +/** + * Here we use the term "schema" to mean any model element that defines a datatype. For schema we allow any Matter + * model that defines a datatype. + * + * Most schema is a {@link ValueModel} which explicitly models data. {@link ClusterModel} is also valid schema. + * + * You will see references to "structs" and "lists" throughout our code. These are Matter's two container types and map + * to JS objects and arrays respectively. Thus we tend to use struct/object and list/array interchangeably. + * + * If schema is a {@link ClusterModel}, it models a struct with attributes as fields. + * + * TODO - move to @matter/model + */ +export type Schema = ClusterModel | ValueModel; + +export namespace Schema { + export const empty = new DatatypeModel({ name: "Empty", type: "struct" }); +} diff --git a/packages/model/src/common/index.ts b/packages/model/src/common/index.ts index a53465d61..fb23bcb26 100644 --- a/packages/model/src/common/index.ts +++ b/packages/model/src/common/index.ts @@ -12,4 +12,5 @@ export * from "./errors.js"; export * from "./FeatureSet.js"; export * from "./FieldValue.js"; export * from "./Metatype.js"; +export * from "./Schema.js"; export * from "./Specification.js"; diff --git a/packages/model/src/models/Model.ts b/packages/model/src/models/Model.ts index 610e2d1ed..01f69959b 100644 --- a/packages/model/src/models/Model.ts +++ b/packages/model/src/models/Model.ts @@ -574,6 +574,9 @@ export abstract class Model { if (this.#children !== undefined && this.#children.length) { props.children = this.#children.length; } + if (!inspect) { + inspect = (value: unknown) => `${value}`; + } return `${inspect(props, options)}`.replace(/^\{/, `${decamelize(this.tag)} {`); } } diff --git a/packages/node/src/behavior/Behavior.ts b/packages/node/src/behavior/Behavior.ts index ebffe6b94..f043b57bd 100644 --- a/packages/node/src/behavior/Behavior.ts +++ b/packages/node/src/behavior/Behavior.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Agent, INSTALL_BEHAVIOR } from "#endpoint/Agent.js"; +import { type Agent, INSTALL_BEHAVIOR } from "#endpoint/Agent.js"; import { AsyncObservable, EventEmitter, @@ -13,16 +13,16 @@ import { MaybePromise, NotImplementedError, Observable, + Transaction, } from "#general"; +import { Schema } from "#model"; +import { assertSecureSession } from "#protocol"; import type { ClusterType } from "#types"; -import { assertSecureSession } from "@matter/protocol"; import { Reactor } from "./Reactor.js"; import type { BehaviorBacking } from "./internal/BehaviorBacking.js"; import { DerivedState, EmptyState } from "./state/StateType.js"; -import { Resource } from "./state/transaction/Resource.js"; import { BehaviorSupervisor } from "./supervision/BehaviorSupervisor.js"; import { RootSupervisor } from "./supervision/RootSupervisor.js"; -import { Schema } from "./supervision/Schema.js"; // Internal fields const BACKING = Symbol("endpoint-owner"); @@ -226,6 +226,15 @@ export abstract class Behavior { (this as unknown as Internal)[BACKING].reactTo(observable, reactor, options); } + /** + * Stop reacting to specified conditions. + * + * @param selector the observable and/or reactor to disable; if omitted terminates all reaction + */ + protected stopReacting(selector?: Reactor.Selector) { + return (this as unknown as Internal)[BACKING].stopReacting(selector); + } + /** * Create a generic callback function that has the same properties as a {@link Reactor}. * @@ -293,7 +302,7 @@ export abstract class Behavior { */ static dependencies?: Iterable; - get [Resource.reference]() { + get [Transaction.Resource.reference]() { return (this as unknown as Internal)[BACKING].datasource; } } diff --git a/packages/node/src/behavior/Reactor.ts b/packages/node/src/behavior/Reactor.ts index 78b015009..6982b055d 100644 --- a/packages/node/src/behavior/Reactor.ts +++ b/packages/node/src/behavior/Reactor.ts @@ -5,10 +5,9 @@ */ import type { Endpoint } from "#endpoint/Endpoint.js"; -import type { Observable, Observer } from "#general"; +import type { Observable, Observer, Transaction } from "#general"; import { MaybePromise } from "#general"; import type { Behavior } from "./Behavior.js"; -import { Resource } from "./state/transaction/Resource.js"; /** * A reactor is an {@link Observer} managed by a {@link Behavior}. You install reactors using {@link Behavior.reactTo} @@ -70,11 +69,19 @@ export namespace Reactor { * * - The reaction will defer until the resource locks become available */ - lock?: true | Resource | Resource[]; + lock?: true | Transaction.Resource | Transaction.Resource[]; /** * Only react the next time the event emits. */ once?: boolean; } + + /** + * Filter for selecting reactors. + */ + export interface Selector { + observable?: Observable; + reactor?: Reactor; + } } diff --git a/packages/node/src/behavior/cluster/ClusterBehavior.ts b/packages/node/src/behavior/cluster/ClusterBehavior.ts index 241e33ae6..9229ce730 100644 --- a/packages/node/src/behavior/cluster/ClusterBehavior.ts +++ b/packages/node/src/behavior/cluster/ClusterBehavior.ts @@ -6,11 +6,11 @@ import type { Agent } from "#endpoint/Agent.js"; import { AsyncObservable, EventEmitter, ImplementationError, MaybePromise, Observable } from "#general"; +import type { Schema } from "#model"; import { ClusterComposer, ClusterType, ElementModifier, TypeFromBitSchema } from "#types"; import { Behavior } from "../Behavior.js"; import type { BehaviorBacking } from "../internal/BehaviorBacking.js"; import type { RootSupervisor } from "../supervision/RootSupervisor.js"; -import { Schema } from "../supervision/Schema.js"; import { NetworkBehavior } from "../system/network/NetworkBehavior.js"; import { ExtensionInterfaceOf, createType, type ClusterOf } from "./ClusterBehaviorUtil.js"; import type { ClusterEvents } from "./ClusterEvents.js"; diff --git a/packages/node/src/behavior/cluster/ClusterBehaviorCache.ts b/packages/node/src/behavior/cluster/ClusterBehaviorCache.ts index a25292749..918952b4d 100644 --- a/packages/node/src/behavior/cluster/ClusterBehaviorCache.ts +++ b/packages/node/src/behavior/cluster/ClusterBehaviorCache.ts @@ -6,8 +6,8 @@ import { Behavior } from "#behavior/Behavior.js"; import { ClusterBehavior } from "#index.js"; +import { Schema } from "#model"; import { ClusterType } from "#types"; -import { Schema } from "../supervision/Schema.js"; /** * To save memory we cache behavior implementations specialized for specific clusters. This allows for efficient diff --git a/packages/node/src/behavior/cluster/ClusterBehaviorUtil.ts b/packages/node/src/behavior/cluster/ClusterBehaviorUtil.ts index c406c139b..6106bb5be 100644 --- a/packages/node/src/behavior/cluster/ClusterBehaviorUtil.ts +++ b/packages/node/src/behavior/cluster/ClusterBehaviorUtil.ts @@ -5,6 +5,7 @@ */ import { AsyncObservable, camelize, GeneratedClass, ImplementationError } from "#general"; +import type { Schema } from "#model"; import { ClusterModel, DefaultValue, @@ -16,11 +17,10 @@ import { Scope, ValueModel, } from "#model"; +import { Val } from "#protocol"; import { Attribute, ClusterType } from "#types"; import { Behavior } from "../Behavior.js"; import { DerivedState } from "../state/StateType.js"; -import { Val } from "../state/Val.js"; -import { Schema } from "../supervision/Schema.js"; import type { ClusterBehavior } from "./ClusterBehavior.js"; import { ClusterBehaviorCache } from "./ClusterBehaviorCache.js"; diff --git a/packages/node/src/behavior/context/ActionTracer.ts b/packages/node/src/behavior/context/ActionTracer.ts index ed7a25424..3234010aa 100644 --- a/packages/node/src/behavior/context/ActionTracer.ts +++ b/packages/node/src/behavior/context/ActionTracer.ts @@ -5,8 +5,8 @@ */ import { DataModelPath } from "#model"; +import { Val } from "#protocol"; import { StatusCode } from "#types"; -import { Val } from "../state/Val.js"; /** * This is an instrumentation interface that allows for recording of attribute I/O, commands, events and state diff --git a/packages/node/src/behavior/context/server/ContextAgents.ts b/packages/node/src/behavior/context/server/ContextAgents.ts index 210fc06b2..efb8aaa5c 100644 --- a/packages/node/src/behavior/context/server/ContextAgents.ts +++ b/packages/node/src/behavior/context/server/ContextAgents.ts @@ -4,10 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Agent } from "#endpoint/Agent.js"; -import { Endpoint } from "#endpoint/Endpoint.js"; -import { EndpointType } from "#endpoint/type/EndpointType.js"; -import { ActionContext } from "../ActionContext.js"; +import type { Agent } from "#endpoint/Agent.js"; +import type { Endpoint } from "#endpoint/Endpoint.js"; +import type { EndpointType } from "#endpoint/type/EndpointType.js"; +import type { ActionContext } from "../ActionContext.js"; /** * Internal helper for managing agents associated with a session. diff --git a/packages/node/src/behavior/context/server/OfflineContext.ts b/packages/node/src/behavior/context/server/OfflineContext.ts index 18f3c687d..d59b7ca3e 100644 --- a/packages/node/src/behavior/context/server/OfflineContext.ts +++ b/packages/node/src/behavior/context/server/OfflineContext.ts @@ -1,14 +1,19 @@ -import { Agent } from "#endpoint/Agent.js"; -import { Endpoint } from "#endpoint/Endpoint.js"; -import { EndpointType } from "#endpoint/type/EndpointType.js"; -import { Diagnostic, MaybePromise } from "#general"; +/** + * @license + * Copyright 2022-2025 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Agent } from "#endpoint/Agent.js"; +import type { Endpoint } from "#endpoint/Endpoint.js"; +import type { EndpointType } from "#endpoint/type/EndpointType.js"; +import { Diagnostic, MaybePromise, Transaction } from "#general"; import { AccessLevel } from "#model"; -import { Transaction } from "../../state/transaction/Transaction.js"; -import { ReadOnlyTransaction } from "../../state/transaction/Tx.js"; -import { ActionContext } from "../ActionContext.js"; -import { ActionTracer } from "../ActionTracer.js"; +import { AccessControl } from "#protocol"; +import type { ActionContext } from "../ActionContext.js"; +import type { ActionTracer } from "../ActionTracer.js"; import { Contextual } from "../Contextual.js"; -import { NodeActivity } from "../NodeActivity.js"; +import type { NodeActivity } from "../NodeActivity.js"; import { ContextAgents } from "./ContextAgents.js"; export let nextInternalId = 1; @@ -77,7 +82,7 @@ export const OfflineContext = { * * Write operations will throw an error with this context. */ - ReadOnly: createOfflineContext(ReadOnlyTransaction), + ReadOnly: createOfflineContext(Transaction.ReadOnly), [Symbol.toStringTag]: "OfflineContext", }; @@ -107,9 +112,11 @@ function createOfflineContext( transaction, activity, - authorizedFor(desiredAccessLevel: AccessLevel) { + authorityAt(desiredAccessLevel: AccessLevel) { // Be as restrictive as possible. The offline flag should make this irrelevant - return desiredAccessLevel === AccessLevel.View; + return desiredAccessLevel === AccessLevel.View + ? AccessControl.Authority.Granted + : AccessControl.Authority.Unauthorized; }, agentFor(endpoint: Endpoint): Agent.Instance { diff --git a/packages/node/src/behavior/context/server/OnlineContext.ts b/packages/node/src/behavior/context/server/OnlineContext.ts index 5d8409ac5..80afd7176 100644 --- a/packages/node/src/behavior/context/server/OnlineContext.ts +++ b/packages/node/src/behavior/context/server/OnlineContext.ts @@ -4,18 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { AccessControlServer } from "#behaviors/access-control"; import { Agent } from "#endpoint/Agent.js"; import { Endpoint } from "#endpoint/Endpoint.js"; import { EndpointType } from "#endpoint/type/EndpointType.js"; -import { Diagnostic, ImplementationError, InternalError, MaybePromise } from "#general"; +import { Diagnostic, ImplementationError, InternalError, MaybePromise, Transaction } from "#general"; import { AccessLevel } from "#model"; -import type { Message } from "#protocol"; -import { AclEndpointContext, assertSecureSession, MessageExchange } from "#protocol"; +import { Node } from "#node/Node.js"; +import type { Message, NodeProtocol, SecureSession } from "#protocol"; +import { AccessControl, assertSecureSession, MessageExchange } from "#protocol"; import { FabricIndex, NodeId, StatusResponseError, SubjectId } from "#types"; -import { AccessControlServer } from "../../../behaviors/access-control/AccessControlServer.js"; -import { RootEndpoint } from "../../../endpoints/root.js"; -import { AccessControl } from "../../AccessControl.js"; -import { Transaction } from "../../state/transaction/Transaction.js"; import { ActionContext } from "../ActionContext.js"; import { ActionTracer } from "../ActionTracer.js"; import { Contextual } from "../Contextual.js"; @@ -26,121 +24,44 @@ import { ContextAgents } from "./ContextAgents.js"; * Operate in online context. Public Matter API interactions happen in online context. */ export function OnlineContext(options: OnlineContext.Options) { - return { - /** - * It can return a promise even if the actor method does not return a promise, so manual checks - * are needed. - */ - act(actor: (context: ActionContext) => MaybePromise): MaybePromise { - let agents: undefined | ContextAgents; - - let fabric: FabricIndex | undefined; - let subject: SubjectId; - - const { exchange } = options; - const session = exchange?.session; - - if (session) { - assertSecureSession(session); - fabric = session.fabric?.fabricIndex; - - // TODO - group subject - subject = session.peerNodeId; - } else { - fabric = options.fabric; - subject = options.subject as NodeId; - } + let fabric: FabricIndex | undefined; + let subject: SubjectId; + let nodeProtocol: NodeProtocol | undefined; + let accessLevelCache: Map | undefined; - if (subject === undefined) { - throw new ImplementationError("OnlineContext requires an authorized subject"); - } + const { exchange } = options; + const session = exchange?.session; - const { message } = options; - const via = Diagnostic.via( - `online#${message?.packetHeader?.messageId?.toString(16) ?? "?"}@${subject.toString(16)}`, - ); + if (session) { + assertSecureSession(session); + fabric = session.fabric?.fabricIndex; - let context: undefined | ActionContext; - let trace: undefined | ActionTracer.Action; - let activity: undefined | Disposable; + // TODO - group subject + subject = session.peerNodeId; + } else { + fabric = options.fabric; + subject = options.subject as NodeId; + } - if (options.tracer && options.actionType) { - trace = { - type: options.actionType, - }; - } - - const close = () => { - if (trace) { - options.tracer?.record(trace); - } - if (message) { - Contextual.setContextOf(message, undefined); - } - if (activity) { - activity[Symbol.dispose](); - } - }; + if (subject === undefined) { + throw new ImplementationError("OnlineContext requires an authorized subject"); + } - const actOnline = (transaction: Transaction) => { - context = { - ...options, - session, - exchange, - subject, - fabric, - transaction, - trace, - - interactionComplete: exchange?.closed, - - authorizedFor(desiredAccessLevel: AccessLevel, location?: AccessControl.Location) { - if (location === undefined) { - throw new InternalError("AccessControl.Location is required"); - } - - // We already checked access levels in this transaction, so reuse it - if (location.accessLevels !== undefined) { - return location.accessLevels.includes(desiredAccessLevel); - } - - if (options.root === undefined) { - throw new InternalError("Root endpoint is required"); - } - - const accessControl = options.root.act(agent => agent.get(AccessControlServer)); - if (MaybePromise.is(accessControl)) { - throw new InternalError("AccessControlServer should already be initialized."); - } - const accessLevels = accessControl.accessLevelsFor( - context as ActionContext, - location, - options.endpointContext, - ); - location.accessLevels = accessLevels; - return accessLevels.includes(desiredAccessLevel); - }, - - agentFor(endpoint: Endpoint): Agent.Instance { - if (!agents) { - agents = ContextAgents(context as ActionContext); - } - return agents.agentFor(endpoint); - }, - - get [Contextual.context](): ActionContext { - return this; - }, - }; - - if (message) { - Contextual.setContextOf(message, context); - } + const { message } = options; + const via = Diagnostic.via( + `online#${message?.packetHeader?.messageId?.toString(16) ?? "?"}@${subject.toString(16)}`, + ); - return actor(context); - }; + return { + /** + * Run an actor with a read/write context. + * + * If the actor changes state, this may return a promise even if {@link actor} does not return a promise. + */ + act(actor: (context: ActionContext) => MaybePromise): MaybePromise { + const { close, trace } = initialize(); - const traceError = (e: unknown) => { + const traceError = (e: unknown): never => { if (trace) { const status = (e as StatusResponseError).code; if (typeof status === "number") { @@ -150,9 +71,14 @@ export function OnlineContext(options: OnlineContext.Options) { throw e; }; + let context: undefined | ActionContext; + const actOnline = (transaction: Transaction) => { + context = createContext(transaction, trace); + return actor(context); + }; + let isAsync = false; try { - activity = options.activity?.frame(via); const result = Transaction.act(via, actOnline); if (MaybePromise.is(result)) { isAsync = true; @@ -162,7 +88,8 @@ export function OnlineContext(options: OnlineContext.Options) { } catch (e) { traceError(e); - // traceError does this but TS isn't smart enough to notice. This is never reached + // TS should not require this because traceError returns never, but it complains without it. This never + // happens throw e; } finally { if (!isAsync && context) { @@ -171,12 +98,150 @@ export function OnlineContext(options: OnlineContext.Options) { } }, + /** + * Begin an operation with a read-only context. You must close the context after use to properly deregister + * activity. + */ + beginReadOnly() { + const { close, trace } = initialize(); + + const context = createContext(Transaction.ReadOnly, trace) as OnlineContext.ReadOnly; + context[Symbol.dispose] = close; + + return context; + }, + [Symbol.toStringTag]: "OnlineContext", }; + + /** + * Initialization stage one - initialize everything common to r/o and r/w contexts + */ + function initialize() { + let trace: undefined | ActionTracer.Action; + const activity = options.activity?.frame(via); + + if (options.tracer && options.actionType) { + trace = { + type: options.actionType, + }; + } + + const close = () => { + if (trace) { + options.tracer?.record(trace); + } + if (message) { + Contextual.setContextOf(message, undefined); + } + if (activity) { + activity[Symbol.dispose](); + } + }; + + return { + close, + trace, + }; + } + + /** + * Initialization stage two - create context object after obtaining transaction + */ + function createContext(transaction: Transaction, trace?: ActionTracer.Action) { + let agents: undefined | ContextAgents; + const context: ActionContext = { + ...options, + session: session as SecureSession | undefined, + exchange, + subject, + fabric, + transaction, + trace, + + interactionComplete: exchange?.closed, + + // TODO - Matter 1.4 - add support for ARLs + authorityAt(desiredAccessLevel: AccessLevel, location?: AccessControl.Location) { + if (location === undefined) { + throw new InternalError("AccessControl.Location is required"); + } + + // We already checked access levels in this transaction, so reuse it + const cachedAccessLevels = accessLevelCache?.get(location); + if (cachedAccessLevels !== undefined) { + return cachedAccessLevels.includes(desiredAccessLevel) + ? AccessControl.Authority.Granted + : AccessControl.Authority.Unauthorized; + } + + if (options.node === undefined) { + throw new InternalError("OnlineContext initialized without node"); + } + + const accessControl = options.node.act(agent => agent.get(AccessControlServer)); + if (MaybePromise.is(accessControl)) { + throw new InternalError("AccessControlServer should already be initialized."); + } + const accessLevels = accessControl.accessLevelsFor(context, location, aclEndpointContextFor(location)); + + if (accessLevelCache === undefined) { + accessLevelCache = new Map(); + } + accessLevelCache.set(location, accessLevels); + + return accessLevels.includes(desiredAccessLevel) + ? AccessControl.Authority.Granted + : AccessControl.Authority.Unauthorized; + }, + + agentFor(endpoint: Endpoint): Agent.Instance { + if (!agents) { + agents = ContextAgents(context); + } + return agents.agentFor(endpoint); + }, + + get [Contextual.context](): ActionContext { + return this; + }, + }; + + if (message) { + Contextual.setContextOf(message, context); + } + + return context; + } + + /** + * Access endpoint metadata required for access control. + */ + function aclEndpointContextFor({ endpoint: number }: AccessControl.Location) { + if (number === undefined) { + throw new InternalError("Online location missing required endpoint number"); + } + + if (options.node === undefined) { + throw new InternalError("Online context has no node defined"); + } + + if (nodeProtocol === undefined) { + nodeProtocol = options.node.protocol; + } + + const endpoint = nodeProtocol[number]; + if (endpoint === undefined) { + throw new InternalError(`Unknown endpoint number ${number} in access control location`); + } + + return endpoint; + } } export namespace OnlineContext { export type Options = { + node: Node; activity?: NodeActivity.Activity; command?: boolean; timed?: boolean; @@ -184,10 +249,12 @@ export namespace OnlineContext { message?: Message; tracer?: ActionTracer; actionType?: ActionTracer.ActionType; - endpointContext?: AclEndpointContext; - root?: Endpoint; } & ( | { exchange: MessageExchange; fabric?: undefined; subject?: undefined } | { exchange?: undefined; fabric: FabricIndex; subject: SubjectId } ); + + export interface ReadOnly extends ActionContext { + [Symbol.dispose](): void; + } } diff --git a/packages/node/src/behavior/index.ts b/packages/node/src/behavior/index.ts index f72a5baf6..871e77937 100644 --- a/packages/node/src/behavior/index.ts +++ b/packages/node/src/behavior/index.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from "./AccessControl.js"; export * from "./Behavior.js"; export * from "./cluster/index.js"; export * from "./context/index.js"; diff --git a/packages/node/src/behavior/internal/BackingEvents.ts b/packages/node/src/behavior/internal/BackingEvents.ts index 5dca4c7f3..4c025e237 100644 --- a/packages/node/src/behavior/internal/BackingEvents.ts +++ b/packages/node/src/behavior/internal/BackingEvents.ts @@ -6,7 +6,7 @@ import type { Endpoint } from "#endpoint/Endpoint.js"; import { EventEmitter, GeneratedClass, Observable, ObservableProxy } from "#general"; -import { BehaviorBacking } from "./BehaviorBacking.js"; +import type { BehaviorBacking } from "./BehaviorBacking.js"; type Implementation = new (target: EventEmitter) => EventEmitter; diff --git a/packages/node/src/behavior/internal/BehaviorBacking.ts b/packages/node/src/behavior/internal/BehaviorBacking.ts index 6c2b3f981..6f9c1a8b5 100644 --- a/packages/node/src/behavior/internal/BehaviorBacking.ts +++ b/packages/node/src/behavior/internal/BehaviorBacking.ts @@ -169,14 +169,17 @@ export abstract class BehaviorBacking { protected get datasourceOptions(): Datasource.Options { return { - path: this.#endpoint.path.at(this.#type.id).at("state"), + location: { + path: this.#endpoint.path.at(this.#type.id).at("state"), + endpoint: this.#endpoint.number, + cluster: this.type.schema?.tag === "cluster" ? (this.type.schema.id as ClusterId) : undefined, + }, supervisor: this.type.supervisor, type: this.type.State, events: this.events as unknown as Datasource.Events, defaults: this.#endpoint.behaviors.defaultsFor(this.type), store: this.store, owner: this.#endpoint, - cluster: this.type.schema?.tag === "cluster" ? (this.type.schema.id as ClusterId) : undefined, }; } @@ -239,6 +242,23 @@ export abstract class BehaviorBacking { this.#reactors.add(observable, reactor, options); } + /** + * Terminate reactions. + */ + async stopReacting(selector?: { observable?: Observable; reactor?: Reactor }) { + if (this.#reactors === undefined) { + return; + } + + if (selector?.observable === undefined && selector?.reactor === undefined) { + await this.#reactors.close(); + this.#reactors = undefined; + return; + } + + await this.#reactors.remove(selector); + } + /** * Invoke {@link Behavior.destroy} to clean up application logic. */ diff --git a/packages/node/src/behavior/internal/Reactors.ts b/packages/node/src/behavior/internal/Reactors.ts index 4a5baebf9..270e0d498 100644 --- a/packages/node/src/behavior/internal/Reactors.ts +++ b/packages/node/src/behavior/internal/Reactors.ts @@ -4,15 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Endpoint } from "#endpoint/Endpoint.js"; -import type { Observable, Observer } from "#general"; -import { asError, ImplementationError, InternalError, Logger, MaybePromise } from "#general"; -import { Reactor } from "../Reactor.js"; -import { ActionContext } from "../context/ActionContext.js"; +import type { Endpoint } from "#endpoint/Endpoint.js"; +import type { Observable, Observer, Transaction } from "#general"; +import { asError, ImplementationError, InternalError, Logger, MatterAggregateError, MaybePromise } from "#general"; +import type { Reactor } from "../Reactor.js"; +import type { ActionContext } from "../context/ActionContext.js"; import { Contextual } from "../context/Contextual.js"; import { NodeActivity } from "../context/NodeActivity.js"; import { OfflineContext } from "../context/server/OfflineContext.js"; -import { Resource } from "../state/transaction/Resource.js"; import type { BehaviorBacking } from "./BehaviorBacking.js"; const logger = Logger.get("Reactors"); @@ -59,7 +58,20 @@ export class Reactors { this.#backings.add(new ReactorBacking(this, observable, reactor, options ?? {})); } - remove(backing: ReactorBacking) { + remove(selector: Reactor.Selector) { + const toRemove = Array>(); + for (const backing of this.#backings) { + if (backing.is(selector.observable, selector.reactor)) { + toRemove.push(backing); + } + } + return MatterAggregateError.allSettled( + toRemove.map(backing => backing.close()), + "Error removing reactors", + ); + } + + deleteClosedBacking(backing: ReactorBacking) { this.#backings.delete(backing); if (!this.#backings.size) { this.#destructionComplete?.(); @@ -82,7 +94,7 @@ class ReactorBacking { #observable: Observable; #offline?: boolean; #closing?: boolean; - #lock?: Iterable; + #lock?: Iterable; #deferred = Array<() => Promise>(); #trampoline?: Promise; #resolveTrampoline?: () => void; @@ -107,7 +119,7 @@ class ReactorBacking { } else if (!Array.isArray(lock)) { lock = [lock]; } - this.#lock = lock as Resource[]; + this.#lock = lock as Transaction.Resource[]; } const reactorListener = ((...args: T) => { @@ -193,8 +205,12 @@ class ReactorBacking { observable.on((this.#listener = reactorListener)); } - is(observable: Observable, reactor: Reactor) { - return this.#observable === observable && this.#reactor === reactor && !this.#closing; + is(observable?: Observable, reactor?: Reactor) { + return ( + (observable === undefined || this.#observable === observable) && + (reactor === undefined || this.#reactor === reactor) && + !this.#closing + ); } close() { @@ -218,9 +234,9 @@ class ReactorBacking { this.#observable.off(this.#listener); this.#closing = true; if (this.#trampoline) { - this.#trampoline = this.#trampoline.finally(() => this.#owner.remove(this)); + this.#trampoline = this.#trampoline.finally(() => this.#owner.deleteClosedBacking(this)); } else { - this.#owner.remove(this); + this.#owner.deleteClosedBacking(this); } } diff --git a/packages/node/src/behavior/internal/ServerBehaviorBacking.ts b/packages/node/src/behavior/internal/ServerBehaviorBacking.ts index 0acddf98f..5818f0874 100644 --- a/packages/node/src/behavior/internal/ServerBehaviorBacking.ts +++ b/packages/node/src/behavior/internal/ServerBehaviorBacking.ts @@ -11,9 +11,9 @@ import type { SupportedElements } from "#endpoint/index.js"; import { camelize } from "#general"; import { FieldValue } from "#model"; import { ServerNodeStore } from "#node/storage/ServerNodeStore.js"; +import { Val } from "#protocol"; import { ClusterType, TlvNoResponse } from "#types"; import { Behavior } from "../Behavior.js"; -import { Val } from "../state/Val.js"; import { Datasource } from "../state/managed/Datasource.js"; import { BehaviorBacking } from "./BehaviorBacking.js"; diff --git a/packages/node/src/behavior/state/StateType.ts b/packages/node/src/behavior/state/StateType.ts index 012c464c8..2428097b8 100644 --- a/packages/node/src/behavior/state/StateType.ts +++ b/packages/node/src/behavior/state/StateType.ts @@ -5,7 +5,7 @@ */ import { GeneratedClass } from "#general"; -import { Val } from "./Val.js"; +import { Val } from "#protocol"; /** * State is a plain JS object. diff --git a/packages/node/src/behavior/state/index.ts b/packages/node/src/behavior/state/index.ts index 4c0790ceb..31e6c3766 100644 --- a/packages/node/src/behavior/state/index.ts +++ b/packages/node/src/behavior/state/index.ts @@ -6,5 +6,3 @@ export * from "./managed/index.js"; export * from "./StateType.js"; -export * from "./transaction/index.js"; -export * from "./Val.js"; diff --git a/packages/node/src/behavior/state/managed/Datasource.ts b/packages/node/src/behavior/state/managed/Datasource.ts index c3f09434c..f0d9b9489 100644 --- a/packages/node/src/behavior/state/managed/Datasource.ts +++ b/packages/node/src/behavior/state/managed/Datasource.ts @@ -13,18 +13,14 @@ import { Logger, MaybePromise, Observable, + Transaction, } from "#general"; -import { AccessLevel, DataModelPath } from "#model"; -import { ClusterId } from "#types"; -import { AccessControl } from "../../AccessControl.js"; -import { ExpiredReferenceError } from "../../errors.js"; +import { AccessLevel } from "#model"; +import type { Val } from "#protocol"; +import { AccessControl, ExpiredReferenceError } from "#protocol"; import { RootSupervisor } from "../../supervision/RootSupervisor.js"; import { ValueSupervisor } from "../../supervision/ValueSupervisor.js"; import { StateType } from "../StateType.js"; -import type { Val } from "../Val.js"; -import { Resource } from "../transaction/Resource.js"; -import { Transaction } from "../transaction/Transaction.js"; -import { ReadOnlyTransaction } from "../transaction/Tx.js"; const logger = Logger.get("Datasource"); @@ -37,7 +33,7 @@ const FEATURES_KEY = "__features__"; * Datasources maintain a version number and triggers change events. If modified in a transaction they compute changes * and persist values as necessary. */ -export interface Datasource extends Resource { +export interface Datasource extends Transaction.Resource { /** * Create a managed version of the source data. */ @@ -57,6 +53,11 @@ export interface Datasource extends Resource { * Obtain a read-only view of values. */ readonly view: InstanceType; + + /** + * Path used in diagnostic messages. + */ + location: AccessControl.Location; } /** @@ -69,39 +70,45 @@ export function Datasource(options: Datas return { toString() { - return internals.path.toString(); + return internals.location.path.toString(); }, reference(session: ValueSupervisor.Session) { - let context = internals.sessions?.get(session); - if (!context) { - context = createSessionContext(this, internals, session); + let ref = internals.sessions?.get(session); + if (!ref) { + ref = createReference(this, internals, session); } - return context.managed as InstanceType; + return ref.managed as InstanceType; }, get version() { return internals.version; }, + get location() { + return internals.location; + }, + validate(session: ValueSupervisor.Session, values?: Val.Struct) { const validate = internals.supervisor.validate; if (!validate) { return; } - validate(values ?? internals.values, session, { path: internals.path }); + validate(values ?? internals.values, session, { path: internals.location.path }); }, get view() { if (!readOnlyView) { const session: ValueSupervisor.Session = { offline: true, - authorizedFor(desiredAccessLevel: AccessLevel) { - return desiredAccessLevel === AccessLevel.View; + authorityAt(desiredAccessLevel: AccessLevel) { + return desiredAccessLevel === AccessLevel.View + ? AccessControl.Authority.Granted + : AccessControl.Authority.Unauthorized; }, - transaction: ReadOnlyTransaction, + transaction: Transaction.ReadOnly, }; - readOnlyView = createSessionContext(this, internals, session).managed as InstanceType; + readOnlyView = createReference(this, internals, session).managed as InstanceType; } return readOnlyView; }, @@ -134,9 +141,9 @@ export namespace Datasource { supervisor: RootSupervisor; /** - * Path used in diagnostic messages. + * Data model location, used for access control and diagnostics. */ - path: DataModelPath; + location: AccessControl.Location; /** * Events triggered automatically. @@ -157,11 +164,6 @@ export namespace Datasource { */ store?: Store; - /** - * The cluster used for access control checks. - */ - cluster?: ClusterId; - /** * The object that owns the datasource. This is passed as the "owner" parameter to {@link Val.Dynamic}. */ @@ -203,8 +205,6 @@ interface SessionContext { * Internal datasource state. */ interface Internals extends Datasource.Options { - path: DataModelPath; - cluster?: ClusterId; values: Val.Struct; version: number; sessions?: Map; @@ -234,7 +234,7 @@ function configure(options: Datasource.Options): Internals { const storedFeaturesKey = storedValues?.[FEATURES_KEY]; if (storedFeaturesKey !== undefined && storedFeaturesKey !== featuresKey) { logger.warn( - `Ignoring persisted values for ${options.path} because features changed from "${storedFeaturesKey}" to "${featuresKey}"`, + `Ignoring persisted values for ${options.location.path} because features changed from "${storedFeaturesKey}" to "${featuresKey}"`, ); storedValues = undefined; } @@ -253,6 +253,9 @@ function configure(options: Datasource.Options): Internals { values[key] = initialValues[key]; } + // Location affects security so make it immutable + Object.freeze(options.location); + return { ...options, version: Crypto.getRandomUInt32(), @@ -261,7 +264,7 @@ function configure(options: Datasource.Options): Internals { interactionObserver() { function handleObserverError(error: any) { - logger.error(`Error in ${options.path} observer:`, error); + logger.error(`Error in ${options.location.path} observer:`, error); } try { @@ -281,7 +284,7 @@ function configure(options: Datasource.Options): Internals { * * This reference provides external access to the {@link Val.Struct} in the context of a specific session. */ -function createSessionContext(resource: Resource, internals: Internals, session: ValueSupervisor.Session) { +function createReference(resource: Transaction.Resource, internals: Internals, session: ValueSupervisor.Session) { let values = internals.values; let precommitValues: Val.Struct | undefined; let changes: CommitChanges | undefined; @@ -289,7 +292,7 @@ function createSessionContext(resource: Resource, internals: Internals, session: const participant = { toString() { - return internals.path.toString(); + return internals.location.path.toString(); }, preCommit, commit1, @@ -307,7 +310,7 @@ function createSessionContext(resource: Resource, internals: Internals, session: rollback(); } catch (e) { logger.error( - `Error resetting reference to ${internals.path} after reset of transaction ${transaction.via}:`, + `Error resetting reference to ${internals.location.path} after reset of transaction ${transaction.via}:`, e, ); } @@ -340,7 +343,7 @@ function createSessionContext(resource: Resource, internals: Internals, session: }, get location() { - return { path: internals.path, cluster: internals.cluster }; + return internals.location; }, set location(_loc: AccessControl.Location) { @@ -412,7 +415,7 @@ function createSessionContext(resource: Resource, internals: Internals, session: refreshSubrefs(); } catch (e) { logger.error( - `Error detaching reference to ${internals.path} from closed transaction ${transaction.via}:`, + `Error detaching reference to ${internals.location.path} from closed transaction ${transaction.via}:`, e, ); } @@ -618,7 +621,7 @@ function createSessionContext(resource: Resource, internals: Internals, session: mutations = session.trace.mutations = []; } mutations.push({ - path: internals.path, + path: internals.location.path, values: changes.persistent, }); } diff --git a/packages/node/src/behavior/state/managed/Instrumentation.ts b/packages/node/src/behavior/state/managed/Instrumentation.ts index a23f4a82a..20956d48f 100644 --- a/packages/node/src/behavior/state/managed/Instrumentation.ts +++ b/packages/node/src/behavior/state/managed/Instrumentation.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { Val } from "#protocol"; import type { Behavior } from "../../Behavior.js"; import type { ValueSupervisor } from "../../supervision/ValueSupervisor.js"; -import type { Val } from "../Val.js"; /** * Instrumentation points for the managed values used for {@link Behavior.state}. diff --git a/packages/node/src/behavior/state/managed/Internal.ts b/packages/node/src/behavior/state/managed/Internal.ts index abfa48d86..7770a418f 100644 --- a/packages/node/src/behavior/state/managed/Internal.ts +++ b/packages/node/src/behavior/state/managed/Internal.ts @@ -4,13 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Val } from "../Val.js"; +import type { Val } from "#protocol"; /** * API for bypassing managed collections and accessing the internal object containing raw data. */ export namespace Internal { export const reference = Symbol("reference"); + export const session = Symbol("session"); export interface Collection { [reference]: Val.Reference; diff --git a/packages/node/src/behavior/state/managed/ManagedReference.ts b/packages/node/src/behavior/state/managed/ManagedReference.ts index 0500a32ec..446642f97 100644 --- a/packages/node/src/behavior/state/managed/ManagedReference.ts +++ b/packages/node/src/behavior/state/managed/ManagedReference.ts @@ -4,9 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AccessControl } from "../../AccessControl.js"; -import { ExpiredReferenceError } from "../../errors.js"; -import { Val } from "../Val.js"; +import { AccessControl, ExpiredReferenceError, Val } from "#protocol"; type Container = Record; diff --git a/packages/node/src/behavior/state/managed/NameResolver.ts b/packages/node/src/behavior/state/managed/NameResolver.ts index 753b886a1..6dfce3894 100644 --- a/packages/node/src/behavior/state/managed/NameResolver.ts +++ b/packages/node/src/behavior/state/managed/NameResolver.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Val } from "#behavior/state/Val.js"; import { RootSupervisor } from "#behavior/supervision/RootSupervisor.js"; -import { Schema } from "#behavior/supervision/Schema.js"; +import type { Schema } from "#model"; +import { Val } from "#protocol"; import { camelize } from "@matter/general"; import { ClusterModel, Model, ValueModel } from "@matter/model"; import { Internal } from "./Internal.js"; diff --git a/packages/node/src/behavior/state/managed/values/BitmapManager.ts b/packages/node/src/behavior/state/managed/values/BitmapManager.ts index cfd8ff7e0..1bffbad5c 100644 --- a/packages/node/src/behavior/state/managed/values/BitmapManager.ts +++ b/packages/node/src/behavior/state/managed/values/BitmapManager.ts @@ -5,12 +5,11 @@ */ import { camelize, GeneratedClass, isObject } from "#general"; +import type { Schema } from "#model"; import { DataModelPath, FeatureMap, ValueModel } from "#model"; -import { ConstraintError, PhantomReferenceError, SchemaImplementationError } from "../../../errors.js"; +import { ConstraintError, PhantomReferenceError, SchemaImplementationError, Val } from "#protocol"; import { RootSupervisor } from "../../../supervision/RootSupervisor.js"; -import { Schema } from "../../../supervision/Schema.js"; import { ValueSupervisor } from "../../../supervision/ValueSupervisor.js"; -import { Val } from "../../Val.js"; import { assertBoolean, assertNumber } from "../../validation/assertions.js"; import { Instrumentation } from "../Instrumentation.js"; import { Internal } from "../Internal.js"; diff --git a/packages/node/src/behavior/state/managed/values/ListManager.ts b/packages/node/src/behavior/state/managed/values/ListManager.ts index a74ac82b1..b70189356 100644 --- a/packages/node/src/behavior/state/managed/values/ListManager.ts +++ b/packages/node/src/behavior/state/managed/values/ListManager.ts @@ -5,14 +5,12 @@ */ import { isObject, serialize } from "#general"; +import type { Schema } from "#model"; import { Access, DataModelPath, ValueModel } from "#model"; +import { AccessControl, ExpiredReferenceError, ReadError, SchemaImplementationError, Val, WriteError } from "#protocol"; import { StatusCode } from "#types"; -import { AccessControl } from "../../../AccessControl.js"; -import { ExpiredReferenceError, ReadError, SchemaImplementationError, WriteError } from "../../../errors.js"; import type { RootSupervisor } from "../../../supervision/RootSupervisor.js"; -import { Schema } from "../../../supervision/Schema.js"; import type { ValueSupervisor } from "../../../supervision/ValueSupervisor.js"; -import { Val } from "../../Val.js"; import { Instrumentation } from "../Instrumentation.js"; import { Internal } from "../Internal.js"; import { ManagedReference } from "../ManagedReference.js"; diff --git a/packages/node/src/behavior/state/managed/values/StructManager.ts b/packages/node/src/behavior/state/managed/values/StructManager.ts index 8690ee3b5..3228bd8eb 100644 --- a/packages/node/src/behavior/state/managed/values/StructManager.ts +++ b/packages/node/src/behavior/state/managed/values/StructManager.ts @@ -5,30 +5,77 @@ */ import { camelize, GeneratedClass, ImplementationError, isObject } from "#general"; +import type { Schema } from "#model"; import { Access, ElementTag, FieldValue, Metatype, ValueModel } from "#model"; +import { AccessControl, PhantomReferenceError, SchemaImplementationError, Val } from "#protocol"; import { FabricIndex } from "#types"; -import { AccessControl } from "../../../AccessControl.js"; -import { PhantomReferenceError, SchemaImplementationError } from "../../../errors.js"; import { RootSupervisor } from "../../../supervision/RootSupervisor.js"; -import { Schema } from "../../../supervision/Schema.js"; import type { ValueSupervisor } from "../../../supervision/ValueSupervisor.js"; -import { Val } from "../../Val.js"; import { Instrumentation } from "../Instrumentation.js"; import { Internal } from "../Internal.js"; import { ManagedReference } from "../ManagedReference.js"; import { NameResolver } from "../NameResolver.js"; import { PrimitiveManager } from "./PrimitiveManager.js"; -const SESSION = Symbol("options"); const AUTHORIZE_READ = Symbol("authorize-read"); +/** + * Internal view of struct. + */ +interface Struct extends Val.Struct { + [Internal.session]: ValueSupervisor.Session; + [Internal.reference]: Val.Reference; + + /** + * Direct read authorization. + */ + [AUTHORIZE_READ](attributeId: number): void; +} + /** * For structs we generate a class with accessors for each property in the schema. */ export function StructManager(owner: RootSupervisor, schema: Schema): ValueSupervisor.Manage { + /** + * These install on the prototype. We use this for internal methods and ID methods as we don't want them to be + * enumerable. + */ + const prototypeDescriptors = { + // TODO - interferes with Chai deep equal. Best fix would probably be a custom deep equal assertion but + // leaving out for now + // [Symbol.toStringTag]: { + // value: name, + // }, + + // TODO - makes Mocha diffs pretty useless. Best fix is probably customized diff but leaving out for now + // toString: { + // value() { + // return serialize(this); + // } + // }, + + // AUTHORIZE_READ is effectively a protected method, see StructManager.assertDirectReadAuthorized below + [AUTHORIZE_READ]: { + value(this: Struct, attributeId: number) { + const access = propertyAccessControls[attributeId]; + + if (access === undefined) { + throw new ImplementationError(`Direct read of unknown property ${attributeId}`); + } + + access.authorizeRead(this[Internal.session], this[Internal.reference].location); + }, + }, + } as PropertyDescriptorMap; + + /** + * These we define on the instance so they appear as normal object properties. + */ const instanceDescriptors = {} as PropertyDescriptorMap; - const propertyAccessControls = {} as Record; + + const propertyAccessControls = {} as Record; let hasFabricIndex = false; + const isCluster = schema.tag === ElementTag.Cluster; // Scan the schema and configure each member (field or attribute) as a property for (const member of owner.membersOf(schema)) { @@ -37,7 +84,10 @@ export function StructManager(owner: RootSupervisor, schema: Schema): ValueSuper const { access, descriptor } = configureProperty(owner, member); instanceDescriptors[name] = descriptor; - propertyAccessControls[name] = access; + if (member.id !== undefined) { + prototypeDescriptors[member.id] = { ...descriptor, enumerable: false }; + propertyAccessControls[member.id] = access; + } if (member.name === "FabricIndex") { hasFabricIndex = true; @@ -52,7 +102,16 @@ export function StructManager(owner: RootSupervisor, schema: Schema): ValueSuper const Wrapper = GeneratedClass({ name, - initialize(this: Wrapper, ref: Val.Reference, session: ValueSupervisor.Session) { + initialize(this: Struct, ref: Val.Reference, session: ValueSupervisor.Session) { + Object.defineProperties(this, { + [Internal.reference]: { + value: ref as Val.Reference, + }, + [Internal.session]: { + value: session, + }, + }); + // Only objects are acceptable if (!isObject(ref.value)) { throw new SchemaImplementationError( @@ -63,52 +122,22 @@ export function StructManager(owner: RootSupervisor, schema: Schema): ValueSuper // If we have a fabric index, update the context if (hasFabricIndex) { - const owningFabric = (ref as Val.Reference).value.fabricIndex as FabricIndex | undefined; + const owningFabric = ref.value.fabricIndex as FabricIndex | undefined; ref.location = { ...ref.location, owningFabric }; } - Object.defineProperties(this, { - [Internal.reference]: { - value: ref as Val.Reference, - }, - [SESSION]: { - value: session, - }, - }); - // Sadly we can't place instance descriptors on our prototype because then simple JS patterns that rely on // enumerating own properties (e.g. spread) won't work. At least we can reuse the accessors so they should // get JITed - Object.defineProperties(this, instanceDescriptors); + // + // The "protocol" optimization for clusters omits these fields as the attribute protocol is ID only. + // Currently outer layers do not support ID keys for fields + if (!isCluster || !session.protocol) { + Object.defineProperties(this, instanceDescriptors); + } }, - instanceDescriptors: { - // TODO - interferes with Chai deep equal. Best fix would probably be a custom deep equal assertion but - // leaving out for now - // [Symbol.toStringTag]: { - // value: name, - // }, - - // TODO - makes Mocha diffs pretty useless. Best fix is probably customized diff but leaving out for now - // toString: { - // value() { - // return serialize(this); - // } - // }, - - // AUTHORIZE_READ is effectively a protected method, see StructManager.assertDirectReadAuthorized below - [AUTHORIZE_READ]: { - value(this: Wrapper, index: string) { - const access = propertyAccessControls[index]; - - if (access === undefined) { - throw new ImplementationError(`Direct read of unknown property ${index}`); - } - - access.authorizeRead(this[SESSION], this[Internal.reference].location); - }, - }, - }, + instanceDescriptors: prototypeDescriptors, }) as new (value: Val, session: AccessControl.Session) => Val.Struct; Instrumentation.instrumentStruct(Wrapper); @@ -125,34 +154,19 @@ export namespace StructManager { * * This function instead throws an error for unauthorized access. It must be invoked before direct property reads. * + * @deprecated remove with old API + * * @param struct a managed struct - * @param index the field to read + * @param attributeId the AttributeId to read */ - export function assertDirectReadAuthorized(struct: Val.Struct, index: string) { - if (!(struct as Wrapper)?.[AUTHORIZE_READ]) { + export function assertDirectReadAuthorized(struct: Val.Struct, attributeId: number) { + if (!(struct as Struct)?.[AUTHORIZE_READ]) { throw new ImplementationError("Cannot authorize read of unmanaged value"); } - return (struct as Wrapper)[AUTHORIZE_READ](index); + return (struct as Struct)[AUTHORIZE_READ](attributeId); } } -interface Wrapper extends Val.Struct, Internal.Collection { - /** - * A reference to the raw value. - */ - [Internal.reference]: Val.Reference; - - /** - * Information regarding the current user session. - */ - [SESSION]: ValueSupervisor.Session; - - /** - * Direct read authorization. - */ - [AUTHORIZE_READ]: (index: string) => void; -} - function configureProperty(supervisor: RootSupervisor, schema: ValueModel) { const name = camelize(schema.name); @@ -172,8 +186,8 @@ function configureProperty(supervisor: RootSupervisor, schema: ValueModel) { const descriptor: PropertyDescriptor = { enumerable: true, - set(this: Wrapper, value: Val) { - access.authorizeWrite(this[SESSION], this[Internal.reference].location); + set(this: Struct, value: Val) { + access.authorizeWrite(this[Internal.session], this[Internal.reference].location); const oldValue = this[Internal.reference].value[name]; @@ -187,7 +201,7 @@ function configureProperty(supervisor: RootSupervisor, schema: ValueModel) { if ((struct as Val.Dynamic)[Val.properties]) { const properties = (struct as Val.Dynamic)[Val.properties]( this[Internal.reference].rootOwner, - this[SESSION], + this[Internal.session], ); if (name in properties) { target = properties; @@ -217,7 +231,7 @@ function configureProperty(supervisor: RootSupervisor, schema: ValueModel) { target[name] = value; } - if (!this[SESSION].acceptInvalid && validate) { + if (!this[Internal.session].acceptInvalid && validate) { // Note: We validate fully for nested structs but *not* for the current struct. This is because // choice conformance may be violated temporarily as individual fields change. // @@ -227,7 +241,7 @@ function configureProperty(supervisor: RootSupervisor, schema: ValueModel) { // I think this is OK for now. If it becomes an issue we'll probably want to wire in a separate // validation step that is performed on commit when choice conformance is in play. try { - validate(value, this[SESSION], { + validate(value, this[Internal.session], { path: this[Internal.reference].location.path, siblings: struct, }); @@ -245,14 +259,17 @@ function configureProperty(supervisor: RootSupervisor, schema: ValueModel) { if (manage === PrimitiveManager) { // For primitives we don't need a manager so just proxy reads directly - descriptor.get = function (this: Wrapper) { - if (access.mayRead(this[SESSION], this[Internal.reference].location)) { + descriptor.get = function (this: Struct) { + if (access.mayRead(this[Internal.session], this[Internal.reference].location)) { const struct = this[Internal.reference].value as Val.Dynamic; if (struct === undefined) { throw new PhantomReferenceError(this[Internal.reference].location); } if (struct[Val.properties]) { - const properties = struct[Val.properties](this[Internal.reference].rootOwner, this[SESSION]); + const properties = struct[Val.properties]( + this[Internal.reference].rootOwner, + this[Internal.session], + ); if (name in properties) { return properties[name]; } @@ -286,7 +303,7 @@ function configureProperty(supervisor: RootSupervisor, schema: ValueModel) { cloneContainer = (container: Val) => ({ ...(container as Val.Struct) }); } - descriptor.get = function (this: Wrapper) { + descriptor.get = function (this: Struct) { let value; // Obtain the value. Normally just struct[name] except in the case of Val.Dynamic @@ -294,7 +311,7 @@ function configureProperty(supervisor: RootSupervisor, schema: ValueModel) { if ((struct as Val.Dynamic)[Val.properties]) { const properties = (struct as Val.Dynamic)[Val.properties]( this[Internal.reference].rootOwner, - this[SESSION], + this[Internal.session], ); if (name in properties) { value = properties[name]; @@ -308,7 +325,7 @@ function configureProperty(supervisor: RootSupervisor, schema: ValueModel) { // Note that we only mask values that are unreadable. This is appropriate when the parent object is // visible. For direct access to a property we should throw an error but that must be implemented at a // higher level because we cannot differentiate here - if (!access.mayRead(this[SESSION], this[Internal.reference].location)) { + if (!access.mayRead(this[Internal.session], this[Internal.reference].location)) { return undefined; } @@ -327,9 +344,9 @@ function configureProperty(supervisor: RootSupervisor, schema: ValueModel) { const assertWriteOk = (value: Val) => { // Note - this needs to mirror behavior in the setter above - access.authorizeWrite(this[SESSION], this[Internal.reference].location); + access.authorizeWrite(this[Internal.session], this[Internal.reference].location); if (validate) { - validate(value, this[SESSION], { + validate(value, this[Internal.session], { path: this[Internal.reference].location.path, siblings: this[Internal.reference].value, }); @@ -339,7 +356,7 @@ function configureProperty(supervisor: RootSupervisor, schema: ValueModel) { // Clone the container before write const ref = ManagedReference(this[Internal.reference], name, assertWriteOk, cloneContainer); - ref.owner = manage(ref, this[SESSION]); + ref.owner = manage(ref, this[Internal.session]); return ref.owner; }; diff --git a/packages/node/src/behavior/state/managed/values/ValueCaster.ts b/packages/node/src/behavior/state/managed/values/ValueCaster.ts index fcd963935..103c4afdb 100644 --- a/packages/node/src/behavior/state/managed/values/ValueCaster.ts +++ b/packages/node/src/behavior/state/managed/values/ValueCaster.ts @@ -5,12 +5,11 @@ */ import { camelize } from "#general"; +import type { Schema } from "#model"; import { ClusterModel, DataModelPath, Metatype, UnsupportedCastError, ValueModel } from "#model"; -import { SchemaImplementationError } from "../../../errors.js"; +import { SchemaImplementationError, Val } from "#protocol"; import { RootSupervisor } from "../../../supervision/RootSupervisor.js"; -import { Schema } from "../../../supervision/Schema.js"; import { ValueSupervisor } from "../../../supervision/ValueSupervisor.js"; -import { Val } from "../../Val.js"; /** * Obtain a {@link ValueSupervisor.Caster} function for the given schema. diff --git a/packages/node/src/behavior/state/managed/values/ValueManager.ts b/packages/node/src/behavior/state/managed/values/ValueManager.ts index 6237da555..df28bae10 100644 --- a/packages/node/src/behavior/state/managed/values/ValueManager.ts +++ b/packages/node/src/behavior/state/managed/values/ValueManager.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { Schema } from "#model"; import { Metatype } from "#model"; import type { RootSupervisor } from "../../../supervision/RootSupervisor.js"; -import type { Schema } from "../../../supervision/Schema.js"; import { ValueSupervisor } from "../../../supervision/ValueSupervisor.js"; import { BitmapManager } from "./BitmapManager.js"; import { ListManager } from "./ListManager.js"; diff --git a/packages/node/src/behavior/state/managed/values/ValuePatcher.ts b/packages/node/src/behavior/state/managed/values/ValuePatcher.ts index cf623bfc3..fca50df8d 100644 --- a/packages/node/src/behavior/state/managed/values/ValuePatcher.ts +++ b/packages/node/src/behavior/state/managed/values/ValuePatcher.ts @@ -5,12 +5,11 @@ */ import { camelize, ImplementationError, isObject } from "#general"; +import type { Schema } from "#model"; import { DataModelPath, Metatype, ValueModel } from "#model"; -import { SchemaImplementationError, WriteError } from "../../../errors.js"; +import { SchemaImplementationError, Val, WriteError } from "#protocol"; import { RootSupervisor } from "../../../supervision/RootSupervisor.js"; -import { Schema } from "../../../supervision/Schema.js"; import { ValueSupervisor } from "../../../supervision/ValueSupervisor.js"; -import { Val } from "../../Val.js"; /** * Obtain a {@link ValueSupervisor.Patch} function for the given schema. diff --git a/packages/node/src/behavior/state/transaction/index.ts b/packages/node/src/behavior/state/transaction/index.ts deleted file mode 100644 index 4a776e628..000000000 --- a/packages/node/src/behavior/state/transaction/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @license - * Copyright 2022-2025 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -export * from "./Errors.js"; -export * from "./Participant.js"; -export * from "./Resource.js"; -export * from "./ResourceSet.js"; -export * from "./Status.js"; -export * from "./Transaction.js"; diff --git a/packages/node/src/behavior/state/validation/ValueValidator.ts b/packages/node/src/behavior/state/validation/ValueValidator.ts index f9b532f1f..f193a6b25 100644 --- a/packages/node/src/behavior/state/validation/ValueValidator.ts +++ b/packages/node/src/behavior/state/validation/ValueValidator.ts @@ -5,13 +5,12 @@ */ import { camelize } from "#general"; +import type { Schema } from "#model"; import { AttributeModel, ClusterModel, DataModelPath, FeatureMap, Metatype, ValueModel } from "#model"; +import { ConformanceError, DatatypeError, SchemaImplementationError, Val } from "#protocol"; import { StatusCode } from "#types"; -import { ConformanceError, DatatypeError, SchemaImplementationError } from "../../errors.js"; import { RootSupervisor } from "../../supervision/RootSupervisor.js"; -import { Schema } from "../../supervision/Schema.js"; import type { ValueSupervisor } from "../../supervision/ValueSupervisor.js"; -import { Val } from "../Val.js"; import { Internal } from "../managed/Internal.js"; import { assertArray, diff --git a/packages/node/src/behavior/state/validation/assertions.ts b/packages/node/src/behavior/state/validation/assertions.ts index 19db181aa..cddabf8a3 100644 --- a/packages/node/src/behavior/state/validation/assertions.ts +++ b/packages/node/src/behavior/state/validation/assertions.ts @@ -6,8 +6,7 @@ import { isObject } from "#general"; import { SchemaErrorPath } from "#model"; -import { DatatypeError } from "../../errors.js"; -import { Val } from "../Val.js"; +import { DatatypeError, Val } from "#protocol"; export function assertNumber(value: Val, path: SchemaErrorPath): asserts value is number { if (Number.isFinite(value)) { diff --git a/packages/node/src/behavior/state/validation/conformance-compiler.ts b/packages/node/src/behavior/state/validation/conformance-compiler.ts index c686f105d..11479eb70 100644 --- a/packages/node/src/behavior/state/validation/conformance-compiler.ts +++ b/packages/node/src/behavior/state/validation/conformance-compiler.ts @@ -6,13 +6,11 @@ import { RootSupervisor } from "#behavior/supervision/RootSupervisor.js"; import { camelize } from "#general"; +import type { Schema } from "#model"; import { Conformance, DataModelPath, FeatureSet, FieldValue, Metatype, ValueModel } from "#model"; -import { AccessControl } from "../../AccessControl.js"; -import { ConformanceError, SchemaImplementationError } from "../../errors.js"; -import { Schema } from "../../supervision/Schema.js"; +import { AccessControl, ConformanceError, SchemaImplementationError, Val } from "#protocol"; import { ValueSupervisor } from "../../supervision/ValueSupervisor.js"; import { NameResolver } from "../managed/NameResolver.js"; -import { Val } from "../Val.js"; import { Code, ConformantNode, diff --git a/packages/node/src/behavior/state/validation/conformance-util.ts b/packages/node/src/behavior/state/validation/conformance-util.ts index ec2062efa..2d73e5ab5 100644 --- a/packages/node/src/behavior/state/validation/conformance-util.ts +++ b/packages/node/src/behavior/state/validation/conformance-util.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { Schema } from "#model"; import { Conformance, DataModelPath, SchemaErrorPath, SchemaImplementationError } from "#model"; -import { Schema } from "../../supervision/Schema.js"; -import { Val } from "../Val.js"; +import { Val } from "#protocol"; import { ValidationLocation } from "./location.js"; export class UnsupportedConformanceNodeError extends SchemaImplementationError { diff --git a/packages/node/src/behavior/state/validation/constraint.ts b/packages/node/src/behavior/state/validation/constraint.ts index 5787d7e68..50528c3bc 100644 --- a/packages/node/src/behavior/state/validation/constraint.ts +++ b/packages/node/src/behavior/state/validation/constraint.ts @@ -7,10 +7,9 @@ import { RootSupervisor } from "#behavior/supervision/RootSupervisor.js"; import { InternalError } from "#general"; import { Constraint, FieldValue, Metatype, ValueModel } from "#model"; -import { ConstraintError } from "../../errors.js"; +import { ConstraintError, Val } from "#protocol"; import { ValueSupervisor } from "../../supervision/ValueSupervisor.js"; import { NameResolver } from "../managed/NameResolver.js"; -import { Val } from "../Val.js"; import { assertArray, assertBoolean, assertNumeric, assertSequence, assertString } from "./assertions.js"; import { ValidationLocation } from "./location.js"; diff --git a/packages/node/src/behavior/state/validation/location.ts b/packages/node/src/behavior/state/validation/location.ts index e867b9ec6..79db135a9 100644 --- a/packages/node/src/behavior/state/validation/location.ts +++ b/packages/node/src/behavior/state/validation/location.ts @@ -5,7 +5,7 @@ */ import { DataModelPath } from "#model"; -import { Val } from "../Val.js"; +import { Val } from "#protocol"; /** * Contextual information tracked during validation. diff --git a/packages/node/src/behavior/supervision/BehaviorSupervisor.ts b/packages/node/src/behavior/supervision/BehaviorSupervisor.ts index cc11d90a8..2e87222f1 100644 --- a/packages/node/src/behavior/supervision/BehaviorSupervisor.ts +++ b/packages/node/src/behavior/supervision/BehaviorSupervisor.ts @@ -5,12 +5,11 @@ */ import { camelize } from "#general"; -import { Access, FieldModel, Scope } from "#model"; +import { Access, FieldModel, Schema, Scope } from "#model"; +import type { Val } from "#protocol"; import type { Behavior } from "../Behavior.js"; import type { StateType } from "../state/StateType.js"; -import type { Val } from "../state/Val.js"; import { RootSupervisor } from "./RootSupervisor.js"; -import { Schema } from "./Schema.js"; /** * Create a {@link RootSupervisor} for a {@link Behavior}. diff --git a/packages/node/src/behavior/supervision/RootSupervisor.ts b/packages/node/src/behavior/supervision/RootSupervisor.ts index 118f4ae9d..9faa4bb83 100644 --- a/packages/node/src/behavior/supervision/RootSupervisor.ts +++ b/packages/node/src/behavior/supervision/RootSupervisor.ts @@ -5,14 +5,12 @@ */ import { camelize, InternalError } from "#general"; -import { AttributeModel, ClusterModel, FeatureMap, FeatureSet, Matter, Model, Scope, ValueModel } from "#model"; -import { AccessControl } from "../AccessControl.js"; -import { Val } from "../state/Val.js"; +import { AttributeModel, ClusterModel, FeatureMap, FeatureSet, Matter, Model, Schema, Scope, ValueModel } from "#model"; +import { AccessControl, Val } from "#protocol"; import { ValueCaster } from "../state/managed/values/ValueCaster.js"; import { ValueManager } from "../state/managed/values/ValueManager.js"; import { ValuePatcher } from "../state/managed/values/ValuePatcher.js"; import { ValueValidator } from "../state/validation/ValueValidator.js"; -import { Schema } from "./Schema.js"; import { ValueSupervisor } from "./ValueSupervisor.js"; /** diff --git a/packages/node/src/behavior/supervision/Schema.ts b/packages/node/src/behavior/supervision/Schema.ts deleted file mode 100644 index 87842fa47..000000000 --- a/packages/node/src/behavior/supervision/Schema.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @license - * Copyright 2022-2025 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { DatatypeModel, type ClusterModel, type ValueModel } from "#model"; - -/** - * We model behavior using Matter semantics. For schema we allow any Matter - * model that defines a datatype. - * - * Most schema is a {@link ValueModel} which explicitly models data. - * {@link ClusterModel} is also valid schema. - * - * You will see references to "structs" and "lists" throughout this code. - * These are Matter's two container types and map to JS objects and arrays - * respectively. - * - * If the schema is a {@link ClusterModel}, it models a struct with attributes - * as fields. - */ -export type Schema = ClusterModel | ValueModel; - -export namespace Schema { - export const empty = new DatatypeModel({ name: "Empty", type: "struct" }); -} diff --git a/packages/node/src/behavior/supervision/ValueSupervisor.ts b/packages/node/src/behavior/supervision/ValueSupervisor.ts index ec6fcba07..57269a41c 100644 --- a/packages/node/src/behavior/supervision/ValueSupervisor.ts +++ b/packages/node/src/behavior/supervision/ValueSupervisor.ts @@ -4,15 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { Transaction } from "#general"; import { AsyncObservable } from "#general"; -import { DataModelPath } from "#model"; -import type { AccessControl } from "../AccessControl.js"; +import { DataModelPath, Schema } from "#model"; +import type { AccessControl, Val } from "#protocol"; import { ActionTracer } from "../context/ActionTracer.js"; -import type { Val } from "../state/Val.js"; -import type { Transaction } from "../state/transaction/Transaction.js"; import type { ValidationLocation } from "../state/validation/location.js"; import type { RootSupervisor } from "./RootSupervisor.js"; -import type { Schema } from "./Schema.js"; /** * Value supervisor implements schema-based supervision of a specific value. @@ -25,12 +23,11 @@ import type { Schema } from "./Schema.js"; * * - Managed instance generation * - * Supervision is implemented via schema-driven runtime compilation. We - * perform as much logic as possible at startup to minimize overhead during - * server operation. + * Supervision is implemented via schema-driven runtime compilation. We perform as much logic as possible at startup to + * minimize overhead during server operation. * - * This means we typically ingest schema, create a compact form of denormalized - * metadata, and/or generate functions to perform required operations. + * This means we typically ingest schema, create a compact form of denormalized metadata, and/or generate functions to + * perform required operations. */ export interface ValueSupervisor { /** @@ -95,6 +92,12 @@ export namespace ValueSupervisor { * If present the session is associated with an online interaction. Emits when the interaction ends. */ interactionComplete?: AsyncObservable<[]>; + + /** + * If true, structs initialize without named properties which are more expensive to install. This is useful + * when implementing the Matter protocol where ID is the only value necessary. + */ + protocol?: boolean; } export type Validate = (value: Val, session: Session, location: ValidationLocation) => void; diff --git a/packages/node/src/behavior/supervision/index.ts b/packages/node/src/behavior/supervision/index.ts index 74278dd95..0e0ff7f2a 100644 --- a/packages/node/src/behavior/supervision/index.ts +++ b/packages/node/src/behavior/supervision/index.ts @@ -6,5 +6,4 @@ export * from "./BehaviorSupervisor.js"; export * from "./RootSupervisor.js"; -export * from "./Schema.js"; export * from "./ValueSupervisor.js"; diff --git a/packages/node/src/behavior/system/commissioning/CommissioningServer.ts b/packages/node/src/behavior/system/commissioning/CommissioningServer.ts index f7a897b76..751fb6be8 100644 --- a/packages/node/src/behavior/system/commissioning/CommissioningServer.ts +++ b/packages/node/src/behavior/system/commissioning/CommissioningServer.ts @@ -18,8 +18,8 @@ import { import { DatatypeModel, FieldElement } from "#model"; import type { Node } from "#node/Node.js"; import { NodeLifecycle } from "#node/NodeLifecycle.js"; -import { ServerNode } from "#node/ServerNode.js"; -import { ExposedFabricInformation, FabricAction, FabricManager, FailsafeContext, PaseClient } from "#protocol"; +import type { ServerNode } from "#node/ServerNode.js"; +import { ExposedFabricInformation, FabricAction, FabricManager, FailsafeContext, PaseClient, Val } from "#protocol"; import { CommissioningFlowType, CommissioningOptions, @@ -33,7 +33,6 @@ import { BasicInformationBehavior } from "../../../behaviors/basic-information/B import { OperationalCredentialsBehavior } from "../../../behaviors/operational-credentials/OperationalCredentialsBehavior.js"; import { Behavior } from "../../Behavior.js"; import { ActionContext } from "../../context/ActionContext.js"; -import { Val } from "../../state/Val.js"; import { NetworkServer } from "../network/NetworkServer.js"; import { SessionsBehavior } from "../sessions/SessionsBehavior.js"; diff --git a/packages/node/src/behavior/system/controller/discovery/ActiveDiscoveries.ts b/packages/node/src/behavior/system/controller/discovery/ActiveDiscoveries.ts index b7aebbfa5..62fa79993 100644 --- a/packages/node/src/behavior/system/controller/discovery/ActiveDiscoveries.ts +++ b/packages/node/src/behavior/system/controller/discovery/ActiveDiscoveries.ts @@ -5,7 +5,7 @@ */ import { Environment, Environmental } from "#general"; -import { Discovery } from "./Discovery.js"; +import type { Discovery } from "./Discovery.js"; /** * Ongoing node discoveries registered with the environment. diff --git a/packages/node/src/behavior/system/controller/discovery/CommissioningDiscovery.ts b/packages/node/src/behavior/system/controller/discovery/CommissioningDiscovery.ts index 6c0733a6a..56ffe8f37 100644 --- a/packages/node/src/behavior/system/controller/discovery/CommissioningDiscovery.ts +++ b/packages/node/src/behavior/system/controller/discovery/CommissioningDiscovery.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { CommissioningClient } from "#behavior/system/commissioning/CommissioningClient.js"; -import { ServerNode } from "#node/ServerNode.js"; -import { Discovery } from "./Discovery.js"; +import type { CommissioningClient } from "#behavior/system/commissioning/CommissioningClient.js"; +import type { ServerNode } from "#node/ServerNode.js"; +import type { Discovery } from "./Discovery.js"; import { InstanceDiscovery } from "./InstanceDiscovery.js"; /** diff --git a/packages/node/src/behavior/system/controller/discovery/ContinuousDiscovery.ts b/packages/node/src/behavior/system/controller/discovery/ContinuousDiscovery.ts index a139629a8..a915b5394 100644 --- a/packages/node/src/behavior/system/controller/discovery/ContinuousDiscovery.ts +++ b/packages/node/src/behavior/system/controller/discovery/ContinuousDiscovery.ts @@ -6,7 +6,7 @@ import { Observable } from "#general"; import type { ClientNode } from "#node/ClientNode.js"; -import { ServerNode } from "#node/ServerNode.js"; +import type { ServerNode } from "#node/ServerNode.js"; import { Discovery } from "./Discovery.js"; /** diff --git a/packages/node/src/behavior/system/controller/discovery/Discovery.ts b/packages/node/src/behavior/system/controller/discovery/Discovery.ts index e598da330..c2cedfc7a 100644 --- a/packages/node/src/behavior/system/controller/discovery/Discovery.ts +++ b/packages/node/src/behavior/system/controller/discovery/Discovery.ts @@ -6,8 +6,8 @@ import { CancelablePromise, MatterAggregateError, MatterError, MaybePromise } from "#general"; import { ClientNodeFactory } from "#node/client/ClientNodeFactory.js"; -import { ClientNode } from "#node/ClientNode.js"; -import { ServerNode } from "#node/ServerNode.js"; +import type { ClientNode } from "#node/ClientNode.js"; +import type { ServerNode } from "#node/ServerNode.js"; import { CommissionableDeviceIdentifiers, ScannerSet } from "#protocol"; import { ControllerBehavior } from "../ControllerBehavior.js"; import { ActiveDiscoveries } from "./ActiveDiscoveries.js"; diff --git a/packages/node/src/behavior/system/controller/discovery/InstanceDiscovery.ts b/packages/node/src/behavior/system/controller/discovery/InstanceDiscovery.ts index a0fac2c54..809705a3a 100644 --- a/packages/node/src/behavior/system/controller/discovery/InstanceDiscovery.ts +++ b/packages/node/src/behavior/system/controller/discovery/InstanceDiscovery.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { MaybePromise } from "#general"; +import type { MaybePromise } from "#general"; import type { ClientNode } from "#node/ClientNode.js"; -import { ServerNode } from "#node/ServerNode.js"; +import type { ServerNode } from "#node/ServerNode.js"; import { Discovery, DiscoveryError } from "./Discovery.js"; /** diff --git a/packages/node/src/behavior/system/index/IndexBehavior.ts b/packages/node/src/behavior/system/index/IndexBehavior.ts index 158b8251c..f71c7046d 100644 --- a/packages/node/src/behavior/system/index/IndexBehavior.ts +++ b/packages/node/src/behavior/system/index/IndexBehavior.ts @@ -7,7 +7,6 @@ import type { Endpoint } from "#endpoint/Endpoint.js"; import { EndpointLifecycle } from "#endpoint/properties/EndpointLifecycle.js"; import { EventEmitter, Observable, Timer } from "#general"; -import { IdentityService } from "#node/server/IdentityService.js"; import { Behavior } from "../../Behavior.js"; /** @@ -44,8 +43,8 @@ export class IndexBehavior extends Behavior { /** * Retrieve a {@link Endpoint} by number. * - * Note that {@link state.partsByNumber} does not include {@link endpoint} but this method will return it if the number - * matches. + * Note that {@link internal.partsByNumber} does not include {@link endpoint} but this method will return it if the + * number matches. */ forNumber(number: number) { if (this.endpoint.lifecycle.hasNumber && number === this.endpoint.number) { @@ -71,12 +70,15 @@ export class IndexBehavior extends Behavior { } #add(endpoint: Endpoint) { - // This assertion is a sanity check; if there is a conflict then state is already corrupted if (endpoint.lifecycle.hasNumber) { - this.env.get(IdentityService).assertNumberAvailable(endpoint.number, endpoint); + // Add to endpoint number index this.internal.partsByNumber[endpoint.number] = endpoint; } + if (endpoint.lifecycle.hasId) { + this.internal.partsById[endpoint.id] = endpoint; + } + for (const child of endpoint.parts) { this.#add(child); } @@ -119,12 +121,12 @@ export namespace IndexBehavior { /** * Map of ID to {@link Endpoint}. */ - partsById = {} as Record; + partsById = {} as Record; /** * Map of number to {@link Endpoint}. */ - partsByNumber = {} as Record; + partsByNumber = {} as Record; } export class Events extends EventEmitter { diff --git a/packages/node/src/behavior/system/network/NetworkServer.ts b/packages/node/src/behavior/system/network/NetworkServer.ts index 517ef7191..dc5c974e6 100644 --- a/packages/node/src/behavior/system/network/NetworkServer.ts +++ b/packages/node/src/behavior/system/network/NetworkServer.ts @@ -9,7 +9,7 @@ import { Ble, ServerSubscriptionConfig } from "#protocol"; import { DiscoveryCapabilitiesBitmap, TypeFromPartialBitSchema } from "#types"; import { CommissioningServer } from "../commissioning/CommissioningServer.js"; import { NetworkBehavior } from "./NetworkBehavior.js"; -import { ServerNetworkRuntime } from "./ServerNetworkRuntime.js"; +import type { ServerNetworkRuntime } from "./ServerNetworkRuntime.js"; const logger = Logger.get("NetworkingServer"); diff --git a/packages/node/src/behavior/system/network/ServerNetworkRuntime.ts b/packages/node/src/behavior/system/network/ServerNetworkRuntime.ts index 3368a2221..f232c0ff1 100644 --- a/packages/node/src/behavior/system/network/ServerNetworkRuntime.ts +++ b/packages/node/src/behavior/system/network/ServerNetworkRuntime.ts @@ -16,7 +16,7 @@ import { TransportInterfaceSet, UdpInterface, } from "#general"; -import { ServerNode } from "#node/ServerNode.js"; +import type { ServerNode } from "#node/ServerNode.js"; import { TransactionalInteractionServer } from "#node/server/TransactionalInteractionServer.js"; import { Ble, diff --git a/packages/node/src/behaviors/access-control/AccessControlServer.ts b/packages/node/src/behaviors/access-control/AccessControlServer.ts index 2bf88de4b..e1e473691 100644 --- a/packages/node/src/behaviors/access-control/AccessControlServer.ts +++ b/packages/node/src/behaviors/access-control/AccessControlServer.ts @@ -4,13 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AccessControl } from "#behavior/AccessControl.js"; import { ActionContext } from "#behavior/context/ActionContext.js"; import { AccessControl as AccessControlTypes } from "#clusters/access-control"; import { deepCopy, InternalError, isDeepEqual, Logger } from "#general"; import { AccessLevel } from "#model"; import { NodeLifecycle } from "#node/NodeLifecycle.js"; -import { AccessControlManager, AclEndpointContext, FabricManager, IncomingSubjectDescriptor } from "#protocol"; +import { + AccessControl, + AccessControlManager, + AclEndpointContext, + FabricManager, + IncomingSubjectDescriptor, +} from "#protocol"; import { CaseAuthenticatedTag, ClusterId, diff --git a/packages/node/src/behaviors/descriptor/DescriptorServer.ts b/packages/node/src/behaviors/descriptor/DescriptorServer.ts index 1229f8c15..3a260476f 100644 --- a/packages/node/src/behaviors/descriptor/DescriptorServer.ts +++ b/packages/node/src/behaviors/descriptor/DescriptorServer.ts @@ -147,7 +147,9 @@ export class DescriptorServer extends DescriptorBehavior { return; } await this.#updatePartsList(); - this.#monitorDestruction(endpoint); + if (!this.endpoint.behaviors.has(IndexBehavior)) { + this.#monitorDestruction(endpoint); + } break; case EndpointLifecycle.Change.ServersChanged: @@ -159,6 +161,14 @@ export class DescriptorServer extends DescriptorBehavior { await this.context.transaction.begin(); this.state.serverList = this.#serverList; break; + + case EndpointLifecycle.Change.Destroying: + if (endpoint !== this.endpoint) { + return; + } + + await this.stopReacting({ reactor: this.#updatePartsList }); + break; } } diff --git a/packages/node/src/behaviors/general-diagnostics/GeneralDiagnosticsServer.ts b/packages/node/src/behaviors/general-diagnostics/GeneralDiagnosticsServer.ts index 44d0355fc..adb4e2a63 100644 --- a/packages/node/src/behaviors/general-diagnostics/GeneralDiagnosticsServer.ts +++ b/packages/node/src/behaviors/general-diagnostics/GeneralDiagnosticsServer.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Val } from "#behavior/state/Val.js"; import { ValueSupervisor } from "#behavior/supervision/ValueSupervisor.js"; import { NetworkServer } from "#behavior/system/network/NetworkServer.js"; import { NetworkCommissioningServer } from "#behaviors/network-commissioning"; @@ -14,7 +13,7 @@ import { Endpoint } from "#endpoint/Endpoint.js"; import { Bytes, ImplementationError, ipv4ToBytes, Logger, Time, Timer } from "#general"; import { FieldElement, Specification } from "#model"; import { NodeLifecycle } from "#node/NodeLifecycle.js"; -import { MdnsService } from "#protocol"; +import { MdnsService, Val } from "#protocol"; import { CommandId, StatusCode, StatusResponseError, TlvInvokeResponse } from "#types"; import { GeneralDiagnosticsBehavior } from "./GeneralDiagnosticsBehavior.js"; diff --git a/packages/node/src/behaviors/operational-credentials/OperationalCredentialsServer.ts b/packages/node/src/behaviors/operational-credentials/OperationalCredentialsServer.ts index ee15e6b49..7fcb5e1e8 100644 --- a/packages/node/src/behaviors/operational-credentials/OperationalCredentialsServer.ts +++ b/packages/node/src/behaviors/operational-credentials/OperationalCredentialsServer.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Val } from "#behavior/state/Val.js"; import { ValueSupervisor } from "#behavior/supervision/ValueSupervisor.js"; import { CommissioningServer } from "#behavior/system/commissioning/CommissioningServer.js"; import { ProductDescriptionServer } from "#behavior/system/product-description/ProductDescriptionServer.js"; @@ -29,6 +28,7 @@ import { PublicKeyError, TlvAttestation, TlvCertSigningRequest, + Val, } from "#protocol"; import { Command, diff --git a/packages/node/src/endpoint/Agent.ts b/packages/node/src/endpoint/Agent.ts index c7a0bb148..22de116b0 100644 --- a/packages/node/src/endpoint/Agent.ts +++ b/packages/node/src/endpoint/Agent.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Behavior } from "#behavior/Behavior.js"; +import type { Behavior } from "#behavior/Behavior.js"; import { ActionContext } from "#behavior/context/ActionContext.js"; import { GeneratedClass, MaybePromise } from "#general"; import { DescriptorBehavior } from "../behaviors/descriptor/DescriptorBehavior.js"; diff --git a/packages/node/src/endpoint/Endpoint.ts b/packages/node/src/endpoint/Endpoint.ts index 27d59e8e0..62c2781f3 100644 --- a/packages/node/src/endpoint/Endpoint.ts +++ b/packages/node/src/endpoint/Endpoint.ts @@ -625,6 +625,7 @@ export class Endpoint { } async close() { + this.lifecycle.change(EndpointLifecycle.Change.Destroying); await this.env.get(EndpointInitializer).deactivateDescendant(this); await this.#construction.close(); } diff --git a/packages/node/src/endpoint/properties/Behaviors.ts b/packages/node/src/endpoint/properties/Behaviors.ts index 0214a47c4..87b8391bd 100644 --- a/packages/node/src/endpoint/properties/Behaviors.ts +++ b/packages/node/src/endpoint/properties/Behaviors.ts @@ -11,8 +11,7 @@ import { ActionTracer } from "#behavior/context/ActionTracer.js"; import { NodeActivity } from "#behavior/context/NodeActivity.js"; import { OfflineContext } from "#behavior/context/server/OfflineContext.js"; import { BehaviorBacking } from "#behavior/internal/BehaviorBacking.js"; -import { Val } from "#behavior/state/Val.js"; -import { Transaction } from "#behavior/state/transaction/Transaction.js"; +import { Datasource } from "#behavior/state/managed/Datasource.js"; import { camelize, Construction, @@ -23,9 +22,11 @@ import { Lifecycle, Logger, MaybePromise, - ReadOnlyError, + Transaction, } from "#general"; import { FeatureSet } from "#model"; +import { ProtocolService } from "#node/server/ProtocolService.js"; +import { ClusterTypeProtocol, Val } from "#protocol"; import { ClusterType } from "#types"; import { DescriptorServer } from "../../behaviors/descriptor/DescriptorServer.js"; import type { Agent } from "../Agent.js"; @@ -52,6 +53,7 @@ export class Behaviors { #supported: SupportedBehaviors; #backings: Record = {}; #options: Record; + #protocol?: ProtocolService; /** * The {@link SupportedBehaviors} of the {@link Endpoint}. @@ -370,6 +372,8 @@ export class Behaviors { } for (const id of destroyNow) { + const backing = this.#backings[id]; + this.#protocol?.deleteCluster(backing); await this.#backings[id].close(agent); delete this.#backings[id]; } @@ -503,6 +507,10 @@ export class Behaviors { return elements; } + [Symbol.iterator]() { + return Object.values(this.#supported)[Symbol.iterator](); + } + #activateLate(type: Behavior.Type) { const result = OfflineContext.act("behavior-late-activation", this.#endpoint.env.get(NodeActivity), context => { this.activate(type, context.agentFor(this.#endpoint)); @@ -529,6 +537,13 @@ export class Behaviors { } } + /** + * Create a read-only online view of a behavior. + */ + createOnlineView(type: Behavior.Type) { + return this.#backingFor(type).datasource; + } + /** * Obtain a backing for a behavior. */ @@ -563,6 +578,10 @@ export class Behaviors { const backing = this.#endpoint.env.get(EndpointInitializer).createBacking(this.#endpoint, myType); this.#backings[type.id] = backing; + if (!this.#protocol) { + this.#protocol = this.#endpoint.env.get(ProtocolService); + } + this.#protocol.addCluster(backing); backing.construction.start(agent); return backing; @@ -583,20 +602,20 @@ export class Behaviors { } /** - * Updates endpoint "state" and "events" properties to include properties for our implementations. + * Updates endpoint "state" and "events" properties to include properties for a supported behavior. */ #augmentEndpoint(type: Behavior.Type) { - Object.defineProperty(this.#endpoint.state, type.id, { + const stateDescriptor = { get: () => { return this.#backingFor(type).stateView; }, - set() { - throw new ReadOnlyError('The "state" property is read-only; you must use set() to modify state'); - }, - enumerable: true, - }); + }; + Object.defineProperty(this.#endpoint.state, type.id, stateDescriptor); + if (type.schema?.id !== undefined) { + Object.defineProperty(this.#endpoint.state, type.schema.id, stateDescriptor); + } let events: undefined | EventEmitter; Object.defineProperty(this.#endpoint.events, type.id, { @@ -615,3 +634,10 @@ export class Behaviors { function clusterOf(behavior?: Behavior.Type): ClusterType | undefined { return (behavior as ClusterBehavior.Type)?.cluster; } + +export namespace Behaviors { + export interface ProtocolContext { + descriptor: ClusterTypeProtocol; + datasource: Datasource; + } +} diff --git a/packages/node/src/endpoint/properties/EndpointLifecycle.ts b/packages/node/src/endpoint/properties/EndpointLifecycle.ts index e184f67b5..beec4f86e 100644 --- a/packages/node/src/endpoint/properties/EndpointLifecycle.ts +++ b/packages/node/src/endpoint/properties/EndpointLifecycle.ts @@ -24,6 +24,7 @@ export class EndpointLifecycle { #installed = Observable<[]>(error => this.emitError("installed", error)); #ready = Observable<[]>(error => this.emitError("ready", error)); #partsReady = Observable<[]>(error => this.emitError("partsReady", error)); + #destroying = Observable<[]>(error => this.emitError("destroying", error)); #destroyed = Observable<[]>(error => this.emitError("destroyed", error)); #changed = Observable<[type: EndpointLifecycle.Change, endpoint: Endpoint]>(error => this.emitError("changed", error), @@ -59,6 +60,13 @@ export class EndpointLifecycle { return this.partsReady; } + /** + * Emitted when the endpoint begins destruction. + */ + get destroying() { + return this.#destroying; + } + /** * Emitted when the endpoint is destroyed. */ @@ -237,6 +245,7 @@ export namespace EndpointLifecycle { Ready = "ready", PartsReady = "partsReady", Crashed = "crashed", + Destroying = "destroying", Destroyed = "destroyed", ServersChanged = "serversChanged", ClientsChanged = "clientsChanged", diff --git a/packages/node/src/endpoint/server/BehaviorServer.ts b/packages/node/src/endpoint/server/BehaviorServer.ts index 7418678c3..33051efd3 100644 --- a/packages/node/src/endpoint/server/BehaviorServer.ts +++ b/packages/node/src/endpoint/server/BehaviorServer.ts @@ -4,13 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AccessControl } from "#behavior/AccessControl.js"; import { Behavior } from "#behavior/Behavior.js"; import { ClusterBehavior } from "#behavior/cluster/ClusterBehavior.js"; -import { type ClusterEvents, Contextual, Resource } from "#behavior/index.js"; +import { ClusterEvents } from "#behavior/cluster/ClusterEvents.js"; +import { Contextual } from "#behavior/context/Contextual.js"; import { StructManager } from "#behavior/state/managed/values/StructManager.js"; -import { Status } from "#behavior/state/transaction/Status.js"; -import { Val } from "#behavior/state/Val.js"; import { Endpoint } from "#endpoint/Endpoint.js"; import { camelize, @@ -22,9 +20,11 @@ import { MaybePromise, Observable, ObserverGroup, + Transaction, } from "#general"; import { CommandModel, ElementTag } from "#model"; import { + AccessControl, AttributeServer, ClusterDatasource, ClusterServer, @@ -35,6 +35,7 @@ import { Message, OccurrenceManager, SecureSession, + Val, } from "#protocol"; import { Attribute, Command, Event } from "#types"; import type { EndpointServer } from "./EndpointServer.js"; @@ -168,11 +169,11 @@ function createAttributeServer( trace.path = endpoint.path.at(name); } - logger.debug("Read", Diagnostic.strong(`${endpoint}.state.${name}`), "via", behavior.context.transaction.via); + //logger.debug("Read", Diagnostic.strong(`${endpoint}.state.${name}`), "via", behavior.context.transaction.via); const state = behavior.state as Val.Struct; - StructManager.assertDirectReadAuthorized(state, name); + StructManager.assertDirectReadAuthorized(state, definition.id); if (trace) { trace.output = state[name]; @@ -199,7 +200,7 @@ function createAttributeServer( state[name] = value; // If the transaction is a write transaction, report that the attribute is updated - return behavior.context.transaction?.status === Status.Exclusive; + return behavior.context.transaction?.status === Transaction.Status.Exclusive; } const server = ConstructAttributeServer( @@ -267,6 +268,7 @@ function createCommandServer( access.authorizeInvoke(behavior.context, { path, + endpoint: endpoint.number, cluster: behavior.cluster.id, }); @@ -281,7 +283,7 @@ function createCommandServer( // Lock if necessary, then invoke if ((behavior.constructor as ClusterBehavior.Type).lockOnInvoke) { const tx = behavior.context.transaction; - if (Resource.isLocked(behavior)) { + if (Transaction.Resource.isLocked(behavior)) { // Automatic locking with locked resource; requires async lock acquisition result = (async function invokeAsync() { await tx.addResources(behavior); diff --git a/packages/node/src/endpoint/storage/DatasourceStore.ts b/packages/node/src/endpoint/storage/DatasourceStore.ts index 5d5885cd1..0774fe4d1 100644 --- a/packages/node/src/endpoint/storage/DatasourceStore.ts +++ b/packages/node/src/endpoint/storage/DatasourceStore.ts @@ -4,14 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Val } from "#behavior/state/Val.js"; import { Datasource } from "#behavior/state/managed/Datasource.js"; -import { Participant } from "#behavior/state/transaction/Participant.js"; -import { Transaction } from "#behavior/state/transaction/Transaction.js"; -import { MaybePromise } from "#general"; +import { MaybePromise, Transaction } from "#general"; +import { Val } from "#protocol"; import type { EndpointStore } from "./EndpointStore.js"; -interface StorageParticipant extends Participant { +interface StorageParticipant extends Transaction.Participant { mutations?: Record; } diff --git a/packages/node/src/endpoint/storage/EndpointStore.ts b/packages/node/src/endpoint/storage/EndpointStore.ts index 45a2caeaf..d19c34155 100644 --- a/packages/node/src/endpoint/storage/EndpointStore.ts +++ b/packages/node/src/endpoint/storage/EndpointStore.ts @@ -4,11 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Val } from "#behavior/state/Val.js"; import { Datasource } from "#behavior/state/managed/Datasource.js"; import { Endpoint } from "#endpoint/Endpoint.js"; import { DatasourceStore } from "#endpoint/storage/DatasourceStore.js"; import { Construction, ImplementationError, StorageContext, SupportedStorageTypes } from "#general"; +import { Val } from "#protocol"; const NUMBER_KEY = "__number__"; diff --git a/packages/node/src/node/ClientNode.ts b/packages/node/src/node/ClientNode.ts index 3314f2e90..fa6c0aa67 100644 --- a/packages/node/src/node/ClientNode.ts +++ b/packages/node/src/node/ClientNode.ts @@ -4,11 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { ActionContext } from "#behavior/context/ActionContext.js"; import { CommissioningClient } from "#behavior/system/commissioning/CommissioningClient.js"; import { NetworkRuntime } from "#behavior/system/network/NetworkRuntime.js"; import { Agent } from "#endpoint/Agent.js"; import { EndpointInitializer } from "#endpoint/properties/EndpointInitializer.js"; import { Identity, Lifecycle, MaybePromise, NotImplementedError } from "#general"; +import { Interactable } from "@matter/protocol"; import { ClientEndpointInitializer } from "./client/ClientEndpointInitializer.js"; import { Node } from "./Node.js"; import type { ServerNode } from "./ServerNode.js"; @@ -79,6 +81,11 @@ export class ClientNode extends Node { return (super.act as any)(actorOrPurpose, actor); } + + get interaction(): Interactable { + // TODO + throw new NotImplementedError(); + } } export namespace ClientNode { diff --git a/packages/node/src/node/Node.ts b/packages/node/src/node/Node.ts index b865d5abe..091cc0d97 100644 --- a/packages/node/src/node/Node.ts +++ b/packages/node/src/node/Node.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { ActionContext } from "#behavior/context/ActionContext.js"; import { NodeActivity } from "#behavior/context/NodeActivity.js"; import { IndexBehavior } from "#behavior/system/index/IndexBehavior.js"; import { NetworkRuntime } from "#behavior/system/network/NetworkRuntime.js"; @@ -21,8 +22,10 @@ import { Logger, RuntimeService, } from "#general"; +import { Interactable } from "#protocol"; import { RootEndpoint } from "../endpoints/root.js"; import { NodeLifecycle } from "./NodeLifecycle.js"; +import { ProtocolService } from "./server/ProtocolService.js"; const logger = Logger.get("Node"); @@ -48,6 +51,7 @@ export abstract class Node; + /** + * An {@link Interactable} that allows for execution of Matter interactions. + */ + abstract interaction: Interactable; + + protected abstract prepareRuntimeShutdown(): Promise; get [RuntimeService.label]() { return ["Runtime for", Diagnostic.strong(this.toString())]; diff --git a/packages/node/src/node/ServerNode.ts b/packages/node/src/node/ServerNode.ts index 1ddd48b71..9b9e8b436 100644 --- a/packages/node/src/node/ServerNode.ts +++ b/packages/node/src/node/ServerNode.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { ActionContext } from "#behavior/context/ActionContext.js"; import { CommissioningServer } from "#behavior/system/commissioning/CommissioningServer.js"; import { ControllerBehavior } from "#behavior/system/controller/ControllerBehavior.js"; import { EventsBehavior } from "#behavior/system/events/EventsBehavior.js"; @@ -14,7 +15,7 @@ import { SessionsBehavior } from "#behavior/system/sessions/SessionsBehavior.js" import { Endpoint } from "#endpoint/Endpoint.js"; import type { Environment } from "#general"; import { Construction, DiagnosticSource, Identity, MatterError, asyncNew, errorOf } from "#general"; -import { FabricManager, OccurrenceManager, SessionManager } from "#protocol"; +import { FabricManager, Interactable, OccurrenceManager, ServerInteraction, SessionManager } from "#protocol"; import { RootEndpoint as BaseRootEndpoint } from "../endpoints/root.js"; import { Node } from "./Node.js"; import { ClientNodes } from "./client/ClientNodes.js"; @@ -38,6 +39,7 @@ class FactoryResetError extends MatterError { */ export class ServerNode extends Node { #nodes?: ClientNodes; + #interaction?: Interactable; /** * Construct a new ServerNode. @@ -163,6 +165,13 @@ export class ServerNode(this.protocol); + } + return this.#interaction; + } + async advertiseNow() { await this.act(`advertiseNow<${this}>`, agent => agent.get(NetworkServer).advertiseNow()); } diff --git a/packages/node/src/node/action/InvokeRequestAction.ts b/packages/node/src/node/action/InvokeRequestAction.ts deleted file mode 100644 index ee145a09c..000000000 --- a/packages/node/src/node/action/InvokeRequestAction.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @license - * Copyright 2022-2025 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { CommandPayload } from "../payloads/CommandPayload.js"; - -/** - * Initiates command execution. - * - * @see {@link MatterSpecification.v11.Core} § 8.8.2 - */ -export type InvokeRequestAction = { - commands: CommandPayload.Request[]; - suppressResponse?: boolean; - timedRequest?: boolean; -}; diff --git a/packages/node/src/node/action/InvokeResponseAction.ts b/packages/node/src/node/action/InvokeResponseAction.ts deleted file mode 100644 index 4a7a799e4..000000000 --- a/packages/node/src/node/action/InvokeResponseAction.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @license - * Copyright 2022-2025 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { CommandPayload } from "../payloads/CommandPayload.js"; - -/** - * Responds with the result of command execution. - * - * @see {@link MatterSpecification.v11.Core} § 8.8.3 - */ -export type InvokeResponseAction = { - commands: CommandPayload.Response[]; - suppressResponse?: boolean; -}; diff --git a/packages/node/src/node/action/ReadRequestAction.ts b/packages/node/src/node/action/ReadRequestAction.ts deleted file mode 100644 index 7790ab463..000000000 --- a/packages/node/src/node/action/ReadRequestAction.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @license - * Copyright 2022-2025 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { DataVersion, EventNumber, NodeId } from "#types"; -import { AttributePath } from "../paths/AttributePath.js"; -import { EventPath } from "../paths/EventPath.js"; - -export namespace ReadRequestAction { - /** - * Filters known data from attribute reads. - * - * @see {@link MatterSpecification.v11.Core} § 8.4.3.2 & 8.9.2.7 - */ - export type AttributeVersionFilter = AttributePath.Write & { - version: DataVersion; - }; - - /** - * Filters to events newer than a specific event number. - * - * @see {@link MatterSpecification.v11.Core} § 8.9.3.1 - */ - export type EventSequenceFilter = { - node: NodeId; - min: EventNumber; - }; -} - -/** - * Initiates read of attribute and/or event data. - * - * @see {@link MatterSpecification.v11.Core} § 8.4.2 - */ -export type ReadRequestAction = { - attributes?: AttributePath.Read[]; - versions?: ReadRequestAction.AttributeVersionFilter[]; - events?: EventPath.Read[]; - sequences?: ReadRequestAction.EventSequenceFilter[]; - fabric?: boolean; -}; diff --git a/packages/node/src/node/action/ReportDataAction.ts b/packages/node/src/node/action/ReportDataAction.ts deleted file mode 100644 index b7e982452..000000000 --- a/packages/node/src/node/action/ReportDataAction.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @license - * Copyright 2022-2025 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { AttributePayload } from "../payloads/AttributePayload.js"; -import { EventPayload } from "../payloads/EventPayload.js"; - -/** - * Read response for attribute/event data. - * - * @see {@link MatterSpecification.v11.Core} § 8.4.3 - */ -export type ReportDataAction = { - attributes: AttributePayload.Report[]; - events: EventPayload.Report[]; - suppressResponse?: boolean; -}; - -/** - * Subscription notification payload. - * - * @see {@link MatterSpecification.v11.Core} § 8.4.3 - */ -export type SubscriptionReportDataAction = ReportDataAction & { - subscription: number; -}; diff --git a/packages/node/src/node/action/StatusResponseAction.ts b/packages/node/src/node/action/StatusResponseAction.ts deleted file mode 100644 index 180e49a69..000000000 --- a/packages/node/src/node/action/StatusResponseAction.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @license - * Copyright 2022-2025 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { StatusCode } from "#types"; - -/** - * Reports status for actions with no other response and errors for actions - * that fail. - * - * @see {@link MatterSpecification.v11.Core} § 8.3.1 - */ -export type StatusResponseAction = { - status: StatusCode; -}; diff --git a/packages/node/src/node/action/SubscribeRequestAction.ts b/packages/node/src/node/action/SubscribeRequestAction.ts deleted file mode 100644 index 219153da2..000000000 --- a/packages/node/src/node/action/SubscribeRequestAction.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @license - * Copyright 2022-2025 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { ReadRequestAction } from "./ReadRequestAction.js"; - -/** - * Subscribes to attribute and event updates. - * - * @see {@link MatterSpecification.v11.Core} § 8.5.2 - */ -export type SubscribeRequestAction = ReadRequestAction & { - keepSubscriptions: boolean; - minIntervalFloorSeconds: number; - maxIntervalCeilingSeconds: number; -}; diff --git a/packages/node/src/node/action/SubscribeResponseAction.ts b/packages/node/src/node/action/SubscribeResponseAction.ts deleted file mode 100644 index f7df16e61..000000000 --- a/packages/node/src/node/action/SubscribeResponseAction.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @license - * Copyright 2022-2025 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Notifies subscriber of successful subscription. - * - * @see {@link MatterSpecification.v11.Core} § 8.5.3 - */ -export type SubscribeResponseAction = { - subscription: number; - maxIntervalSeconds: number; -}; diff --git a/packages/node/src/node/action/TimedRequestAction.ts b/packages/node/src/node/action/TimedRequestAction.ts deleted file mode 100644 index a60681065..000000000 --- a/packages/node/src/node/action/TimedRequestAction.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @license - * Copyright 2022-2025 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Informs the receiver that a transaction must complete time out if incomplete - * when an interval elapses. - * - * @see {@link MatterSpecification.v11.Core} § 8.7.4 - */ -export type TimedRequestAction = { - timeoutMilliseconds: number; -}; diff --git a/packages/node/src/node/action/WriteRequestAction.ts b/packages/node/src/node/action/WriteRequestAction.ts deleted file mode 100644 index 33f7d3dee..000000000 --- a/packages/node/src/node/action/WriteRequestAction.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @license - * Copyright 2022-2025 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { AttributePayload } from "../payloads/AttributePayload.js"; - -/** - * Initiates attribute modification. - * - * @see {@link MatterSpecification.v11.Core} § 8.7.2 - */ -export type WriteRequestAction = { - attributes: AttributePayload.Write[]; - timedRequest?: boolean; - suppressResponse?: boolean; -}; diff --git a/packages/node/src/node/action/WriteResponseAction.ts b/packages/node/src/node/action/WriteResponseAction.ts deleted file mode 100644 index e5d104196..000000000 --- a/packages/node/src/node/action/WriteResponseAction.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @license - * Copyright 2022-2025 Matter.js Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { AttributePayload } from "../payloads/AttributePayload.js"; - -/** - * Informs invoker of result of attribute modification. - * - * @see {@link MatterSpecification.v11.Core} § 8.7.3 - */ -export type WriteResponseAction = { - responses: AttributePayload.Response; - suppressResponse?: boolean; - timedRequest?: boolean; -}; diff --git a/packages/node/src/node/client/ClientNodes.ts b/packages/node/src/node/client/ClientNodes.ts index 33e4fb3b5..599858d30 100644 --- a/packages/node/src/node/client/ClientNodes.ts +++ b/packages/node/src/node/client/ClientNodes.ts @@ -4,8 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { CommissioningDiscovery, ContinuousDiscovery, Discovery, InstanceDiscovery } from "#behavior/index.js"; import { RemoteDescriptor } from "#behavior/system/commissioning/RemoteDescriptor.js"; +import { CommissioningDiscovery } from "#behavior/system/controller/discovery/CommissioningDiscovery.js"; +import { ContinuousDiscovery } from "#behavior/system/controller/discovery/ContinuousDiscovery.js"; +import { Discovery } from "#behavior/system/controller/discovery/Discovery.js"; +import { InstanceDiscovery } from "#behavior/system/controller/discovery/InstanceDiscovery.js"; import { EndpointContainer } from "#endpoint/properties/EndpointContainer.js"; import { CancelablePromise, Lifespan, Time } from "#general"; import { ServerNodeStore } from "#node/storage/ServerNodeStore.js"; diff --git a/packages/node/src/node/client/NodePeerStore.ts b/packages/node/src/node/client/NodePeerStore.ts index 4fe139114..fd233a9a4 100644 --- a/packages/node/src/node/client/NodePeerStore.ts +++ b/packages/node/src/node/client/NodePeerStore.ts @@ -6,7 +6,7 @@ import { RemoteDescriptor } from "#behavior/system/commissioning/RemoteDescriptor.js"; import { InternalError } from "#general"; -import { ServerNode } from "#node/ServerNode.js"; +import type { ServerNode } from "#node/ServerNode.js"; import { OperationalPeer, PeerAddress, PeerAddressStore, PeerDataStore } from "#protocol"; /** diff --git a/packages/node/src/node/paths/EventPath.ts b/packages/node/src/node/paths/EventPath.ts index 0e090d77f..9229d3fe2 100644 --- a/packages/node/src/node/paths/EventPath.ts +++ b/packages/node/src/node/paths/EventPath.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { EventId } from "#types"; +import { EventId, TlvEventPath, TypeFromSchema } from "#types"; import { BasePath } from "./BasePath.js"; export namespace EventPath { @@ -33,3 +33,5 @@ export namespace EventPath { event: Address; }; } + +export type EventPath = TypeFromSchema; diff --git a/packages/node/src/node/server/IdentityService.ts b/packages/node/src/node/server/IdentityService.ts index bc10f2384..7079a6501 100644 --- a/packages/node/src/node/server/IdentityService.ts +++ b/packages/node/src/node/server/IdentityService.ts @@ -6,7 +6,7 @@ import { OfflineContext } from "#behavior/context/server/OfflineContext.js"; import { IndexBehavior } from "#behavior/system/index/IndexBehavior.js"; -import { Endpoint } from "#endpoint/Endpoint.js"; +import type { Endpoint } from "#endpoint/Endpoint.js"; import { ImplementationError } from "#general"; /** diff --git a/packages/node/src/node/server/ProtocolService.ts b/packages/node/src/node/server/ProtocolService.ts new file mode 100644 index 000000000..188cf14ec --- /dev/null +++ b/packages/node/src/node/server/ProtocolService.ts @@ -0,0 +1,324 @@ +/** + * @license + * Copyright 2022-2025 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Behavior } from "#behavior/Behavior.js"; +import { ClusterBehavior } from "#behavior/cluster/ClusterBehavior.js"; +import type { BehaviorBacking } from "#behavior/internal/BehaviorBacking.js"; +import { Datasource } from "#behavior/state/managed/Datasource.js"; +import { ValueSupervisor } from "#behavior/supervision/ValueSupervisor.js"; +import type { Endpoint } from "#endpoint/Endpoint.js"; +import { ImplementationError } from "#general"; +import { AcceptedCommandList, AttributeList, ElementTag, GeneratedCommandList, Matter } from "#model"; +import type { Node } from "#node/Node.js"; +import type { + AttributeTypeProtocol, + ClusterProtocol, + ClusterTypeProtocol, + CollectionProtocol, + EndpointProtocol, + NodeProtocol, +} from "#protocol"; +import { AccessControl, FabricManager } from "#protocol"; +import type { AttributeId, ClusterId, DeviceTypeId, EndpointNumber, FabricIndex, TlvSchema } from "#types"; +import { DescriptorBehavior } from "../../behaviors/descriptor/DescriptorBehavior.js"; + +/** + * Protocol view of a {@link Node} + * + * This service maintains an optimized {@link NodeProtocol} that maps to the state of a {@link Node}. + * + * The protocol view only contains endpoints and clusters with active backings. {@link Behaviors} conveys backing + * state via the public interface. + */ +export class ProtocolService { + #state: NodeState; + + constructor(node: Node) { + this.#state = new NodeState(node); + } + + addCluster(backing: BehaviorBacking) { + const { schema } = backing.type; + if (schema?.tag !== ElementTag.Cluster || schema.id === undefined) { + return; + } + + this.#state.stateFor(backing.endpoint).addCluster(backing); + } + + deleteCluster(backing: BehaviorBacking) { + if (this.#state.hasEndpoint(backing.endpoint)) { + this.#state.stateFor(backing.endpoint).deleteCluster(backing); + } + } + + get protocol() { + return this.#state.protocol; + } +} + +const WildcardPathFlags = { + skipRootNode: 1, + skipGlobalAttributes: 1 << 1, + skipAttributeList: 1 << 2, + skipCommandLists: 1 << 3, + skipCustomElements: 1 << 4, + skipFixedAttributes: 1 << 5, + skipChangesOmittedAttributes: 1 << 6, + skipDiagnosticsClusters: 1 << 7, +}; + +class NodeState { + protocol: NodeProtocol; + #endpoints = new Set(); + #endpointStates = {} as Record; + + constructor(node: Node) { + let fabrics: FabricManager | undefined; + + this.protocol = { + matter: Matter, + + nodeIdFor(index: FabricIndex) { + if (!fabrics) { + fabrics = node.env.get(FabricManager); + } + return fabrics.findByIndex(index)?.nodeId; + }, + + [Symbol.iterator]: this.#endpoints[Symbol.iterator].bind(this.#endpoints), + + toString() { + return `node-proto#${node.id}`; + }, + + inspect() { + return this.toString(); + }, + } satisfies NodeProtocol & { toString(): string; inspect(): string } as NodeProtocol; + } + + stateFor(endpoint: Endpoint) { + const { number } = endpoint; + let state = this.#endpointStates[number]; + if (state !== undefined) { + return state; + } + + state = new EndpointState(this, endpoint); + this.protocol[number] = state.protocol; + this.#endpoints.add(state.protocol); + this.#endpointStates[number] = state; + + return state; + } + + hasEndpoint(endpoint: Endpoint) { + return endpoint.number in this.#endpointStates; + } + + deleteEndpoint(endpoint: EndpointProtocol) { + delete this.protocol[endpoint.id]; + this.#endpoints.delete(endpoint); + delete this.#endpointStates[endpoint.id]; + } +} + +class EndpointState { + protocol: EndpointProtocol; + #node: NodeState; + #activeClusters = new Set(); + #clusters = new Set(); + + constructor(node: NodeState, endpoint: Endpoint) { + this.#node = node; + this.protocol = { + id: endpoint.number, + wildcardPathFlags: endpoint.number === 0 ? WildcardPathFlags.skipRootNode : 0, + path: endpoint.path, + deviceTypes: [], + + toString() { + return `endpoint-proto#${this.id}`; + }, + + inspect() { + return this.toString(); + }, + + [Symbol.iterator]: this.#clusters[Symbol.iterator].bind(this.#clusters), + } satisfies EndpointProtocol & { toString(): string; inspect(): string } as EndpointProtocol; + } + + addCluster(backing: BehaviorBacking) { + const type = clusterTypeProtocolOf(backing.type); + if (!type) { + return; + } + + const cluster = new ClusterState(type, backing.datasource, this.protocol.id); + + // When descriptor behavior initializes, sync device types + if (backing.type.id === DescriptorBehavior.id) { + this.#updateDeviceTypes(backing.endpoint.state.descriptor.deviceTypeList); + (backing.events as DescriptorBehavior["events"]).deviceTypeList$Changed.on( + this.#updateDeviceTypes.bind(this), + ); + } + + this.protocol[cluster.type.id] = cluster; + this.#activeClusters.add(cluster.type.id); + this.#clusters.add(cluster); + } + + deleteCluster(backing: BehaviorBacking) { + const { schema } = backing.type; + if (schema === undefined) { + return; + } + + const { tag, id } = schema; + if (tag !== ElementTag.Cluster || id === undefined) { + return; + } + + const protocol = this.protocol[id]; + if (protocol) { + this.#clusters.delete(protocol); + delete this.protocol[id]; + } + + this.#activeClusters.delete(id as ClusterId); + + if (!this.#activeClusters.size) { + this.#node.deleteEndpoint(this.protocol); + } + } + + #updateDeviceTypes(deviceTypeList: Readonly<{ deviceType: DeviceTypeId }[]>) { + this.protocol.deviceTypes = deviceTypeList.map(dt => dt.deviceType); + } +} + +class ClusterState implements ClusterProtocol { + type: ClusterTypeProtocol; + + #datasource: Datasource; + #endpointId: number; + + get version() { + return this.#datasource.version; + } + + get location() { + return this.#datasource.location; + } + + open(session: AccessControl.Session) { + if (!("transaction" in session)) { + throw new ImplementationError("Cluster protocol must be opened with a supervisor session"); + } + return this.#datasource.reference(session as ValueSupervisor.Session); + } + + toString() { + return `cluster-proto#${this.#endpointId}:${this.type.id}`; + } + + inspect() { + return this.toString(); + } + + constructor(type: ClusterTypeProtocol, datasource: Datasource, endpointId: EndpointNumber) { + this.type = type; + this.#datasource = datasource; + this.#endpointId = endpointId; + } +} + +const behaviorCache = new WeakMap(); + +function clusterTypeProtocolOf(behavior: Behavior.Type): ClusterTypeProtocol | undefined { + if (behaviorCache.has(behavior)) { + return behaviorCache.get(behavior); + } + + const { cluster, schema } = behavior as ClusterBehavior.Type; + if (cluster === undefined || schema?.id === undefined) { + return; + } + + const tlvs = {} as Record>; + for (const attr of Object.values(cluster.attributes)) { + tlvs[attr.id] = attr.schema; + } + + let wildcardPathFlags = schema.effectiveQuality.diagnostics ? WildcardPathFlags.skipDiagnosticsClusters : 0; + if (schema.id & 0xffff0000) { + wildcardPathFlags |= WildcardPathFlags.skipCustomElements; + } + + const attrList = Array(); + const attributes: CollectionProtocol = { + [Symbol.iterator]: attrList[Symbol.iterator].bind(attrList), + }; + + for (const member of behavior.supervisor.membersOf(schema)) { + const { id, tag, effectiveQuality: quality } = member; + + if (tag !== "attribute" || id === undefined) { + continue; + } + + const tlv = tlvs[id]; + if (tlv === undefined) { + continue; + } + + let wildcardPathFlags; + switch (id) { + case GeneratedCommandList.id: + case AcceptedCommandList.id: + wildcardPathFlags = WildcardPathFlags.skipGlobalAttributes | WildcardPathFlags.skipCommandLists; + break; + + case AttributeList.id: + wildcardPathFlags = WildcardPathFlags.skipGlobalAttributes | WildcardPathFlags.skipAttributeList; + break; + + default: + wildcardPathFlags = 0; + break; + } + + if (id & 0xffff0000) { + wildcardPathFlags |= WildcardPathFlags.skipGlobalAttributes; + } + if (quality.fixed) { + wildcardPathFlags |= WildcardPathFlags.skipFixedAttributes; + } + if (quality.changesOmitted) { + wildcardPathFlags |= WildcardPathFlags.skipChangesOmittedAttributes; + } + + const { + access: { limits }, + } = behavior.supervisor.get(member); + + const attr = { id: id as AttributeId, tlv, wildcardPathFlags, limits }; + attrList.push(attr); + attributes[id] = attr; + } + + const descriptor: ClusterTypeProtocol = { + id: schema.id as ClusterId, + attributes, + wildcardPathFlags, + }; + behaviorCache.set(behavior, descriptor); + + return descriptor; +} diff --git a/packages/node/src/node/server/ServerEnvironment.ts b/packages/node/src/node/server/ServerEnvironment.ts index ebb983d1e..68a1373a6 100644 --- a/packages/node/src/node/server/ServerEnvironment.ts +++ b/packages/node/src/node/server/ServerEnvironment.ts @@ -5,7 +5,7 @@ */ import { EndpointInitializer } from "#endpoint/index.js"; -import { ServerNode } from "#node/ServerNode.js"; +import type { ServerNode } from "#node/ServerNode.js"; import { ServerNodeStore } from "#node/storage/ServerNodeStore.js"; import { FabricManager, SessionManager } from "#protocol"; import { ServerEndpointInitializer } from "../../endpoint/server/ServerEndpointInitializer.js"; diff --git a/packages/node/src/node/server/TransactionalInteractionServer.ts b/packages/node/src/node/server/TransactionalInteractionServer.ts index 7218c9852..6c0106158 100644 --- a/packages/node/src/node/server/TransactionalInteractionServer.ts +++ b/packages/node/src/node/server/TransactionalInteractionServer.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AccessControl } from "#behavior/AccessControl.js"; import { ActionContext } from "#behavior/context/ActionContext.js"; import { ActionTracer } from "#behavior/context/ActionTracer.js"; import { NodeActivity } from "#behavior/context/NodeActivity.js"; @@ -16,8 +15,8 @@ import { EndpointLifecycle } from "#endpoint/properties/EndpointLifecycle.js"; import { EndpointServer } from "#endpoint/server/EndpointServer.js"; import { Diagnostic, InternalError, Logger, MaybePromise } from "#general"; import { + AccessControl, AccessDeniedError, - AclEndpointContext, AnyAttributeServer, AnyEventServer, AttributePath, @@ -37,7 +36,7 @@ import { WriteRequest, WriteResponse, } from "#protocol"; -import { EndpointNumber, StatusCode, StatusResponseError, TlvEventFilter, TypeFromSchema } from "#types"; +import { StatusCode, StatusResponseError, TlvEventFilter, TypeFromSchema } from "#types"; import { AccessControlServer } from "../../behaviors/access-control/AccessControlServer.js"; import { ServerNode } from "../ServerNode.js"; @@ -67,34 +66,33 @@ const AclAttributeId = AccessControlCluster.attributes.acl.id; export class TransactionalInteractionServer extends InteractionServer { #endpointStructure: InteractionEndpointStructure; #changeListener: (type: EndpointLifecycle.Change, endpoint: Endpoint) => void; - #endpoint: Endpoint; + #node: ServerNode; #activity: NodeActivity; #newActivityBlocked = false; #aclServer?: AccessControlServer; #aclUpdateIsDelayedInExchange = new Set(); - #endpointContexts = new Map(); - static async create(endpoint: Endpoint, sessions: SessionManager) { + static async create(node: ServerNode, sessions: SessionManager) { const structure = new InteractionEndpointStructure(); - return new TransactionalInteractionServer(endpoint, { + return new TransactionalInteractionServer(node, { sessions, structure, - subscriptionOptions: endpoint.state.network.subscriptionOptions, - maxPathsPerInvoke: endpoint.state.basicInformation.maxPathsPerInvoke, + subscriptionOptions: node.state.network.subscriptionOptions, + maxPathsPerInvoke: node.state.basicInformation.maxPathsPerInvoke, initiateExchange: (address, protocolId) => - endpoint.env.get(ExchangeManager).initiateExchange(address, protocolId), + node.env.get(ExchangeManager).initiateExchange(address, protocolId), }); } - constructor(endpoint: Endpoint, context: InteractionContext) { + constructor(node: ServerNode, context: InteractionContext) { super(context); const { structure } = context; - this.#activity = endpoint.env.get(NodeActivity); + this.#activity = node.env.get(NodeActivity); - this.#endpoint = endpoint; + this.#node = node; this.#endpointStructure = structure; // TODO - rewrite element lookup so we don't need to build the secondary endpoint structure cache @@ -114,14 +112,14 @@ export class TransactionalInteractionServer extends InteractionServer { } }; - endpoint.lifecycle.changed.on(this.#changeListener); + node.lifecycle.changed.on(this.#changeListener); } async [Symbol.asyncDispose]() { - this.#endpoint.lifecycle.changed.off(this.#changeListener); + this.#node.lifecycle.changed.off(this.#changeListener); await this.close(); this.#endpointStructure.close(); - await EndpointServer.forEndpoint(this.#endpoint)[Symbol.asyncDispose](); + await EndpointServer.forEndpoint(this.#node)[Symbol.asyncDispose](); } blockNewActivity() { @@ -149,40 +147,13 @@ export class TransactionalInteractionServer extends InteractionServer { if (this.#aclServer !== undefined) { return this.#aclServer; } - const aclServer = this.#endpoint.act(agent => agent.get(AccessControlServer)); + const aclServer = this.#node.act(agent => agent.get(AccessControlServer)); if (MaybePromise.is(aclServer)) { throw new InternalError("AccessControlServer should already be initialized."); } return (this.#aclServer = aclServer); } - /** - * Gets the context information from an endpoint needed for ACL checks. It prefills a cache if needed. - * The cache is cleared on structural changes and initialized again on next need. - * TODO Remove with the legacy API - */ - #getAclEndpointContext(endpointId: EndpointNumber) { - if (!this.#endpointContexts.has(endpointId)) { - this.#endpoint.visit(({ number, state }) => { - if (number !== undefined && !this.#endpointContexts.has(number)) { - this.#endpointContexts.set(number, { - number, - deviceTypes: state.descriptor.deviceTypeList.map(({ deviceType }) => deviceType), - }); - } - }); - } - const context = this.#endpointContexts.get(endpointId); - if (context) { - return context; - } else { - throw new StatusResponseError( - "Endpoint not found for ACL check. This should never happen.", - StatusCode.UnsupportedEndpoint, - ); - } - } - protected override readAttribute( path: AttributePath, attribute: AnyAttributeServer, @@ -207,8 +178,7 @@ export class TransactionalInteractionServer extends InteractionServer { exchange, tracer: this.#tracer, actionType: ActionTracer.ActionType.Read, - endpointContext: this.#getAclEndpointContext(path.endpointId), - root: this.#endpoint, + node: this.#node, }).act(readAttribute); if (MaybePromise.is(result)) { @@ -222,7 +192,6 @@ export class TransactionalInteractionServer extends InteractionServer { * This can currently only be used for subscriptions because errors are ignored! */ protected override readEndpointAttributesForSubscription( - endpointId: EndpointNumber, attributes: { path: AttributePath; attribute: AnyAttributeServer }[], exchange: MessageExchange, fabricFiltered: boolean, @@ -265,8 +234,7 @@ export class TransactionalInteractionServer extends InteractionServer { exchange, tracer: this.#tracer, actionType: ActionTracer.ActionType.Read, - endpointContext: this.#getAclEndpointContext(endpointId), - root: this.#endpoint, + node: this.#node, }).act(readAttributes); if (MaybePromise.is(result)) { throw new InternalError("Online read should not return a promise."); @@ -283,7 +251,12 @@ export class TransactionalInteractionServer extends InteractionServer { message: Message, ) { const readEvent = (context: ActionContext) => { - if (!context.authorizedFor(event.readAcl, { cluster: path.clusterId } as AccessControl.Location)) { + if ( + context.authorityAt(event.readAcl, { + endpoint: path.endpointId, + cluster: path.clusterId, + } as AccessControl.Location) !== AccessControl.Authority.Granted + ) { throw new AccessDeniedError( `Access to ${path.endpointId}/${Diagnostic.hex(path.clusterId)} denied on ${exchange.session.name}.`, ); @@ -298,8 +271,7 @@ export class TransactionalInteractionServer extends InteractionServer { exchange, tracer: this.#tracer, actionType: ActionTracer.ActionType.Read, - endpointContext: this.#getAclEndpointContext(path.endpointId), - root: this.#endpoint, + node: this.#node, }).act(readEvent); } @@ -380,9 +352,7 @@ export class TransactionalInteractionServer extends InteractionServer { fabricFiltered: true, tracer: this.#tracer, actionType: ActionTracer.ActionType.Write, - endpointContext: this.#getAclEndpointContext(path.endpointId), - - root: this.#endpoint, + node: this.#node, }).act(writeAttribute); } @@ -396,7 +366,12 @@ export class TransactionalInteractionServer extends InteractionServer { timed = false, ) { const invokeCommand = (context: ActionContext) => { - if (!context.authorizedFor(command.invokeAcl, { cluster: path.clusterId } as AccessControl.Location)) { + if ( + context.authorityAt(command.invokeAcl, { + endpoint: endpoint.number, + cluster: path.clusterId, + } as AccessControl.Location) !== AccessControl.Authority.Granted + ) { throw new AccessDeniedError( `Access to ${endpoint.number}/${Diagnostic.hex(path.clusterId)} denied on ${exchange.session.name}.`, ); @@ -412,23 +387,20 @@ export class TransactionalInteractionServer extends InteractionServer { exchange, tracer: this.#tracer, actionType: ActionTracer.ActionType.Invoke, - endpointContext: this.#getAclEndpointContext(path.endpointId), - root: this.#endpoint, + node: this.#node, }).act(invokeCommand); } get #tracer() { - if (this.#endpoint.env.has(ActionTracer)) { - return this.#endpoint.env.get(ActionTracer); + if (this.#node.env.has(ActionTracer)) { + return this.#node.env.get(ActionTracer); } } #updateStructure() { - if (this.#endpoint.lifecycle.isPartsReady) { - const server = EndpointServer.forEndpoint(this.#endpoint); + if (this.#node.lifecycle.isPartsReady) { + const server = EndpointServer.forEndpoint(this.#node); this.#endpointStructure.initializeFromEndpoint(server); - - this.#endpointContexts.clear(); } } } diff --git a/packages/node/test/behavior/state/managed/DatasourceTest.ts b/packages/node/test/behavior/state/managed/DatasourceTest.ts index 36d520c39..cb30d69ad 100644 --- a/packages/node/test/behavior/state/managed/DatasourceTest.ts +++ b/packages/node/test/behavior/state/managed/DatasourceTest.ts @@ -7,15 +7,15 @@ import { ActionContext } from "#behavior/context/ActionContext.js"; import { NodeActivity } from "#behavior/context/NodeActivity.js"; import { OfflineContext } from "#behavior/context/server/OfflineContext.js"; -import { StateType } from "#behavior/state/StateType.js"; -import { Val } from "#behavior/state/Val.js"; import { Datasource } from "#behavior/state/managed/Datasource.js"; -import { FinalizationError } from "#behavior/state/transaction/Errors.js"; +import { StateType } from "#behavior/state/StateType.js"; import { BehaviorSupervisor } from "#behavior/supervision/BehaviorSupervisor.js"; import { RootSupervisor } from "#behavior/supervision/RootSupervisor.js"; import { ValueSupervisor } from "#behavior/supervision/ValueSupervisor.js"; -import { AsyncObservable, MaybePromise, Observable } from "#general"; +import { AsyncObservable, MaybePromise, Observable, UnsettledStateError } from "#general"; import { DataModelPath, DatatypeModel, FieldElement, FieldModel } from "#model"; +import { Val } from "#protocol"; +import { EndpointNumber } from "#types"; class MyState { foo = "bar"; @@ -52,7 +52,10 @@ function createDatasource( options: Partial> = {}, ): Datasource { return Datasource({ - path: DataModelPath("TestDatasource"), + location: { + endpoint: EndpointNumber(1), + path: DataModelPath("TestDatasource"), + }, type: (options.type ?? MyState) as T, supervisor, ...options, @@ -278,7 +281,11 @@ describe("Datasource", () => { expect(ds.view.foo).equals("xxxx"); }); - async function assertRejects(observer: (ds: Datasource, ...args: any[]) => MaybePromise) { + async function assertRejects( + type: new () => Error, + message: string, + observer: (ds: Datasource, ...args: any[]) => MaybePromise, + ) { const events = { foo$Changing: AsyncObservable() }; const ds = createDatasource({ events }); @@ -286,27 +293,31 @@ describe("Datasource", () => { events.foo$Changing.on((...args: any[]) => observer(ds, ...args)); await expect(withReference(ds, async ({ state }) => (state.foo = "x"))).eventually.rejectedWith( - FinalizationError, - "Rolled back due to pre-commit error", + type, + message, ); expect(ds.view.foo).equals("bar"); } it("disallows infinite cascading mutations", async () => { - await assertRejects(async (ds, newValue, _oldValue, context) => { - ds.reference(context).foo = `${newValue}x`; - }); + await assertRejects( + UnsettledStateError, + "State has not settled after 5 pre-commit cycles which likely indicates an infinite loop", + async (ds, newValue, _oldValue, context) => { + ds.reference(context).foo = `${newValue}x`; + }, + ); }); it("aborts on sync listener error", async () => { - await assertRejects(() => { + await assertRejects(Error, "oops", () => { throw new Error("oops"); }); }); it("aborts on async listener error", async () => { - await assertRejects(async () => { + await assertRejects(Error, "oops", async () => { throw new Error("oops"); }); }); diff --git a/packages/node/test/behavior/state/managed/values/ListManagerTest.ts b/packages/node/test/behavior/state/managed/values/ListManagerTest.ts index 30b1f4fea..27897ce43 100644 --- a/packages/node/test/behavior/state/managed/values/ListManagerTest.ts +++ b/packages/node/test/behavior/state/managed/values/ListManagerTest.ts @@ -38,14 +38,14 @@ export async function testFabricScoped(actor: (struct: TestStruct, lists: TwoLis fabricFiltered: true, fabric: FabricIndex(1), subject: NodeId(1), - root: aclEndpoint([1, 3]), + node: aclEndpoint([1, 3]), }; const cx2 = { fabricFiltered: true, fabric: FabricIndex(2), subject: NodeId(2), - root: aclEndpoint([1, 3]), + node: aclEndpoint([1, 3]), }; return struct.online2(cx1, cx2, async ({ cx1, cx2, ref1, ref2 }) => { @@ -69,7 +69,7 @@ describe("ListManager", () => { it("basic get/set", async () => { const struct = TestStruct({ list: listOf("string") }, { list: [] }); - await struct.online({ subject: NodeId(1), fabric: FabricIndex(1), root: aclEndpoint([1, 3]) }, async ref => { + await struct.online({ subject: NodeId(1), fabric: FabricIndex(1), node: aclEndpoint([1, 3]) }, async ref => { const list = ref.list as string[]; list[0] = "hi"; @@ -88,7 +88,7 @@ describe("ListManager", () => { const struct = TestStruct({ list: listOf("string") }, { list: [] }); await struct.online( - { subject: NodeId(1), fabric: FabricIndex(1), root: aclEndpoint([1, 3]) }, + { subject: NodeId(1), fabric: FabricIndex(1), node: aclEndpoint([1, 3]) }, async (ref, cx) => { const list = ref.list as string[]; @@ -118,7 +118,7 @@ describe("ListManager", () => { it("basic array iteration", async () => { const struct = TestStruct({ list: listOf("string") }, { list: [] }); - await struct.online({ subject: NodeId(1), fabric: FabricIndex(1), root: aclEndpoint([1, 3]) }, async ref => { + await struct.online({ subject: NodeId(1), fabric: FabricIndex(1), node: aclEndpoint([1, 3]) }, async ref => { const list = ref.list as string[]; (list[0] = "hi"), (list[1] = "there"); diff --git a/packages/node/test/behavior/state/managed/values/StructManagerTest.ts b/packages/node/test/behavior/state/managed/values/StructManagerTest.ts index c24738315..ebc279bf1 100644 --- a/packages/node/test/behavior/state/managed/values/StructManagerTest.ts +++ b/packages/node/test/behavior/state/managed/values/StructManagerTest.ts @@ -6,13 +6,12 @@ import { ActionContext } from "#behavior/context/ActionContext.js"; import { OfflineContext } from "#behavior/context/server/OfflineContext.js"; -import { ConstraintError } from "#behavior/errors.js"; import { Datasource } from "#behavior/state/managed/Datasource.js"; -import { Val } from "#behavior/state/Val.js"; import { RootSupervisor } from "#behavior/supervision/RootSupervisor.js"; import { MaybePromise } from "#general"; import { ClusterModel, DataModelPath, FeatureMap, FeatureSet, FieldElement } from "#model"; -import { FabricIndex, NodeId } from "#types"; +import { ConstraintError, Val } from "#protocol"; +import { EndpointNumber, FabricIndex, NodeId } from "#types"; import { aclEndpoint, TestStruct } from "./value-utils.js"; export type Nested = { @@ -24,7 +23,7 @@ export type Nested = { const TestContext = { fabric: FabricIndex(1), subject: NodeId(1), - root: aclEndpoint([1, 3]), + node: aclEndpoint([1, 3]), }; function testNested( @@ -77,7 +76,11 @@ async function testDuality(life: boolean, actor: (struct: { alive?: boolean }) = const supervisor = RootSupervisor.for(schema); - const datasource = Datasource({ type: SchrödingersCatsState, supervisor, path: DataModelPath(0) }); + const datasource = Datasource({ + type: SchrödingersCatsState, + supervisor, + location: { endpoint: EndpointNumber(1), path: DataModelPath(0) }, + }); await OfflineContext.act("test", undefined, cx => { actor(datasource.reference(cx)); diff --git a/packages/node/test/behavior/state/managed/values/value-utils.ts b/packages/node/test/behavior/state/managed/values/value-utils.ts index 7098f9b6f..f03572afb 100644 --- a/packages/node/test/behavior/state/managed/values/value-utils.ts +++ b/packages/node/test/behavior/state/managed/values/value-utils.ts @@ -6,13 +6,14 @@ import { ActionContext } from "#behavior/context/ActionContext.js"; import { OnlineContext } from "#behavior/context/server/OnlineContext.js"; -import { Val } from "#behavior/state/Val.js"; import { Datasource } from "#behavior/state/managed/Datasource.js"; import { RootSupervisor } from "#behavior/supervision/RootSupervisor.js"; import { ValueSupervisor } from "#behavior/supervision/ValueSupervisor.js"; -import { Endpoint } from "#endpoint/Endpoint.js"; import { camelize, Identity, MaybePromise, Observable } from "#general"; import { DataModelPath, FieldElement, FieldModel } from "#model"; +import { Node } from "#node/Node.js"; +import { Val } from "#protocol"; +import { EndpointNumber } from "#types"; /** * Create schema for a single field. @@ -82,7 +83,10 @@ export function TestStruct(fields: Record } const datasource = Datasource({ - path: DataModelPath("TestStruct"), + location: { + endpoint: EndpointNumber(1), + path: DataModelPath("TestStruct"), + }, type: TestState, supervisor, defaults, @@ -117,8 +121,14 @@ export type TestStruct = Identity>; export function aclEndpoint(acls: number[]) { return { + protocol: { + 1: { + deviceTypes: [], + }, + }, + act: () => ({ accessLevelsFor: () => acls, }), - } as unknown as Endpoint; + } as unknown as Node; } diff --git a/packages/node/test/behavior/state/validation/conformanceTest.ts b/packages/node/test/behavior/state/validation/conformanceTest.ts index ab7904f41..065e0b281 100644 --- a/packages/node/test/behavior/state/validation/conformanceTest.ts +++ b/packages/node/test/behavior/state/validation/conformanceTest.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ConformanceError } from "#behavior/errors.js"; import { FieldElement } from "#model"; +import { ConformanceError } from "#protocol"; import { Features, Fields, Tests, testValidation } from "./validation-test-utils.js"; function missing(conformance: string, fieldName = "test") { diff --git a/packages/node/test/behavior/state/validation/constraintTest.ts b/packages/node/test/behavior/state/validation/constraintTest.ts index a0a3ddba2..2b0d9fda1 100644 --- a/packages/node/test/behavior/state/validation/constraintTest.ts +++ b/packages/node/test/behavior/state/validation/constraintTest.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ConstraintError } from "#behavior/errors.js"; +import { ConstraintError } from "#protocol"; import { Fields, Tests, testValidation } from "./validation-test-utils.js"; const AllTests = Tests({ diff --git a/packages/node/test/behaviors/switch/SwitchServerTest.ts b/packages/node/test/behaviors/switch/SwitchServerTest.ts index 8191c9ba7..f78a7d0f7 100644 --- a/packages/node/test/behaviors/switch/SwitchServerTest.ts +++ b/packages/node/test/behaviors/switch/SwitchServerTest.ts @@ -103,9 +103,7 @@ describe("SwitchServer", () => { describe("test custom validators", () => { it("Accept valid currentPosition", async () => { const device = await createLatchingSwitch(); - await expect(device.set({ switch: { currentPosition: 1 } })).to.not.be.rejectedWith( - "Rolled back due to pre-commit error", - ); + await expect(device.set({ switch: { currentPosition: 1 } })).to.not.be.rejected; }); it("Reject invalid currentPosition", async () => { @@ -117,9 +115,7 @@ describe("SwitchServer", () => { it("Accept valid rawPosition", async () => { const device = await createLatchingSwitch(); - await expect(device.set({ switch: { rawPosition: 1 } })).to.not.be.rejectedWith( - "Rolled back due to pre-commit error", - ); + await expect(device.set({ switch: { rawPosition: 1 } })).to.not.be.rejected; }); it("Reject invalid rawPosition", async () => { diff --git a/packages/node/test/behaviors/time-format-localization/TimeFormatLocalizationServerTest.ts b/packages/node/test/behaviors/time-format-localization/TimeFormatLocalizationServerTest.ts index a06a1f746..9e8c57a06 100644 --- a/packages/node/test/behaviors/time-format-localization/TimeFormatLocalizationServerTest.ts +++ b/packages/node/test/behaviors/time-format-localization/TimeFormatLocalizationServerTest.ts @@ -4,10 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ConstraintError } from "#behavior/errors.js"; import { TimeFormatLocalizationServer } from "#behaviors/time-format-localization"; import { TimeFormatLocalization } from "#clusters/time-format-localization"; import { ServerNode } from "#node/ServerNode.js"; +import { ConstraintError } from "#protocol"; import { MockServerNode } from "../../node/mock-server-node.js"; function createEndpoint() { diff --git a/packages/node/test/endpoint/server/BehaviorServerTest.ts b/packages/node/test/endpoint/server/BehaviorServerTest.ts index 5948fa7a7..53907848c 100644 --- a/packages/node/test/endpoint/server/BehaviorServerTest.ts +++ b/packages/node/test/endpoint/server/BehaviorServerTest.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { NetworkServer } from "#behavior/system/network/NetworkServer.js"; import { NetworkCommissioningServer } from "#behaviors/network-commissioning"; import { OnOffServer } from "#behaviors/on-off"; import { AccessControl } from "#clusters/access-control"; @@ -16,16 +15,13 @@ import { OnOffLightDevice } from "#devices/on-off-light"; import { Bytes } from "#general"; import { AcceptedCommandList, FeatureMap, GeneratedCommandList, Specification } from "#model"; import { - ChannelManager, ExchangeManager, Fabric, FabricBuilder, FabricManager, - InteractionServerMessenger, Message, MessageExchange, MessageType, - SessionType, } from "#protocol"; import { AttributeId, @@ -40,20 +36,16 @@ import { TlvDataReport, TlvEnum, TlvField, - TlvInvokeRequest, TlvInvokeResponseData, - TlvInvokeResponseForSend, TlvNullable, TlvObject, - TlvReadRequest, TlvStatusResponse, TlvSubjectId, - TlvSubscribeRequest, - TlvWriteRequest, TypeFromSchema, VendorId, } from "#types"; import { MockServerNode } from "../../node/mock-server-node.js"; +import { interaction } from "../../node/node-helpers.js"; const ROOT_CERT = Bytes.fromHex( "153001010024020137032414001826048012542826058015203b37062414001824070124080130094104d89eb7e3f3226d0918f4b85832457bb9981bca7aaef58c18fb5ec07525e472b2bd1617fb75ee41bd388f94ae6a6070efc896777516a5c54aff74ec0804cdde9d370a3501290118240260300414e766069362d7e35b79687161644d222bdde93a68300514e766069362d7e35b79687161644d222bdde93a6818300b404e8fb06526f0332b3e928166864a6d29cade53fb5b8918a6d134d0994bf1ae6dce6762dcba99e80e96249d2f1ccedb336b26990f935dba5a0b9e5b4c9e5d1d8f1818181824ff0118", @@ -104,111 +96,6 @@ class WifiCommissioningServer extends NetworkCommissioningServer.with("WiFiNetwo } } -async function connect(node: MockServerNode, fabric: Fabric) { - const exchange = await node.createExchange({ fabric }); - - const interactionServer = node.behaviors.internalsOf(NetworkServer).runtime.interactionServer; - - return { exchange, interactionServer }; -} - -async function performWrite( - node: MockServerNode, - fabric: Fabric, - request: TypeFromSchema["writeRequests"][number], -) { - const { exchange, interactionServer } = await connect(node, fabric); - - await interactionServer.handleWriteRequest( - exchange, - { - suppressResponse: true, - interactionModelRevision: Specification.INTERACTION_MODEL_REVISION, - timedRequest: false, - writeRequests: [request], - }, - { - packetHeader: { sessionType: SessionType.Unicast }, - } as Message, - ); -} - -async function performRead( - node: MockServerNode, - fabric: Fabric, - isFabricFiltered: boolean, - request: Exclude["attributeRequests"], undefined>[number], -) { - const { exchange, interactionServer } = await connect(node, fabric); - - const result = await interactionServer.handleReadRequest( - exchange, - { - interactionModelRevision: Specification.INTERACTION_MODEL_REVISION, - attributeRequests: [request], - isFabricFiltered: isFabricFiltered, - }, - { - packetHeader: { sessionType: SessionType.Unicast }, - } as Message, - ); - const data = result.payload?.next(); - return typeof data.value === "object" && "attributeData" in data.value - ? data.value.attributeData?.payload - : undefined; -} - -const BarelyMockedMessenger = { - sendStatus: _code => {}, - sendDataReport: async (_report, _forFabricFilteredRead) => {}, - send: async (_type, _message) => {}, - close: async () => {}, -} as InteractionServerMessenger; - -const BarelyMockedMessage = { - packetHeader: { sessionType: SessionType.Unicast }, -} as Message; - -async function performInvoke( - node: MockServerNode, - fabric: Fabric, - request: TypeFromSchema["invokeRequests"][number], - responder: (value: TypeFromSchema) => void, -) { - const { exchange, interactionServer } = await connect(node, fabric); - - await interactionServer.handleInvokeRequest( - exchange, - { - invokeRequests: [request], - interactionModelRevision: Specification.INTERACTION_MODEL_REVISION, - suppressResponse: false, - timedRequest: false, - }, - { - ...BarelyMockedMessenger, - send: async (_type, message) => { - const response = TlvInvokeResponseForSend.decode(message).invokeResponses[0]; - responder(TlvInvokeResponseData.decodeTlv(response)); - }, - } as InteractionServerMessenger, - BarelyMockedMessage, - ); -} - -async function performSubscribe( - node: MockServerNode, - fabric: Fabric, - request: TypeFromSchema, -) { - const { exchange, interactionServer } = await connect(node, fabric); - - const channelManager = node.env.get(ChannelManager); - channelManager.getChannel = () => exchange.channel; - - await interactionServer.handleSubscribeRequest(exchange, request, BarelyMockedMessenger, BarelyMockedMessage); -} - // This is AccessControl.AccessControlEntryStruct but not sure how to remove fabric index from payload so just // redefining for now const AcesWithoutFabric = TlvObject({ @@ -219,7 +106,7 @@ const AcesWithoutFabric = TlvObject({ }); async function writeAcl(node: MockServerNode, fabric: Fabric, acl: TypeFromSchema) { - await performWrite(node, fabric, { + await interaction.write(node, fabric, { path: { endpointId: EndpointNumber(0), clusterId: ClusterId(AccessControl.Cluster.id), @@ -230,7 +117,7 @@ async function writeAcl(node: MockServerNode, fabric: Fabric, acl: TypeFromSchem } async function readAcls(node: MockServerNode, fabric: Fabric, isFabricFiltered: boolean) { - return await performRead(node, fabric, isFabricFiltered, { + return await interaction.read(node, fabric, isFabricFiltered, { endpointId: EndpointNumber(0), clusterId: AccessControl.Cluster.id, attributeId: AttributeId(AccessControl.Cluster.attributes.acl.id), @@ -290,7 +177,7 @@ describe("BehaviorServer", () => { const fabric1 = await createFabric(node, 1); // Create a subscription to a couple of attributes and an event - await performSubscribe(node, fabric1, { + await interaction.subscribe(node, fabric1, { interactionModelRevision: Specification.INTERACTION_MODEL_REVISION, isFabricFiltered: false, attributeRequests: [FABRICS_PATH, COMMISSIONED_FABRICS_PATH], @@ -393,7 +280,7 @@ describe("BehaviorServer", () => { const fabric = await createFabric(node, 1); - const commands = await performRead(node, fabric, false, { + const commands = await interaction.read(node, fabric, false, { endpointId: EndpointNumber(1), clusterId: ClusterId(NetworkCommissioning.Cluster.id), attributeId: AttributeId(AcceptedCommandList.id), @@ -403,7 +290,7 @@ describe("BehaviorServer", () => { NetworkCommissioning.WiFiNetworkInterfaceOrThreadNetworkInterfaceComponent.commands.scanNetworks.requestId, ]); - const commandResponds = await performRead(node, fabric, false, { + const commandResponds = await interaction.read(node, fabric, false, { endpointId: EndpointNumber(1), clusterId: ClusterId(NetworkCommissioning.Cluster.id), attributeId: AttributeId(GeneratedCommandList.id), @@ -421,7 +308,7 @@ describe("BehaviorServer", () => { const node = await MockServerNode.createOnline({ device: MyDevice }); - const featureMap = await performRead(node, await createFabric(node, 1), false, { + const featureMap = await interaction.read(node, await createFabric(node, 1), false, { endpointId: EndpointNumber(1), clusterId: ClusterId(OnOff.Cluster.id), attributeId: AttributeId(FeatureMap.id), @@ -452,7 +339,7 @@ describe("BehaviorServer", () => { const node = await MockServerNode.createOnline({ device: MyDevice }); - await performInvoke( + await interaction.invoke( node, await createFabric(node, 1), { diff --git a/packages/node/test/endpoints/BridgedNodeEndpointTest.ts b/packages/node/test/endpoints/BridgeTest.ts similarity index 96% rename from packages/node/test/endpoints/BridgedNodeEndpointTest.ts rename to packages/node/test/endpoints/BridgeTest.ts index 167c4138b..0f8f4de34 100644 --- a/packages/node/test/endpoints/BridgedNodeEndpointTest.ts +++ b/packages/node/test/endpoints/BridgeTest.ts @@ -11,23 +11,7 @@ import { Endpoint } from "#endpoint/Endpoint.js"; import { AggregatorEndpoint } from "#endpoints/aggregator"; import { BridgedNodeEndpoint } from "#endpoints/bridged-node"; import { Environment, StorageBackendMemory, StorageService } from "#general"; -import { MockEndpoint } from "../endpoint/mock-endpoint.js"; -import { MockServerNode } from "../node/mock-server-node.js"; - -const BridgedLightDevice = OnOffLightDevice.with(BridgedDeviceBasicInformationServer); - -async function createBridge( - definition: T | Endpoint.Configuration, - options?: MockEndpoint.Options, -): Promise> { - const config = Endpoint.configurationFor(definition, options); - const bridge = await MockEndpoint.create(config); - - const node = bridge.owner as MockServerNode; - await node.start(); - - return bridge; -} +import { BridgedLightDevice, createBridge } from "./bridge-helpers.js"; function expectBridgedLight(bridge: Endpoint) { const light = bridge.parts.require("light"); @@ -46,7 +30,7 @@ function expectBridgedLight(bridge: Endpoint) { ]); } -describe("BridgedNodeEndpointTest", () => { +describe("a bridge", () => { describe("makes children bridged", () => { it("at startup", async () => { const bridge = await createBridge({ diff --git a/packages/node/test/endpoints/LargeBridgeTest.ts b/packages/node/test/endpoints/LargeBridgeTest.ts new file mode 100644 index 000000000..90d9764db --- /dev/null +++ b/packages/node/test/endpoints/LargeBridgeTest.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2022-2025 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Endpoint } from "#endpoint/Endpoint.js"; +import { AggregatorEndpoint } from "#endpoints/aggregator"; +import { FabricManager } from "#protocol"; +import { FabricIndex } from "#types"; +import { MockServerNode } from "../node/mock-server-node.js"; +import { CommissioningHelper, interaction } from "../node/node-helpers.js"; +import { BridgedLightDevice, createBridge } from "./bridge-helpers.js"; + +const commissioning = CommissioningHelper(); + +// TODO - this should be 1000+ but requires additional optimization first +const NODE_COUNT = 100; + +async function createLargeBridge() { + const parts = Array(); + + for (let i = 0; i < NODE_COUNT; i++) { + parts.push({ + type: BridgedLightDevice, + id: `light-${i}`, + }); + } + + const result = createBridge({ + type: AggregatorEndpoint, + parts, + }); + + for (let i = 0; i < NODE_COUNT * 10; i++) { + await MockTime.yield(); + } + + return result; +} + +describe("a large bridge", () => { + it("instantiates and destroys", async () => { + const bridge = await createLargeBridge(); + await bridge.owner?.close(); + }); + + it("reads in a reasonable amount of time", async () => { + const node = (await createLargeBridge()).owner as MockServerNode; + await commissioning.commission(node); + + await interaction.read(node, node.env.get(FabricManager).findByIndex(FabricIndex(1))!, false, {}); + + await node.close(); + }); +}); diff --git a/packages/node/test/endpoints/bridge-helpers.ts b/packages/node/test/endpoints/bridge-helpers.ts new file mode 100644 index 000000000..af9db6c1e --- /dev/null +++ b/packages/node/test/endpoints/bridge-helpers.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2022-2025 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BridgedDeviceBasicInformationServer } from "#behaviors/bridged-device-basic-information"; +import { OnOffLightDevice } from "#devices/on-off-light"; +import { Endpoint } from "#endpoint/Endpoint.js"; +import { AggregatorEndpoint } from "#endpoints/aggregator"; +import { MockEndpoint } from "../endpoint/mock-endpoint.js"; +import { MockServerNode } from "../node/mock-server-node.js"; + +export const BridgedLightDevice = OnOffLightDevice.with(BridgedDeviceBasicInformationServer); + +export async function createBridge( + definition: T | Endpoint.Configuration, + options?: MockEndpoint.Options, +): Promise> { + const config = Endpoint.configurationFor(definition, options); + const bridge = await MockEndpoint.create(config); + + const node = bridge.owner as MockServerNode; + await node.start(); + + return bridge; +} diff --git a/packages/node/test/node/AttributeResponseTest.ts b/packages/node/test/node/AttributeResponseTest.ts new file mode 100644 index 000000000..55ff6cc99 --- /dev/null +++ b/packages/node/test/node/AttributeResponseTest.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2022-2025 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BasicInformationCluster } from "#clusters/basic-information"; +import { AttributeResponse, Read, ReadResult } from "#protocol"; +import { ClusterId, EndpointNumber } from "#types"; +import { MockServerNode } from "./mock-server-node.js"; + +describe("AttributeReaderRequest", () => { + it("reads concrete attribute", async () => { + const response = await read( + Read.Attribute({ + cluster: BasicInformationCluster, + attributes: "vendorName", + }), + ); + + expect(response).deep.equals([ + [ + { + kind: "attr-value", + path: { + attributeId: 1, + clusterId: 40, + endpointId: 0, + }, + value: "Matter.js Test Vendor", + }, + ], + ]); + }); + + it("reads wildcard endpoint & attributes", async () => { + const response = await read( + Read.Attribute({ + cluster: BasicInformationCluster, + }), + ); + + expect(countAttrs(response)).deep.equals({ + 0: { + 40: 28, + }, + }); + }); + + it("reads full wildcard", async () => { + const response = await read(Read.Attribute()); + expect(countAttrs(response)).deep.equals({ + 0: { + 29: 9, + 31: 8, + 40: 28, + 48: 10, + 51: 14, + 60: 8, + 62: 10, + 63: 9, + }, + }); + }); + + // TODO - more tests +}); + +async function read(...args: Parameters) { + const request = Read(...args); + if (!Read.isAttribute(request)) { + throw new Error("Expected an attribute request"); + } + + const node = await MockServerNode.createOnline(); + + const results = node.online({}, ({ context }) => { + return [...new AttributeResponse(node.protocol, context, request)]; + }); + + return results; +} + +function countAttrs(chunks: ReadResult.Chunk[]) { + const counts = {} as Record>; + for (const chunk of chunks) { + for (const report of chunk) { + if (report.kind !== "attr-value") { + throw new Error("Only attribute values expected"); + } + const endpointCounts = (counts[report.path.endpointId] ??= {}); + endpointCounts[report.path.clusterId] ??= 0; + endpointCounts[report.path.clusterId]++; + } + } + return counts; +} diff --git a/packages/node/test/node/ServerNodeTest.ts b/packages/node/test/node/ServerNodeTest.ts index c05cda625..a161c49c0 100644 --- a/packages/node/test/node/ServerNodeTest.ts +++ b/packages/node/test/node/ServerNodeTest.ts @@ -4,11 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { CommissioningServer } from "#behavior/system/commissioning/CommissioningServer.js"; -import { BasicInformationBehavior } from "#behaviors/basic-information"; import { DescriptorBehavior } from "#behaviors/descriptor"; import { PumpConfigurationAndControlServer } from "#behaviors/pump-configuration-and-control"; -import { GeneralCommissioning } from "#clusters/general-commissioning"; +import { BasicInformationCluster } from "#clusters/basic-information"; import { PumpConfigurationAndControl } from "#clusters/pump-configuration-and-control"; import { ColorTemperatureLightDevice } from "#devices/color-temperature-light"; import { ExtendedColorLightDevice } from "#devices/extended-color-light"; @@ -19,40 +17,26 @@ import { Endpoint } from "#endpoint/Endpoint.js"; import { EndpointBehaviorsError, EndpointPartsError } from "#endpoint/errors.js"; import { AggregatorEndpoint } from "#endpoints/aggregator"; import { - Bytes, CrashedDependenciesError, - Crypto, DnsCodec, DnsMessage, DnsRecordType, Environment, - Key, MockUdpChannel, NetworkSimulator, - PrivateKey, StorageBackendMemory, StorageManager, StorageService, } from "#general"; +import { OfflineContext } from "#index.js"; +import { AccessLevel, BasicInformation, ElementTag, FeatureMap } from "#model"; import { ServerNode } from "#node/ServerNode.js"; -import { AttestationCertificateManager, CertificationDeclarationManager, FabricManager } from "#protocol"; -import { NodeId, VendorId } from "#types"; +import { AttestationCertificateManager, CertificationDeclarationManager, Val } from "#protocol"; +import { VendorId } from "#types"; import { MockServerNode } from "./mock-server-node.js"; +import { CommissioningHelper, Fixtures, testFactoryReset } from "./node-helpers.js"; -let commissionForFabricNumber: number | undefined = undefined; - -Crypto.get().createKeyPair = () => { - const DEFAULT_SEC1_KEY = Bytes.fromHex( - "30770201010420aef3484116e9481ec57be0472df41bf499064e5024ad869eca5e889802d48075a00a06082a8648ce3d030107a144034200043c398922452b55caf389c25bd1bca4656952ccb90e8869249ad8474653014cbf95d687965e036b521c51037e6b8cedefca1eb44046694fa08882eed6519decba", - ); - - const sec1Key = - commissionForFabricNumber !== undefined - ? Fixtures.fabrics[commissionForFabricNumber]?.sec1Key - : DEFAULT_SEC1_KEY; - - return Key({ sec1: sec1Key }) as PrivateKey; -}; +const commissioning = CommissioningHelper(); describe("ServerNode", () => { before(() => { @@ -60,7 +44,7 @@ describe("ServerNode", () => { }); beforeEach(() => { - commissionForFabricNumber = undefined; + commissioning.fabricNumber = undefined; }); describe("emits correct lifecycle changes", () => { @@ -100,6 +84,8 @@ describe("ServerNode", () => { ["partsReady"], ["online"], ["offline"], + ["destroying", "node0"], + ["destroying", "node0.part0"], ["destroyed", "node0.part0"], ["destroyed", "node0"], ]); @@ -126,6 +112,8 @@ describe("ServerNode", () => { ["partsReady", "node0.part0"], ["online"], ["offline"], + ["destroying", "node0"], + ["destroying", "node0.part0"], ["destroyed", "node0.part0"], ["destroyed", "node0"], ]); @@ -152,6 +140,8 @@ describe("ServerNode", () => { ["ready", "node0.part0"], ["partsReady", "node0.part0"], ["offline"], + ["destroying", "node0"], + ["destroying", "node0.part0"], ["destroyed", "node0.part0"], ["destroyed", "node0"], ]); @@ -226,7 +216,7 @@ describe("ServerNode", () => { }); it("commissions", async () => { - const { node } = await commission(); + const { node } = await commissioning.commission(); await MockTime.resolve(node.cancel()); @@ -234,7 +224,7 @@ describe("ServerNode", () => { }); it("times out commissioning", async () => { - const { node } = await almostCommission(); + const { node } = await commissioning.almostCommission(); const opcreds = node.state.operationalCredentials; @@ -306,7 +296,7 @@ describe("ServerNode", () => { }); it("decommissions and recommissions", async () => { - const { node, contextOptions } = await commission(); + const { node, contextOptions } = await commissioning.commission(); const fabricIndex = await node.online( contextOptions, @@ -333,39 +323,11 @@ describe("ServerNode", () => { if (!node.lifecycle.isOnline) { await MockTime.resolve(node.lifecycle.online); } - await commission(node); + await commissioning.commission(node); await node.close(); }); - async function testFactoryReset(mode: "online" | "offline-after-commission" | "offline") { - let node: MockServerNode; - if (mode !== "offline") { - ({ node } = await commission()); - } else { - node = await MockServerNode.createOnline({ online: false }); - } - - if (mode === "offline-after-commission") { - await node.cancel(); - } - - await MockTime.resolve(node.erase()); - - // Confirm previous online state is resumed - expect(node.lifecycle.isOnline).equals(mode === "online"); - - // Confirm basic state information is present - expect(node.stateOf(BasicInformationBehavior).vendorName).equals("Matter.js Test Vendor"); - - // Confirm pairing codes are available - const pairingCodes = node.stateOf(CommissioningServer).pairingCodes; - expect(typeof pairingCodes).equals("object"); - expect(typeof pairingCodes.manualPairingCode).equals("string"); - - await node.close(); - } - it("factory resets when offline after commission", async () => { await testFactoryReset("offline-after-commission"); }); @@ -379,7 +341,7 @@ describe("ServerNode", () => { }); it("commissions twice", async () => { - const { node } = await commission(); + const { node } = await commissioning.commission(); let lastCommissionedFabricCount; node.events.operationalCredentials.commissionedFabrics$Changed.on(commissionedFabrics => { @@ -396,7 +358,7 @@ describe("ServerNode", () => { lastFabricsCount = fabrics.length; }); - await commission(node, 1); + await commissioning.commission(node, 1); expect(node.state.operationalCredentials.nocs.length).equals(2); expect(Object.keys(node.state.commissioning.fabrics).length).equals(2); @@ -426,7 +388,7 @@ describe("ServerNode", () => { const node = await MockServerNode.createOnline({ device: aggregator }); - await commission(node); + await commissioning.commission(node); expect(node.stateOf(DescriptorBehavior).partsList).deep.equals([aggregator.number, light.number, pump.number]); expect(aggregator.stateOf(DescriptorBehavior).partsList).deep.equals([light.number, pump.number]); @@ -439,7 +401,7 @@ describe("ServerNode", () => { describe("crashes gracefully", () => { const badNodeEnv = new Environment("test"); - badNodeEnv.vars.set("behaviors.basicInformation.version", "not a number"); + badNodeEnv.vars.set("behaviors.basicInformation.vendorId", "not a number"); const badEndpointEnv = new Environment("test"); badEndpointEnv.vars.set("behaviors.illuminancemeasurement.diet", "duck food"); @@ -473,7 +435,7 @@ describe("ServerNode", () => { config: { type: MockServerNode.RootEndpoint, environment: badNodeEnv }, device: undefined, }), - ).rejectedWith(EndpointBehaviorsError, "Behaviors have errors"); + ).rejectedWith(EndpointBehaviorsError, 'Cannot convert "not a number" to an integer'); }); it("from behavior error on child during startup", async () => { @@ -482,7 +444,7 @@ describe("ServerNode", () => { config: { type: MockServerNode.RootEndpoint, environment: badEndpointEnv, id: "foo" }, device: LightSensorDevice, }), - ).rejectedWith(EndpointBehaviorsError, "Behaviors have errors"); + ).rejectedWith(EndpointBehaviorsError, 'Property "diet" is unsupported'); }); it("from behavior error on child added after startup", async () => { @@ -492,7 +454,7 @@ describe("ServerNode", () => { }); await expect(node.add(LightSensorDevice)).rejectedWith( CrashedDependenciesError, - "Behaviors have errors", + 'Property "diet" is unsupported', ); }); }); @@ -518,8 +480,9 @@ describe("ServerNode", () => { id: "foo", number: 1, colorControl: { - startUpColorTemperatureMireds: 0, - coupleColorTempToLevelMinMireds: 0, + colorMode: 0, + startUpColorTemperatureMireds: 1, + coupleColorTempToLevelMinMireds: 1, }, }); @@ -538,155 +501,68 @@ describe("ServerNode", () => { id: "foo", number: 1, colorControl: { - startUpColorTemperatureMireds: 0, - coupleColorTempToLevelMinMireds: 0, + startUpColorTemperatureMireds: 1, + coupleColorTempToLevelMinMireds: 1, }, }); await node.close(); } }); -}); - -async function almostCommission(node?: MockServerNode, number = 0) { - if (!node) { - node = await MockServerNode.createOnline(); - } - - const params = Fixtures.fabrics[number]; - commissionForFabricNumber = number; - - const exchange = await node.createExchange(); - const context = { exchange, command: true }; - - await node.online(context, async agent => { - await agent.generalCommissioning.armFailSafe({ expiryLengthSeconds: Fixtures.failsafeLengthS, breadcrumb: 4 }); - }); - - await node.online(context, async agent => { - await agent.generalCommissioning.setRegulatoryConfig({ - newRegulatoryConfig: 2, - countryCode: "XX", - breadcrumb: 5, - }); - }); - - await node.online(context, async agent => { - await agent.operationalCredentials.certificateChainRequest({ certificateType: 2 }); - }); - - await node.online(context, async agent => { - await agent.operationalCredentials.certificateChainRequest({ certificateType: 1 }); - }); - - await node.online(context, async agent => { - await agent.operationalCredentials.attestationRequest({ attestationNonce: params.attestationNonce }); - }); + describe("initializes protocol", () => { + it("with part at startup", async () => { + const node = new MockServerNode({ parts: [OnOffLightDevice] }); - await node.online(context, async agent => { - await agent.operationalCredentials.csrRequest({ csrNonce: params.csrNonce }); - }); + await node.start(); + const { protocol } = node; + + expect(protocol).has.property("0"); + expect(protocol).has.property("1"); + expect([...protocol]).length(2); + + const ep0 = protocol[0]!; + expect(typeof ep0 === "object"); + expect(ep0.id).equals(0); + expect(ep0.deviceTypes).deep.equals([22]); + expect(ep0.wildcardPathFlags).equals(0x1); + expect([...ep0]).length( + [...node.behaviors].filter(behavior => behavior.schema?.tag === ElementTag.Cluster).length, + ); + + const ep1 = protocol[1]!; + expect(ep1.id).equals(1); + expect(ep1.deviceTypes).deep.equals([256]); + expect(ep1.wildcardPathFlags).equals(0); + expect([...ep1]).length( + [...[...node.parts][0].behaviors].filter(behavior => behavior.schema?.tag === ElementTag.Cluster) + .length, + ); + + expect(ep0).has.property(`${BasicInformation.id}`); + const bi = ep0[BasicInformation.id]!; + expect(typeof bi).equals("object"); + + expect(bi.version).equals(1); + expect(bi.type.id).equals(BasicInformation.id); + expect([...bi.type.attributes].length).equals(28); + + expect(bi.type.attributes).has.property(`${FeatureMap.id}`); + const fm = bi.type.attributes[FeatureMap.id]!; + expect(typeof fm).equals("object"); + + expect(typeof fm.tlv.encode).equals("function"); + expect(typeof fm.limits).equals("object"); + expect(fm.limits.writable).equals(false); + expect(fm.limits.readLevel).equals(AccessLevel.View); + + const fmVals = bi.open(OfflineContext.ReadOnly); + expect((fmVals as Val.Struct).vendorName).equals("Matter.js Test Vendor"); + expect(fmVals[BasicInformationCluster.attributes.vendorName.id]).equals("Matter.js Test Vendor"); - await node.online(context, async agent => { - agent.operationalCredentials.addTrustedRootCertificate({ rootCaCertificate: params.caCert }); - }); + await node.close(); - await node.online(context, async agent => { - const result = await agent.operationalCredentials.addNoc({ - nocValue: params.nocValue, - icacValue: params.icacValue, - ipkValue: params.ipkValue, - caseAdminSubject: NodeId((number + 1) * 100), - adminVendorId: VendorId(65521), + expect([...protocol]).length(0); }); - expect(result.statusCode).deep.equals(0); - }); - - return { node, context }; -} - -async function commission(existingNode?: MockServerNode, number = 0) { - const { node } = await almostCommission(existingNode, number); - - // Do not reuse session from initial commissioning because we must now move from CASE to PASE - const fabric = node.env.get(FabricManager).fabrics[number]; - const contextOptions = { - exchange: await node.createExchange({ - fabric, - peerNodeId: NodeId(number + 1), - }), - command: true, - }; - - await node.online(contextOptions, async agent => { - // Use MockTime.resolve to wait for broadcaster cleanup - const result = await MockTime.resolve(agent.generalCommissioning.commissioningComplete()); - expect(result).deep.equals({ errorCode: GeneralCommissioning.CommissioningError.Ok, debugText: "" }); }); - - if (!node.lifecycle.isCommissioned) { - await node.lifecycle.commissioned; - } - - return { node, contextOptions }; -} - -namespace Fixtures { - function u(hex: string) { - return Bytes.fromHex(hex); - } - - export const failsafeLengthS = 60; - - export const fabrics = [ - { - attestationNonce: u("2cfd6a1c253a03e0f5a9135d841bb443cee50be270ab122ee24b6b0775f53cc6"), - - csrNonce: u("92c333f220a57c8178863176aeebf1a3ef4d8d45f2d4bd1cb5d1b63a29b8eb3e"), - - caCert: u( - "1530010101240201370324140118260480228127260580254d3a370624140118240701240801300941048786311b1347352b08216bc91ecd9e03b1b791ad57f42587b8d62478b853a27414dd7816bbd657b241b3bcc2759998187d10e7e7668fce709bb6611318c19939370a3501290118240260300414898211523c4f998d57c940be9bd24360f503d356300514898211523c4f998d57c940be9bd24360f503d35618300b400d1c064fc9269b4309259f4cbed23f7f10059e29c9a5c728c73b492e8e495af264a4b5338fb2eaefdb3012eeb965eb9eb67f36562a3859c190574929f73eac6a18", - ), - - nocValue: u( - "1530010101240201370324130218260480228127260580254d3a370624150124110118240701240801300941043c398922452b55caf389c25bd1bca4656952ccb90e8869249ad8474653014cbf95d687965e036b521c51037e6b8cedefca1eb44046694fa08882eed6519decba370a350128011824020136030402040118300414b02b87728e8fe0d7becfe48e60e59962c91531c730051494d101c4667b1123bffe5ccbc3a88dba7a9bc94018300b40fbf900de9b3e2771363af8902eff38edc7b129d54c111087e0221d58ca4afbc74bfa379248a4a2a85ae21baeb33b3cc1dae2e98aa2dbf663081ede54a05bade318", - ), - - icacValue: u( - "1530010101240201370324140118260480228127260580254d3a370624130218240701240801300941045789231805399ce966e541c5d4f554f8c079ad6e9b45d4a3a69d848a4b495c2d10e94270a21bb87bd6241d2d0c05fe481455f1b3a1f51dc8d09e16567946032f370a350129011824026030041494d101c4667b1123bffe5ccbc3a88dba7a9bc940300514898211523c4f998d57c940be9bd24360f503d35618300b40ea5633574251e3a556b6e2adb06eee429a1eb9f7d884415dddaed609637adbe9ee0ee818d92d95acf60bdd548967d9976a18d4081f63964ce7312dff09aec8ec18", - ), - - ipkValue: u("74656d706f726172792069706b203031"), - - sec1Key: u( - "30770201010420aef3484116e9481ec57be0472df41bf499064e5024ad869eca5e889802d48075a00a06082a8648ce3d030107a144034200043c398922452b55caf389c25bd1bca4656952ccb90e8869249ad8474653014cbf95d687965e036b521c51037e6b8cedefca1eb44046694fa08882eed6519decba", - ), - }, - { - attestationNonce: u("35655d2c73a9fd8067443f7394da7b3030bc239a5d6d8de0bc727c1be339c0bf"), - - csrNonce: u("92c333f220a57c8178863176aeebf1a3ef4d8d45f2d4bd1cb5d1b63a29b8eb3e"), - - caCert: u( - "1530010101240201370324140118260480228127260580254d3a37062414011824070124080130094104b9ab722d204c75eaadd8bdf9e45cc50829d50250cdd2b324513ae7209c82e4d5a423ddbcc9e7375214fe7f06ef3c8a75a99315fcbd9eb03934d9a2c8f4e12c35370a3501290118240260300414e11fb699346554a6e43055e4a790bda9b1f0a558300514e11fb699346554a6e43055e4a790bda9b1f0a55818300b40020c2c400f8b8ddb047cecdaf996668496383a62e7597d1984f7335b7fda0448109fa4b5c9dc9ad0d79ee9e1fe60ccbab081932c4b1aa212e5476d65810ccacf18", - ), - - nocValue: u( - "1530010101240201370324130218260480228127260580254d3a3706241501241102182407012408013009410490495e266f4291ffae925d58654ecf67527b052256af369cd81e20ee23d68e6f2e769ba7358eb4459c5df393799a57fe996f1cd4e8bc9ff977e3279f62adf182370a350128011824020136030402040118300414f93ba80eac51bb6001c5eb1a9bcb7695c37ea59530051435efb6ef11a6ba2623301212798224a21cd2832118300b40b7a069f6cc83f0bede4c0eacb33a4d58a4f9ec3fb1423114f36566e6c06d2884d6f3d1f06b957f56b4ccc52e7a440880709b629a7ea983c6779d691045be553818", - ), - - icacValue: u( - "1530010101240201370324140118260480228127260580254d3a37062413021824070124080130094104a5add92a13816f79eece14c3dee05d7a85738d2b746b8d7992744465133adbf56a3d5f0dcc69c9512bb7c93654d52b790f0d28958064d58c4ec8d941d9f989ba370a350129011824026030041435efb6ef11a6ba2623301212798224a21cd28321300514e11fb699346554a6e43055e4a790bda9b1f0a55818300b4074a61f76df08caa2486dfdd8f8a8bad9dd5f2af120c8d2d6ce86d6ca2156feb38e9c1e0f78dded84c1181e03c9b8c860584a2267e2b9cc29b7ff69553fd35aff18", - ), - - ipkValue: u("74656d706f726172792069706b203031"), - - sec1Key: u( - "30770201010420fef3484116e9481ec57be0472df41bf499064e5024ad869eca5e889802d48075a00a06082a8648ce3d030107a144034200043c398922452b55caf389c25bd1bca4656952ccb90e8869249ad8474653014cbf95d687965e036b521c51037e6b8cedefca1eb44046694fa08882eed6519decba", - ), - }, - ]; - - export namespace Fabric2 {} -} +}); diff --git a/packages/node/test/node/mock-node.ts b/packages/node/test/node/mock-node.ts index e7dab14fa..c406eaad7 100644 --- a/packages/node/test/node/mock-node.ts +++ b/packages/node/test/node/mock-node.ts @@ -6,7 +6,6 @@ import { Behavior } from "#behavior/Behavior.js"; import { ServerBehaviorBacking } from "#behavior/internal/ServerBehaviorBacking.js"; -import { Val } from "#behavior/state/Val.js"; import { Endpoint } from "#endpoint/Endpoint.js"; import { EndpointInitializer } from "#endpoint/properties/EndpointInitializer.js"; import { EndpointStore } from "#endpoint/storage/EndpointStore.js"; @@ -17,6 +16,7 @@ import { ServerNode } from "#node/ServerNode.js"; import { IdentityService } from "#node/server/IdentityService.js"; import { EndpointStoreService } from "#node/storage/EndpointStoreService.js"; import { ServerNodeStore } from "#node/storage/ServerNodeStore.js"; +import { Val } from "#protocol"; import { EndpointNumber } from "#types"; export class MockPartInitializer extends EndpointInitializer { diff --git a/packages/node/test/node/mock-server-node.ts b/packages/node/test/node/mock-server-node.ts index 8346a409e..6f85d4f2c 100644 --- a/packages/node/test/node/mock-server-node.ts +++ b/packages/node/test/node/mock-server-node.ts @@ -88,6 +88,9 @@ export class MockServerNode actor(context.agentFor(this))); } diff --git a/packages/node/test/node/node-helpers.ts b/packages/node/test/node/node-helpers.ts new file mode 100644 index 000000000..0ad65dd7e --- /dev/null +++ b/packages/node/test/node/node-helpers.ts @@ -0,0 +1,352 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BasicInformationBehavior } from "#behaviors/basic-information"; +import { GeneralCommissioning } from "#clusters/general-commissioning"; +import { Bytes, Crypto, Key, PrivateKey } from "#general"; +import { CommissioningServer, NetworkServer } from "#index.js"; +import { ChannelManager, Fabric, FabricManager, InteractionServerMessenger, Message, SessionType } from "#protocol"; +import { + NodeId, + TlvInvokeRequest, + TlvInvokeResponseData, + TlvInvokeResponseForSend, + TlvReadRequest, + TlvSubscribeRequest, + TlvWriteRequest, + TypeFromSchema, + VendorId, +} from "#types"; +import { Specification } from "@matter/model"; +import { MockServerNode } from "./mock-server-node.js"; + +let activeCommissioning: undefined | ReturnType; + +Crypto.get().createKeyPair = () => { + const DEFAULT_SEC1_KEY = Bytes.fromHex( + "30770201010420aef3484116e9481ec57be0472df41bf499064e5024ad869eca5e889802d48075a00a06082a8648ce3d030107a144034200043c398922452b55caf389c25bd1bca4656952ccb90e8869249ad8474653014cbf95d687965e036b521c51037e6b8cedefca1eb44046694fa08882eed6519decba", + ); + + const sec1Key = + activeCommissioning?.fabricNumber !== undefined + ? Fixtures.fabrics[activeCommissioning.fabricNumber]?.sec1Key + : DEFAULT_SEC1_KEY; + + return Key({ sec1: sec1Key }) as PrivateKey; +}; + +export async function testFactoryReset(mode: "online" | "offline-after-commission" | "offline") { + let node: MockServerNode; + if (mode !== "offline") { + ({ node } = await CommissioningHelper().commission()); + } else { + node = await MockServerNode.createOnline({ online: false }); + } + + if (mode === "offline-after-commission") { + await node.cancel(); + } + + // We want to confirm unique ID is reset but the ID is not random in testing. So set to something known we can + // compare after reset + const oldUniqueId = "asdf"; + await node.set({ basicInformation: { uniqueId: oldUniqueId } }); + + await MockTime.resolve(node.erase()); + + // Confirm previous online state is resumed + expect(node.lifecycle.isOnline).equals(mode === "online"); + + // Confirm basic state information is present + expect(node.stateOf(BasicInformationBehavior).vendorName).equals("Matter.js Test Vendor"); + + // Confirm unique ID did not persist + expect(node.state.basicInformation.uniqueId).not.equals(oldUniqueId); + + // Confirm pairing codes are available + const pairingCodes = node.stateOf(CommissioningServer).pairingCodes; + expect(typeof pairingCodes).equals("object"); + expect(typeof pairingCodes.manualPairingCode).equals("string"); + + await node.close(); +} + +export function CommissioningHelper() { + return { + fabricNumber: undefined as number | undefined, + + async almostCommission(node?: MockServerNode, number = 0) { + activeCommissioning = this; + + try { + if (!node) { + node = await MockServerNode.createOnline(); + } + + const params = Fixtures.fabrics[number]; + this.fabricNumber = number; + + const exchange = await node.createExchange(); + + const context = { exchange, command: true }; + + await node.online(context, async agent => { + await agent.generalCommissioning.armFailSafe({ + expiryLengthSeconds: Fixtures.failsafeLengthS, + breadcrumb: 4, + }); + }); + + await node.online(context, async agent => { + await agent.generalCommissioning.setRegulatoryConfig({ + newRegulatoryConfig: 2, + countryCode: "XX", + breadcrumb: 5, + }); + }); + + await node.online(context, async agent => { + await agent.operationalCredentials.certificateChainRequest({ certificateType: 2 }); + }); + + await node.online(context, async agent => { + await agent.operationalCredentials.certificateChainRequest({ certificateType: 1 }); + }); + + await node.online(context, async agent => { + await agent.operationalCredentials.attestationRequest({ + attestationNonce: params.attestationNonce, + }); + }); + + await node.online(context, async agent => { + await agent.operationalCredentials.csrRequest({ csrNonce: params.csrNonce }); + }); + + await node.online(context, async agent => { + agent.operationalCredentials.addTrustedRootCertificate({ rootCaCertificate: params.caCert }); + }); + + await node.online(context, async agent => { + const result = await agent.operationalCredentials.addNoc({ + nocValue: params.nocValue, + icacValue: params.icacValue, + ipkValue: params.ipkValue, + caseAdminSubject: NodeId((number + 1) * 100), + adminVendorId: VendorId(65521), + }); + expect(result.statusCode).deep.equals(0); + }); + + return { node, context }; + } finally { + activeCommissioning = undefined; + } + }, + + async commission(existingNode?: MockServerNode, number = 0) { + try { + activeCommissioning = this; + + const { node } = await this.almostCommission(existingNode, number); + + // Do not reuse session from initial commissioning because we must now move from CASE to PASE + const fabric = node.env.get(FabricManager).fabrics[number]; + const contextOptions = { + exchange: await node.createExchange({ + fabric, + peerNodeId: NodeId(number + 1), + }), + command: true, + }; + + await node.online(contextOptions, async agent => { + // Use MockTime.resolve to wait for broadcaster cleanup + const result = await MockTime.resolve(agent.generalCommissioning.commissioningComplete()); + expect(result).deep.equals({ + errorCode: GeneralCommissioning.CommissioningError.Ok, + debugText: "", + }); + }); + + if (!node.lifecycle.isCommissioned) { + await node.lifecycle.commissioned; + } + + return { node, contextOptions }; + } finally { + activeCommissioning = undefined; + } + }, + }; +} + +export namespace interaction { + const BarelyMockedMessenger = { + sendStatus: _code => {}, + sendDataReport: async (_report, _forFabricFilteredRead) => {}, + send: async (_type, _message) => {}, + close: async () => {}, + } as InteractionServerMessenger; + + const BarelyMockedMessage = { + packetHeader: { sessionType: SessionType.Unicast }, + } as Message; + + export async function connect(node: MockServerNode, fabric: Fabric) { + const exchange = await node.createExchange({ fabric }); + + const interactionServer = node.behaviors.internalsOf(NetworkServer).runtime.interactionServer; + + return { exchange, interactionServer }; + } + + export async function write( + node: MockServerNode, + fabric: Fabric, + request: TypeFromSchema["writeRequests"][number], + ) { + const { exchange, interactionServer } = await connect(node, fabric); + + await interactionServer.handleWriteRequest( + exchange, + { + suppressResponse: true, + interactionModelRevision: Specification.INTERACTION_MODEL_REVISION, + timedRequest: false, + writeRequests: [request], + }, + { + packetHeader: { sessionType: SessionType.Unicast }, + } as Message, + ); + } + + export async function read( + node: MockServerNode, + fabric: Fabric, + isFabricFiltered: boolean, + request: Exclude["attributeRequests"], undefined>[number], + ) { + const { exchange, interactionServer } = await connect(node, fabric); + + const result = await interactionServer.handleReadRequest( + exchange, + { + interactionModelRevision: Specification.INTERACTION_MODEL_REVISION, + attributeRequests: [request], + isFabricFiltered: isFabricFiltered, + }, + { + packetHeader: { sessionType: SessionType.Unicast }, + } as Message, + ); + + const data = result.payload?.next(); + return typeof data.value === "object" && "attributeData" in data.value + ? data.value.attributeData?.payload + : undefined; + } + + export async function invoke( + node: MockServerNode, + fabric: Fabric, + request: TypeFromSchema["invokeRequests"][number], + responder: (value: TypeFromSchema) => void, + ) { + const { exchange, interactionServer } = await connect(node, fabric); + + await interactionServer.handleInvokeRequest( + exchange, + { + invokeRequests: [request], + interactionModelRevision: Specification.INTERACTION_MODEL_REVISION, + suppressResponse: false, + timedRequest: false, + }, + { + ...BarelyMockedMessenger, + send: async (_type, message) => { + const response = TlvInvokeResponseForSend.decode(message).invokeResponses[0]; + responder(TlvInvokeResponseData.decodeTlv(response)); + }, + } as InteractionServerMessenger, + BarelyMockedMessage, + ); + } + + export async function subscribe( + node: MockServerNode, + fabric: Fabric, + request: TypeFromSchema, + ) { + const { exchange, interactionServer } = await connect(node, fabric); + + const channels = node.env.get(ChannelManager); + channels.getChannel = () => exchange.channel; + + await interactionServer.handleSubscribeRequest(exchange, request, BarelyMockedMessenger, BarelyMockedMessage); + } +} + +export namespace Fixtures { + function u(hex: string) { + return Bytes.fromHex(hex); + } + + export const failsafeLengthS = 60; + + export const fabrics = [ + { + attestationNonce: u("2cfd6a1c253a03e0f5a9135d841bb443cee50be270ab122ee24b6b0775f53cc6"), + + csrNonce: u("92c333f220a57c8178863176aeebf1a3ef4d8d45f2d4bd1cb5d1b63a29b8eb3e"), + + caCert: u( + "1530010101240201370324140118260480228127260580254d3a370624140118240701240801300941048786311b1347352b08216bc91ecd9e03b1b791ad57f42587b8d62478b853a27414dd7816bbd657b241b3bcc2759998187d10e7e7668fce709bb6611318c19939370a3501290118240260300414898211523c4f998d57c940be9bd24360f503d356300514898211523c4f998d57c940be9bd24360f503d35618300b400d1c064fc9269b4309259f4cbed23f7f10059e29c9a5c728c73b492e8e495af264a4b5338fb2eaefdb3012eeb965eb9eb67f36562a3859c190574929f73eac6a18", + ), + + nocValue: u( + "1530010101240201370324130218260480228127260580254d3a370624150124110118240701240801300941043c398922452b55caf389c25bd1bca4656952ccb90e8869249ad8474653014cbf95d687965e036b521c51037e6b8cedefca1eb44046694fa08882eed6519decba370a350128011824020136030402040118300414b02b87728e8fe0d7becfe48e60e59962c91531c730051494d101c4667b1123bffe5ccbc3a88dba7a9bc94018300b40fbf900de9b3e2771363af8902eff38edc7b129d54c111087e0221d58ca4afbc74bfa379248a4a2a85ae21baeb33b3cc1dae2e98aa2dbf663081ede54a05bade318", + ), + + icacValue: u( + "1530010101240201370324140118260480228127260580254d3a370624130218240701240801300941045789231805399ce966e541c5d4f554f8c079ad6e9b45d4a3a69d848a4b495c2d10e94270a21bb87bd6241d2d0c05fe481455f1b3a1f51dc8d09e16567946032f370a350129011824026030041494d101c4667b1123bffe5ccbc3a88dba7a9bc940300514898211523c4f998d57c940be9bd24360f503d35618300b40ea5633574251e3a556b6e2adb06eee429a1eb9f7d884415dddaed609637adbe9ee0ee818d92d95acf60bdd548967d9976a18d4081f63964ce7312dff09aec8ec18", + ), + + ipkValue: u("74656d706f726172792069706b203031"), + + sec1Key: u( + "30770201010420aef3484116e9481ec57be0472df41bf499064e5024ad869eca5e889802d48075a00a06082a8648ce3d030107a144034200043c398922452b55caf389c25bd1bca4656952ccb90e8869249ad8474653014cbf95d687965e036b521c51037e6b8cedefca1eb44046694fa08882eed6519decba", + ), + }, + { + attestationNonce: u("35655d2c73a9fd8067443f7394da7b3030bc239a5d6d8de0bc727c1be339c0bf"), + + csrNonce: u("92c333f220a57c8178863176aeebf1a3ef4d8d45f2d4bd1cb5d1b63a29b8eb3e"), + + caCert: u( + "1530010101240201370324140118260480228127260580254d3a37062414011824070124080130094104b9ab722d204c75eaadd8bdf9e45cc50829d50250cdd2b324513ae7209c82e4d5a423ddbcc9e7375214fe7f06ef3c8a75a99315fcbd9eb03934d9a2c8f4e12c35370a3501290118240260300414e11fb699346554a6e43055e4a790bda9b1f0a558300514e11fb699346554a6e43055e4a790bda9b1f0a55818300b40020c2c400f8b8ddb047cecdaf996668496383a62e7597d1984f7335b7fda0448109fa4b5c9dc9ad0d79ee9e1fe60ccbab081932c4b1aa212e5476d65810ccacf18", + ), + + nocValue: u( + "1530010101240201370324130218260480228127260580254d3a3706241501241102182407012408013009410490495e266f4291ffae925d58654ecf67527b052256af369cd81e20ee23d68e6f2e769ba7358eb4459c5df393799a57fe996f1cd4e8bc9ff977e3279f62adf182370a350128011824020136030402040118300414f93ba80eac51bb6001c5eb1a9bcb7695c37ea59530051435efb6ef11a6ba2623301212798224a21cd2832118300b40b7a069f6cc83f0bede4c0eacb33a4d58a4f9ec3fb1423114f36566e6c06d2884d6f3d1f06b957f56b4ccc52e7a440880709b629a7ea983c6779d691045be553818", + ), + + icacValue: u( + "1530010101240201370324140118260480228127260580254d3a37062413021824070124080130094104a5add92a13816f79eece14c3dee05d7a85738d2b746b8d7992744465133adbf56a3d5f0dcc69c9512bb7c93654d52b790f0d28958064d58c4ec8d941d9f989ba370a350129011824026030041435efb6ef11a6ba2623301212798224a21cd28321300514e11fb699346554a6e43055e4a790bda9b1f0a55818300b4074a61f76df08caa2486dfdd8f8a8bad9dd5f2af120c8d2d6ce86d6ca2156feb38e9c1e0f78dded84c1181e03c9b8c860584a2267e2b9cc29b7ff69553fd35aff18", + ), + + ipkValue: u("74656d706f726172792069706b203031"), + + sec1Key: u( + "30770201010420fef3484116e9481ec57be0472df41bf499064e5024ad869eca5e889802d48075a00a06082a8648ce3d030107a144034200043c398922452b55caf389c25bd1bca4656952ccb90e8869249ad8474653014cbf95d687965e036b521c51037e6b8cedefca1eb44046694fa08882eed6519decba", + ), + }, + ]; + + export namespace Fabric2 {} +} diff --git a/packages/nodejs/src/behavior/instrumentation.ts b/packages/nodejs/src/behavior/instrumentation.ts index ebc74ab05..47c974508 100644 --- a/packages/nodejs/src/behavior/instrumentation.ts +++ b/packages/nodejs/src/behavior/instrumentation.ts @@ -4,7 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Instrumentation, Val } from "#node"; +import { Instrumentation } from "#node"; +import { Val } from "#protocol"; import { inspect } from "util"; /** diff --git a/packages/protocol/src/action/Interactable.ts b/packages/protocol/src/action/Interactable.ts new file mode 100644 index 000000000..ca8f96e24 --- /dev/null +++ b/packages/protocol/src/action/Interactable.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Invoke } from "./request/Invoke.js"; +import { Read } from "./request/Read.js"; +import { Subscribe } from "./request/Subscribe.js"; +import { Write } from "./request/Write.js"; +import { InvokeResult } from "./response/InvokeResult.js"; +import { ReadResult } from "./response/ReadResult.js"; +import { SubscribeResult } from "./response/SubscribeResult.js"; +import { WriteResult } from "./response/WriteResult.js"; +import { AccessControl } from "./server/AccessControl.js"; + +/** + * Objects implementing this interface can participate in Matter interactions. + */ +export interface Interactable { + /** + * Perform a Matter read interaction. + */ + read(request: Read, session?: SessionT): ReadResult; + + /** + * Perform a Matter subscribe interaction. + */ + subscribe(request: Subscribe, session?: SessionT): SubscribeResult; + + /** + * Perform a Matter write interaction. + */ + write(request: T, session?: SessionT): WriteResult; + + /** + * Perform a Matter invoke interaction. + */ + invoke(request: T, session?: SessionT): InvokeResult; +} diff --git a/packages/node/src/behavior/state/Val.ts b/packages/protocol/src/action/Val.ts similarity index 85% rename from packages/node/src/behavior/state/Val.ts rename to packages/protocol/src/action/Val.ts index 0fb8910f4..bd8d87e3b 100644 --- a/packages/node/src/behavior/state/Val.ts +++ b/packages/protocol/src/action/Val.ts @@ -4,11 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AccessControl } from "../AccessControl.js"; -import type { ValueSupervisor } from "../supervision/ValueSupervisor.js"; +import { AccessControl } from "./server/AccessControl.js"; /** - * General type for state values. + * Any JS value representing a value defined by Matter. */ export type Val = unknown; @@ -18,6 +17,11 @@ export namespace Val { */ export type Struct = Record; + /** + * Type for Matter structs encoded using protocol semantics. In JS this is an object with "numeric" keys. + */ + export type ProtocolStruct = Record; + /** * Type for Matter lists. In Js this is an array. */ @@ -94,13 +98,13 @@ export namespace Val { * Unmanaged raw state classes have no contextual information. They may implement this interface to provide an * alternate context-aware object for property read, write and validation. */ - export interface Dynamic extends Struct { + export interface Dynamic extends Struct { /** * Obtain a context-aware property source (and sink). Supervision will read/write properties from here if * present. Otherwise they're read from static state as normal. * * @param owner the owner of the root reference of the managed value - * @param session the {@link ValueSupervisor.Session} accessing the value + * @param session the {@link AccessControl.Session} accessing the value */ [properties](this: This, owner: O, session: S): Partial; } diff --git a/packages/node/src/behavior/errors.ts b/packages/protocol/src/action/errors.ts similarity index 97% rename from packages/node/src/behavior/errors.ts rename to packages/protocol/src/action/errors.ts index 1bbc4f7dd..53a4cb672 100644 --- a/packages/node/src/behavior/errors.ts +++ b/packages/protocol/src/action/errors.ts @@ -4,9 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SchemaErrorPath, ValueModel } from "#model"; +import { Schema, SchemaErrorPath, ValueModel } from "#model"; import { StatusCode, StatusResponseError } from "#types"; -import { Schema } from "./supervision/Schema.js"; export { SchemaImplementationError } from "#model"; diff --git a/packages/protocol/src/action/index.ts b/packages/protocol/src/action/index.ts new file mode 100644 index 000000000..4920ac4d0 --- /dev/null +++ b/packages/protocol/src/action/index.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./errors.js"; +export * from "./Interactable.js"; +export * from "./protocols.js"; +export * from "./request/index.js"; +export * from "./response/index.js"; +export * from "./server/index.js"; +export * from "./Val.js"; diff --git a/packages/protocol/src/action/protocols.ts b/packages/protocol/src/action/protocols.ts new file mode 100644 index 000000000..c9f505300 --- /dev/null +++ b/packages/protocol/src/action/protocols.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2022-2025 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataModelPath, MatterModel } from "#model"; +import type { AttributeId, ClusterId, DeviceTypeId, EndpointNumber, FabricIndex, NodeId, TlvSchema } from "#types"; +import { AccessControl } from "./server/AccessControl.js"; +import { Val } from "./Val.js"; + +/** + * Optimized Matter protocol<->JS object interface + * + * This is the root contract between a Matter protocol implementation and a higher-level Matter data model. + * + * Note that the term "protocol" here has dual meaning. The client of this interface is a Matter Protocol + * implementation. The implementation adheres to a JS protocol in the "interface" sense used by JS specifications. + * + * The goal is for this abstraction to be as inexpensive as possible. Element access is via iteration or direct + * property access using protocol-level numeric IDs. + * + * We limit metadata to parent->child, authorization and marshalling. We limit function calls to a few places where the + * return value depends on session state. + * + * To optimize performance, underlying implementations should align their native structures as closely as possible to + * those defined by these contracts. When adaptation is necessary you can generate objects to perform adaptation, + * possibly with getters and/or setters where caching is infeasible or unnecessary. + */ +export interface NodeProtocol extends CollectionProtocol { + /** + * The active Matter data model. + * + * A protocol implementation may fall back to this model in some cases when element paths do not resolve. + */ + matter: MatterModel; + + /** + * Obtain the ID of this node in the context of a specific fabric. + */ + nodeIdFor(fabric: FabricIndex): NodeId | undefined; +} + +/** + * Protocol contract for a specific endpoint. + */ +export interface EndpointProtocol + extends CollectionProtocol, + AddressableElementProtocol { + /** + * Path to the endpoint within the data model. + */ + path: DataModelPath; + + /** + * List of device types associated with the endpoint. + */ + deviceTypes: DeviceTypeId[]; +} + +/** + * Protocol contract for a specific cluster on a specific endpoint. + */ +export interface ClusterProtocol { + type: ClusterTypeProtocol; + + /** + * Current version of cluster data. + */ + version: number; + + /** + * The location of the cluster within the data model. + */ + location: AccessControl.Location; + + /** + * Access a record of attribute values, keyed by attribute ID. + * + * Note that current protocol implementations do not filter data within this responsibility based on the + * session. So doing so is the responsibility of the node implementation. + */ + open(session: AccessControl.Session): Val.ProtocolStruct; +} + +/** + * Protocol contract for a specific type of cluster (including feature variants). + * + * TODO - commands and events + */ +export interface ClusterTypeProtocol extends AddressableElementProtocol { + /** + * Attribute metadata. + */ + attributes: CollectionProtocol; +} + +/** + * Descriptor for a specific property type. + */ +export interface AttributeTypeProtocol extends AddressableElementProtocol { + /** + * The TLV schema for this property. + */ + tlv: TlvSchema; + + /** + * Access control information for the attribute. + */ + limits: AccessControl.Limits; +} + +/** + * Protocol contract for addressable elements. + */ +export interface AddressableElementProtocol { + /** + * The numeric ID of the element defined by the Matter specification. + */ + id: N; + + /** + * Bitmap with each wildcard path flag bit set where this value should be skipped. + */ + wildcardPathFlags: number; +} + +/** + * A collection of elements that supports lookup by numeric ID and iteration. + */ +export interface CollectionProtocol { + [id: number]: T | undefined; + [Symbol.iterator](): Iterator; +} diff --git a/packages/protocol/src/action/request/Invoke.ts b/packages/protocol/src/action/request/Invoke.ts new file mode 100644 index 000000000..03ebbe8d8 --- /dev/null +++ b/packages/protocol/src/action/request/Invoke.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FALLBACK_INTERACTIONMODEL_REVISION } from "#session/Session.js"; +import { ClusterType, CommandData, InvokeRequest, TlvSchema, TypeFromSchema } from "#types"; +import { MalformedRequestError } from "./MalformedRequestError.js"; +import { Specifier } from "./Specifier.js"; + +export interface Invoke extends InvokeRequest { + timeout?: number; +} + +/** + * Request invocation of one or more commands. + */ +export function Invoke(definition: Invoke.Definition): Invoke { + const { + commands, + interactionModelRevision = FALLBACK_INTERACTIONMODEL_REVISION, + suppressResponse = false, + timed: timedRequest = false, + } = definition; + + if (!commands?.length) { + throw new MalformedRequestError(`Invocation requires at least one command`); + } + + return { + invokeRequests: commands, + interactionModelRevision, + suppressResponse, + timedRequest, + }; +} + +export namespace Invoke { + export interface Definition { + commands: CommandData[]; + suppressResponse?: boolean; + timed?: boolean; + interactionModelRevision?: number; + } + + export function Command(request: Invoke.CommandRequest): CommandData { + const result: CommandData = { + commandPath: { + clusterId: Specifier.clusterFor(request.cluster).id, + commandId: Invoke.commandOf(request).requestId, + }, + }; + + const endpointId = Specifier.endpointIdOf(request); + if (endpointId !== undefined) { + result.commandPath.endpointId = endpointId; + } + + return result; + } + + export type CommandRequest< + C extends Specifier.Cluster = Specifier.Cluster, + CMD extends Specifier.Command> = Specifier.Command>, + > = { + endpoint?: Specifier.Endpoint; + cluster: C; + command: CMD; + } & Fields, CMD>["requestSchema"]>; + + export function commandOf(request: R): ClusterType.Command { + if (typeof request.command === "string") { + const cluster = Specifier.clusterFor(request.cluster); + if (cluster === undefined) { + throw new MalformedRequestError(`Cannot designate command "${request.command}" without cluster`); + } + const command = cluster.commands[request.command]; + if (command === undefined) { + throw new MalformedRequestError(`Cluster ${cluster.name} does not define command ${request.command}`); + } + return command as Specifier.CommandFor, R["command"]>; + } + return request.command as Specifier.CommandFor, R["command"]>; + } + + export type Fields> = + S extends TlvSchema + ? {} + : S extends TlvSchema + ? { fields?: TypeFromSchema } + : { fields: TypeFromSchema }; +} diff --git a/packages/protocol/src/action/request/MalformedRequestError.ts b/packages/protocol/src/action/request/MalformedRequestError.ts new file mode 100644 index 000000000..9e657dd06 --- /dev/null +++ b/packages/protocol/src/action/request/MalformedRequestError.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ImplementationError } from "#general"; + +/** + * Thrown when an action request does not adhere to the Matter specification. + * + * This is a client-side error that throws while formulating a request. + */ +export class MalformedRequestError extends ImplementationError {} diff --git a/packages/protocol/src/action/request/Read.ts b/packages/protocol/src/action/request/Read.ts new file mode 100644 index 000000000..5aa56c266 --- /dev/null +++ b/packages/protocol/src/action/request/Read.ts @@ -0,0 +1,356 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FALLBACK_INTERACTIONMODEL_REVISION } from "#session/Session.js"; +import type { + AttributePath, + ClusterType, + DataVersionFilter, + EventFilter, + EventPath, + GlobalAttributes, + ReadRequest, +} from "#types"; +import { camelize } from "@matter/general"; +import { MalformedRequestError } from "./MalformedRequestError.js"; +import { Specifier } from "./Specifier.js"; + +/** + * A read request formulated using Matter protocol semantics. + */ +export interface Read extends ReadRequest {} + +/** + * Formulate a read request using Matter numeric IDs. + */ +export function Read(options: Read.Options): Read; + +/** + * Formulate a read request with extended options and name-based IDs. + */ +export function Read(options: Read.Options, ...selectors: Read.Selector[]): Read; + +/** + * Formulate a read request with name-based IDs. + */ +export function Read(...selectors: Read.Selector[]): Read; + +export function Read(optionsOrSelector: Read.Options | Read.Selector, ...selectors: Read.Selector[]): Read { + let options; + if ("kind" in optionsOrSelector) { + selectors = [optionsOrSelector, ...selectors]; + options = {}; + } else { + options = optionsOrSelector; + } + let { attributes: attributeRequests, versionFilters, events: eventRequests, eventFilters } = options; + + const result: Read = { + isFabricFiltered: options.fabricFilter ?? true, + interactionModelRevision: options.interactionModelRevision ?? FALLBACK_INTERACTIONMODEL_REVISION, + }; + + for (const selector of selectors) { + reifySelector(selector); + } + + if (!attributeRequests?.length && !eventRequests?.length) { + throw new MalformedRequestError(`Read action designates no attributes or events`); + } + + if (attributeRequests) { + result.attributeRequests = attributeRequests; + } + + if (versionFilters) { + result.dataVersionFilters = versionFilters; + } + + if (eventRequests) { + result.eventRequests = eventRequests; + } + + if (eventFilters !== undefined) { + result.eventFilters = eventFilters; + } + + return result; + + function reifySelector(selector: Read.Selector) { + switch (selector.kind) { + case "attribute": + reifyAttributeSelector(selector); + break; + + case "event": + reifyEventSelector(selector); + break; + + default: + throw new MalformedRequestError(`Invalid selector kind "${(selector as Read.Selector).kind}"`); + } + } + + /** + * Update "real" ReadRequest fields from our convenience attribute "selector". + */ + function reifyAttributeSelector(selector: Read.AttributeSelector) { + const cluster = Specifier.clusterOf(selector); + const { endpoint } = selector; + + // Install data version filter if the endpoint reports it has complete version information + if (typeof endpoint === "object" && endpoint?.versions && typeof cluster === "object" && cluster !== null) { + const version = endpoint.versions?.[camelize(cluster.name)]; + if (version !== undefined) { + const filter = { + path: { endpointId: endpoint.number, clusterId: cluster.id }, + dataVersion: version, + }; + if (versionFilters === undefined) { + versionFilters = [filter]; + } else if ( + versionFilters.find( + ({ path: { endpointId, clusterId } }) => + endpointId === endpoint.number && clusterId === cluster.id, + ) === undefined + ) { + versionFilters.push(filter); + } + } + } + + // Configure base AttributePath + if (attributeRequests === undefined) { + attributeRequests = []; + } + const prototype: AttributePath = {}; + if (endpoint !== undefined) { + prototype.endpointId = Specifier.endpointIdOf(selector); + } + if (cluster !== undefined) { + prototype.clusterId = cluster.id; + } + + // If the attribute is a wildcard we are done + let { attributes } = selector; + if (attributes === undefined) { + attributeRequests.push(prototype); + return; + } + + // Add concrete attribute requests + if (!Array.isArray(attributes)) { + attributes = [attributes]; + } + for (const specifier of attributes) { + attributeRequests.push({ ...prototype, attributeId: Specifier.attributeFor(cluster, specifier).id }); + } + } + + /** + * Update "real" ReadRequest fields from our convenience event "selector" + */ + function reifyEventSelector(selector: Read.EventSelector) { + const cluster = Specifier.clusterOf(selector); + const { endpoint } = selector; + + // Install event minimum if the endpoint reports ingested events + if (typeof endpoint === "object" && endpoint.minEvent !== undefined) { + if (eventFilters === undefined) { + eventFilters = [{ eventMin: endpoint.minEvent }]; + } + } + + // Configure base EventPath + if (eventRequests === undefined) { + eventRequests = []; + } + const prototype: EventPath = {}; + if (endpoint !== undefined) { + prototype.endpointId = Specifier.endpointIdOf(selector); + } + if (cluster !== undefined) { + prototype.clusterId = cluster.id; + } + + // If the event is a wildcard we are done + let { events } = selector; + if (events === undefined) { + eventRequests.push(prototype); + return; + } + + // Add concrete event requests + if (!Array.isArray(events)) { + events = [events]; + } + for (const specifier of events) { + eventRequests.push({ ...prototype, eventId: Specifier.eventFor(cluster, specifier).id }); + } + } +} + +export namespace Read { + export interface Options { + attributes?: AttributePath[]; + versionFilters?: DataVersionFilter[]; + events?: EventPath[]; + eventFilters?: EventFilter[]; + fabricFilter?: boolean; + interactionModelRevision?: number; + } + + export interface Attributes extends Read { + attributeRequests: Exclude; + } + + export function isAttribute(request: Read): request is Attributes { + return !!request.attributeRequests?.length; + } + + export interface Events extends Read { + eventRequests: Exclude; + } + + export function isEvent(request: Read): request is Events { + return !!request.eventRequests?.length; + } + + /** + * Selects attributes or events to read. + */ + export type Selector = + | ({ kind: "attribute" } & AttributeSelector) + | ({ kind: "event" } & EventSelector); + + /** + * Selects attributes to read. Limits fields to legal permutations per the Matter specification. + */ + export type AttributeSelector = + | AttributeSelector.Concrete + | AttributeSelector.FullWildcard + | AttributeSelector.Global + | AttributeSelector.WildcardEndpoint + | AttributeSelector.WildcardAttribute + | AttributeSelector.Endpoint; + + /** + * Selects events to read. Limits fields to legal permutations per the Matter specification. + */ + export type EventSelector = + | EventSelector.Concrete + | EventSelector.FullWildcard + | EventSelector.WildcardEndpoint + | EventSelector.WildcardEvent + | EventSelector.Endpoint; + + export function Attribute( + selector?: AttributeSelector, + ): { kind: "attribute" } & AttributeSelector { + return { + kind: "attribute", + ...selector, + }; + } + + export function Event(selector: T): { kind: "event" } & T { + return { + kind: "event", + ...selector, + }; + } + + export interface WildcardFlags { + skipRoot?: boolean; + skipCustom?: boolean; + skipDiagnostics?: boolean; + skipGlobals?: boolean; + skipAttributeList?: boolean; + skipCommandList?: boolean; + skipFixed?: boolean; + skipChangesOmitted?: boolean; + } + + export type GlobalAttributeSpecifier = ClusterType.Attribute | keyof GlobalAttributes; + + export namespace AttributeSelector { + export interface Concrete { + endpoint: Specifier.Endpoint; + cluster: C; + attributes: Specifier.Attribute> | Specifier.Attribute>[]; + } + + export interface Wildcard { + flags?: WildcardFlags; + } + + export interface FullWildcard extends Wildcard { + endpoint?: undefined; + cluster?: undefined; + attributes?: undefined; + } + + export interface Global extends Wildcard { + endpoint?: Specifier.Endpoint; + cluster?: undefined; + attributes: GlobalAttributeSpecifier | GlobalAttributeSpecifier[]; + } + + export interface WildcardEndpoint extends Wildcard { + endpoint?: undefined; + cluster: C; + attributes: Specifier.Attribute> | Specifier.Attribute>[]; + } + + export interface WildcardAttribute extends Wildcard { + endpoint?: Specifier.Endpoint; + cluster: Specifier.Cluster; + attributes?: undefined; + } + + export interface Endpoint extends Wildcard { + endpoint: Specifier.Endpoint; + cluster?: undefined; + attributes?: undefined; + } + } + + export namespace EventSelector { + export interface Concrete { + endpoint: Specifier.Endpoint; + cluster: Specifier.Cluster; + events: Specifier.Event> | Specifier.Event>[]; + } + + export interface Wildcard { + flags?: WildcardFlags; + } + + export interface FullWildcard extends Wildcard { + endpoint?: undefined; + cluster?: undefined; + events?: undefined; + } + + export interface WildcardEndpoint extends Wildcard { + endpoint?: undefined; + cluster: Specifier.Cluster; + events: Specifier.Event> | Specifier.Event>[]; + } + + export interface WildcardEvent extends Wildcard { + endpoint: Specifier.Endpoint; + cluster: Specifier.Cluster; + events?: undefined; + } + + export interface Endpoint extends Wildcard { + endpoint: Specifier.Endpoint; + cluster?: undefined; + events?: undefined; + } + } +} diff --git a/packages/protocol/src/action/request/Specifier.ts b/packages/protocol/src/action/request/Specifier.ts new file mode 100644 index 000000000..8d2a8c806 --- /dev/null +++ b/packages/protocol/src/action/request/Specifier.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ClusterType, EndpointNumber, GlobalAttributeNames, GlobalAttributes } from "#types"; +import { MalformedRequestError } from "./MalformedRequestError.js"; + +const GlobalAttrMap = GlobalAttributes({}) as Record; + +/** + * A "specifier" addresses a specific element of the Matter data model in the context of a request. + */ +export namespace Specifier { + /** + * Clusters we designate with an object to convey structural and type information. The second form of this union + * allows for specification with a host object (such as a ClusterBehavior instance or class). + */ + export type Cluster = ClusterType | { cluster: ClusterType }; + + /** + * You may address endpoints with a bare ID but the object form allows an "endpoint object" as input and optionally + * conveys additional metadata. + */ + export type Endpoint = + | EndpointNumber + | { number: EndpointNumber; versions?: Record; minEvent?: number }; + + /** + * An attribute specifier may be the name of a cluster attribute or the name of a cluster or global attribute. + */ + export type Attribute = + | ClusterType.Attribute + | (string & keyof C["attributes"]) + | GlobalAttributeNames; + + /** + * A command specifier may be the name of a cluster attribute or the name of a cluster command. + */ + export type Command = ClusterType.Command | (string & keyof C["commands"]); + + /** + * An event specifier may be the name of a cluser event or an event object. + */ + export type Event = ClusterType.Event | (string & keyof C["events"]); + + /** + * Extract a cluster type from a cluster specifier type. + */ + export type ClusterFor = T extends ClusterType + ? T + : T extends { cluster: ClusterType } + ? T["cluster"] + : never; + + /** + * Extract a cluster type from a cluster specifier. + */ + export function clusterFor(specifier: T) { + if ("cluster" in specifier) { + return specifier["cluster"] as ClusterFor; + } + return specifier as ClusterFor; + } + + /** + * Extract an attribute object from a cluster and attribute specifier. + */ + export function attributeFor(cluster: ClusterType | undefined, specifier: Specifier.Attribute) { + if (typeof specifier === "object") { + return specifier; + } + + if (cluster === undefined) { + const attr = GlobalAttrMap[specifier]; + if (attr === undefined) { + throw new MalformedRequestError(`Cannot designate event "${specifier}" without a cluster`); + } + return attr; + } + + const attr = cluster.attributes?.[specifier]; + if (attr === undefined) { + throw new MalformedRequestError(`Cluster ${cluster.name} does not define attribute ${specifier}`); + } + + return attr; + } + + /** + * Extract a command type from a cluster type and command specifier. + */ + export type CommandFor> = CMD extends string + ? C["commands"][CMD] + : CMD extends ClusterType.Command + ? CMD + : never; + + /** + * Extract an event object from a cluster and event specifier. + */ + export function eventFor(cluster: ClusterType | undefined, specifier: Specifier.Event) { + if (typeof specifier === "object") { + return specifier; + } + + if (cluster === undefined) { + throw new MalformedRequestError(`Cannot designate event "${specifier}" without a cluster`); + } + + const event = cluster.events?.[specifier]; + if (event === undefined) { + throw new MalformedRequestError(`Cluster ${cluster.name} does not define event ${specifier}`); + } + + return event; + } + + /** + * Extract the cluster type from a cluster request type. + */ + export type ClusterOf = T extends { cluster: Specifier.Cluster } + ? Specifier.ClusterFor + : undefined; + + /** + * Extract the cluster type from an element request. + */ + export function clusterOf(request: T): ClusterOf { + if (request.cluster) { + return Specifier.clusterFor(request.cluster) as ClusterOf; + } + return undefined as ClusterOf; + } + + /** + * Determine endpoint number for an object with an endpoint specifier. + */ + export function endpointIdOf(request: T): EndpointNumber | undefined { + if (typeof request.endpoint === "number") { + return request.endpoint; + } + return request.endpoint?.number; + } +} diff --git a/packages/protocol/src/action/request/Subscribe.ts b/packages/protocol/src/action/request/Subscribe.ts new file mode 100644 index 000000000..f87c46aea --- /dev/null +++ b/packages/protocol/src/action/request/Subscribe.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { UINT16_MAX } from "#general"; +import { MalformedRequestError } from "./MalformedRequestError.js"; +import { Read } from "./Read.js"; + +/** + * Defines a subscription. + * + * The subscription interval fields are optional as matter.js will set them to appropriate defaults based on physical + * attributes of the target device. If you are unsure of appropriate values do not set them. + */ +export interface Subscribe extends Read { + keepSubscriptions: boolean; + minIntervalFloorSeconds?: number; + maxIntervalCeilingSeconds?: number; +} + +export function Subscribe(options: Subscribe.Options): Subscribe { + const subscribe = Read(options) as unknown as Subscribe; + + const { keepSubscriptions, minIntervalFloorSeconds, maxIntervalCeilingSeconds } = options; + subscribe.keepSubscriptions = keepSubscriptions ?? true; + + if (minIntervalFloorSeconds !== undefined) { + if (minIntervalFloorSeconds < 0 || minIntervalFloorSeconds > UINT16_MAX) { + throw new MalformedRequestError(`Minimum interval floor ${minIntervalFloorSeconds} is out of range`); + } + subscribe.minIntervalFloorSeconds = minIntervalFloorSeconds; + } + + if (maxIntervalCeilingSeconds !== undefined) { + if (maxIntervalCeilingSeconds < 0 || maxIntervalCeilingSeconds > UINT16_MAX) { + throw new MalformedRequestError(`Maximum interval ceiling ${maxIntervalCeilingSeconds} is out of range`); + } + subscribe.maxIntervalCeilingSeconds = maxIntervalCeilingSeconds; + } + + return subscribe; +} + +export namespace Subscribe { + export interface Options extends Read.Options { + keepSubscriptions?: boolean; + minIntervalFloorSeconds?: number; + maxIntervalCeilingSeconds?: number; + } +} + +// TODO - subscribe DSL extending read DSL diff --git a/packages/protocol/src/action/request/Write.ts b/packages/protocol/src/action/request/Write.ts new file mode 100644 index 000000000..e605d9d33 --- /dev/null +++ b/packages/protocol/src/action/request/Write.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WriteRequest } from "#types"; + +export interface Write extends WriteRequest { + timeout?: number; +} + +// TODO - write DSL diff --git a/packages/protocol/src/action/request/index.ts b/packages/protocol/src/action/request/index.ts new file mode 100644 index 000000000..87a5b52f7 --- /dev/null +++ b/packages/protocol/src/action/request/index.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./Invoke.js"; +export * from "./Read.js"; +export * from "./Specifier.js"; +export * from "./Subscribe.js"; +export * from "./Write.js"; diff --git a/packages/protocol/src/action/response/InvokeResult.ts b/packages/protocol/src/action/response/InvokeResult.ts new file mode 100644 index 000000000..8ebab3bd5 --- /dev/null +++ b/packages/protocol/src/action/response/InvokeResult.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Invoke } from "#action/request/Invoke.js"; +import type { CommandData } from "#types"; +import { CancelablePromise } from "@matter/general"; + +export type InvokeResult = T extends { suppressResponse: true } + ? CancelablePromise + : AsyncIterable; + +export namespace InvokeResult { + export type Chunk = CommandData[]; +} diff --git a/packages/protocol/src/action/response/ReadResult.ts b/packages/protocol/src/action/response/ReadResult.ts new file mode 100644 index 000000000..e7df49165 --- /dev/null +++ b/packages/protocol/src/action/response/ReadResult.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + AttributeId, + AttributePath, + ClusterId, + EndpointNumber, + EventId, + EventPath, + NodeId, + Status, + StatusCode, +} from "#types"; + +/** + * Streaming result for a Matter protocol-level read. + * + * These structures contain data for AttributeReportIB and EventReportIB. We don't use the deeply-nested native TLV + * structure directly for reasons of performance and developer convenience. + * + * Iteration occurs in chunks for performance reasons. A chunk is an iterable of reports, one per output attribute or + * event. + */ +export interface ReadResult extends AsyncIterable {} + +export namespace ReadResult { + export type Chunk = Iterable; + + export type Report = + | AttributeValue + | GlobalAttributeStatus + | ClusterAttributeStatus + | EventValue + | GlobalEventStatus + | ClusterEventStatus; + + export interface ConcreteAttributePath extends AttributePath { + nodeId?: NodeId; + endpointId: EndpointNumber; + clusterId: ClusterId; + attributeId: AttributeId; + } + + export interface AttributeValue { + kind: "attr-value"; + path: ConcreteAttributePath; + value: unknown; + } + + export interface GlobalAttributeStatus { + kind: "attr-status"; + path: ConcreteAttributePath; + status: StatusCode; + } + + export interface ClusterAttributeStatus { + kind: "attr-cluster-status"; + path: ConcreteAttributePath; + clusterStatus: number; + } + + export interface ConcreteEventPath extends EventPath { + nodeId?: NodeId; + endpointId: EndpointNumber; + clusterId: ClusterId; + eventId: EventId; + } + + export interface EventValue { + kind: "event-value"; + path: ConcreteEventPath; + } + + export interface GlobalEventStatus { + kind: "event-status"; + path: ConcreteEventPath; + status: Status; + } + + export interface ClusterEventStatus { + kind: "event-cluster-status"; + path: ConcreteEventPath; + clusterStatus: number; + } +} diff --git a/packages/protocol/src/action/response/SubscribeResult.ts b/packages/protocol/src/action/response/SubscribeResult.ts new file mode 100644 index 000000000..232daa894 --- /dev/null +++ b/packages/protocol/src/action/response/SubscribeResult.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SubscribeResponse } from "#types"; +import { ReadResult } from "./ReadResult.js"; + +export interface SubscribeResult extends AsyncIterator {} + +export namespace SubscribeResult { + export type Chunk = ReadResult.Chunk | SubscribeResponse; +} diff --git a/packages/protocol/src/action/response/WriteResult.ts b/packages/protocol/src/action/response/WriteResult.ts new file mode 100644 index 000000000..5ab15d0be --- /dev/null +++ b/packages/protocol/src/action/response/WriteResult.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Write } from "#action/request/Write.js"; +import type { CancelablePromise } from "#general"; +import type { WriteResponse } from "#types"; + +export type WriteResult = CancelablePromise< + T extends { suppressResponse: true } ? void : WriteResponse +>; diff --git a/packages/protocol/src/action/response/index.ts b/packages/protocol/src/action/response/index.ts new file mode 100644 index 000000000..86ed3d84c --- /dev/null +++ b/packages/protocol/src/action/response/index.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2022-2025 Matter.js Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./InvokeResult.js"; +export * from "./ReadResult.js"; +export * from "./SubscribeResult.js"; +export * from "./WriteResult.js"; diff --git a/packages/node/src/behavior/AccessControl.ts b/packages/protocol/src/action/server/AccessControl.ts similarity index 89% rename from packages/node/src/behavior/AccessControl.ts rename to packages/protocol/src/action/server/AccessControl.ts index d9f4e089a..b8f83f147 100644 --- a/packages/node/src/behavior/AccessControl.ts +++ b/packages/protocol/src/action/server/AccessControl.ts @@ -4,10 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Access, AccessLevel, DataModelPath, ElementTag, ValueModel } from "#model"; -import { ClusterId, FabricIndex, StatusCode, SubjectId } from "#types"; -import { InvokeError, ReadError, SchemaImplementationError, WriteError } from "./errors.js"; -import { Schema } from "./supervision/Schema.js"; +import { Access, AccessLevel, DataModelPath, ElementTag, Schema, ValueModel } from "#model"; +import { ClusterId, EndpointNumber, FabricIndex, StatusCode, SubjectId } from "#types"; +import { InvokeError, ReadError, SchemaImplementationError, WriteError } from "../errors.js"; const cache = new WeakMap(); @@ -103,6 +102,11 @@ export namespace AccessControl { */ path: DataModelPath; + /** + * The owning endpoint. + */ + endpoint?: EndpointNumber; + /** * The owning behavior. */ @@ -113,12 +117,6 @@ export namespace AccessControl { * enforcement. */ owningFabric?: FabricIndex; - - /** - * The access levels already retrieved for this location. With this subtree elements can access the same - * access levels without re-evaluating. - */ - accessLevels?: AccessLevel[]; } /** @@ -126,9 +124,9 @@ export namespace AccessControl { */ export interface Session { /** - * Checks if the authorized client has a certain Access Privilege granted. + * Determine whether authorized client has authority at a specific location. */ - authorizedFor(desiredAccessLevel: AccessLevel, location?: Location): boolean; + authorityAt(desiredAccessLevel: AccessLevel, location?: Location): Authority; /** * The fabric of the authorized client. @@ -141,8 +139,12 @@ export namespace AccessControl { readonly subject?: SubjectId; /** - * If this is true, fabric-scoped lists are filtered to the accessing - * fabric. + * Flag subject as a group rather than a peer. + */ + readonly isGroupSubject?: boolean; + + /** + * If this is true, fabric-scoped lists are filtered to the accessing fabric. */ readonly fabricFiltered?: boolean; @@ -165,9 +167,30 @@ export namespace AccessControl { */ offline?: boolean; } + + /** + * Authority status. + */ + export enum Authority { + /** + * Authority is granted. + */ + Granted = 1, + + /** + * Insufficient privileges. + */ + Unauthorized = 2, + + /** + * Feature is restricted. + */ + Restricted = 3, + } } Object.freeze(AccessControl); +Object.freeze(AccessControl.Authority); function enforcerFor(schema: Schema): AccessControl { if (schema.tag === ElementTag.Command) { @@ -184,7 +207,7 @@ function dataEnforcerFor(schema: Schema): AccessControl { return true; } - return session.authorizedFor(limits.readLevel, location); + return session.authorityAt(limits.readLevel, location) === AccessControl.Authority.Granted; }; let mayWrite: AccessControl.Verification = (session, location) => { @@ -192,7 +215,7 @@ function dataEnforcerFor(schema: Schema): AccessControl { return true; } - return session.authorizedFor(limits.writeLevel, location); + return session.authorityAt(limits.writeLevel, location) === AccessControl.Authority.Granted; }; let authorizeRead: AccessControl.Assertion = (session, location) => { @@ -200,7 +223,7 @@ function dataEnforcerFor(schema: Schema): AccessControl { return; } - if (session.authorizedFor(limits.readLevel, location)) { + if (session.authorityAt(limits.readLevel, location) === AccessControl.Authority.Granted) { return; } @@ -212,7 +235,7 @@ function dataEnforcerFor(schema: Schema): AccessControl { return; } - if (session.authorizedFor(limits.writeLevel, location)) { + if (session.authorityAt(limits.writeLevel, location) === AccessControl.Authority.Granted) { return; } @@ -413,7 +436,7 @@ function commandEnforcerFor(schema: Schema): AccessControl { throw new WriteError(location, "Permission denied: No accessing fabric", StatusCode.UnsupportedAccess); } - if (session.authorizedFor(limits.writeLevel, location)) { + if (session.authorityAt(limits.writeLevel, location) === AccessControl.Authority.Granted) { return; } @@ -437,7 +460,7 @@ function commandEnforcerFor(schema: Schema): AccessControl { return false; } - return session.authorizedFor(limits.writeLevel, location); + return session.authorityAt(limits.writeLevel, location) === AccessControl.Authority.Granted; }, }; } diff --git a/packages/protocol/src/action/server/AttributeResponse.ts b/packages/protocol/src/action/server/AttributeResponse.ts new file mode 100644 index 000000000..6f307f558 --- /dev/null +++ b/packages/protocol/src/action/server/AttributeResponse.ts @@ -0,0 +1,413 @@ +/** + * @license + * Copyright 2022-2025 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AttributeTypeProtocol, ClusterProtocol, EndpointProtocol, NodeProtocol } from "#action/protocols.js"; +import { Read } from "#action/request/Read.js"; +import { ReadResult } from "#action/response/ReadResult.js"; +import { AccessControl } from "#action/server/AccessControl.js"; +import { Val } from "#action/Val.js"; +import { InternalError } from "#general"; +import { AccessLevel, AttributeModel, ElementTag } from "#model"; +import { + AttributePath, + BitmapSchema, + ClusterId, + EndpointNumber, + GlobalAttributes, + NodeId, + Status, + StatusResponseError, + WildcardPathFlagsBitmap, +} from "#types"; + +export const GlobalAttrIds = new Set(Object.values(GlobalAttributes).map(attr => attr.id)); +export const WildcardPathFlagsCodec = BitmapSchema(WildcardPathFlagsBitmap); +export const FallbackLimits: AccessControl.Limits = { + fabricScoped: false, + fabricSensitive: false, + readable: true, + readLevel: AccessLevel.View, + timed: false, + writable: true, + writeLevel: AccessLevel.Administer, +}; + +/** + * Implements read of attribute data for Matter "read" and "subscribe" interactions. + * + * TODO - profile; ensure nested functions are properly JITed and/or inlined + */ +export class AttributeResponse { + // Configuration + #session: SessionT; + #node: NodeProtocol; + #versions?: Record> | undefined; + + // Each input AttributePathIB that does not have an error installs a producer. Producers run after validation and + // generate actual attribute data + #dataProducers?: Array<(this: AttributeResponse) => Iterable>; + + // The initial "chunk" may be a list of errors. As producers execute it is a set of records associated with the + // most recently touched endpoint. When the endpoint changes the previous chunk emits + #chunk?: ReadResult.Report[]; + + // The following state updates as data producers execute. This serves both to convey state between functions and as + // a cache between producers that touch the same endpoint and/or cluster + #currentEndpoint?: EndpointProtocol; + #currentCluster?: ClusterProtocol; + #currentState?: Val.ProtocolStruct; + #wildcardPathFlags = 0; + + // The node ID may be expensive to retrieve and is invariant so we cache it here + #cachedNodeId?: NodeId; + + constructor(node: NodeProtocol, session: SessionT, { dataVersionFilters, attributeRequests }: Read.Attributes) { + this.#node = node; + this.#session = session; + + const nodeId = session.fabric === undefined ? NodeId.UNSPECIFIED_NODE_ID : this.#nodeId; + + // Index versions + if (dataVersionFilters?.length) { + this.#versions = {}; + for (const { + path: { nodeId: filterNodeId, endpointId, clusterId }, + dataVersion, + } of dataVersionFilters) { + if (filterNodeId !== undefined && filterNodeId !== nodeId) { + continue; + } + if (typeof endpointId !== "number") { + // Grr github advanced security + continue; + } + (this.#versions[endpointId] ?? (this.#versions[endpointId] = {}))[clusterId] = dataVersion; + } + } + + // Register paths + for (const path of attributeRequests) { + if (path.endpointId === undefined || path.clusterId === undefined || path.attributeId === undefined) { + this.#addWildcard(path); + } else { + this.#addConcrete(path as ReadResult.ConcreteAttributePath); + } + } + } + + /** + * Emits chunks produced by paths added via {@link #addWildcard} and {@link #addConcrete}. + */ + *[Symbol.iterator](): Generator { + if (this.#dataProducers) { + for (const producer of this.#dataProducers) { + yield* producer.apply(this); + } + } + + // We emit chunks lazily when the endpoint changes so there may be one remaining chunk. There may also be a + // chunk with errors even if there are no data producers + if (this.#chunk !== undefined) { + yield this.#chunk; + } + } + + /** + * Validate a wildcard path and update internal state. + */ + #addWildcard(path: AttributePath) { + const { nodeId, endpointId, clusterId, attributeId, wildcardPathFlags } = path; + + if (nodeId !== undefined && nodeId !== this.#nodeId) { + return; + } + + const wpf = wildcardPathFlags ? WildcardPathFlagsCodec.encode(wildcardPathFlags) : 0; + + if (clusterId === undefined && attributeId !== undefined && !GlobalAttrIds.has(attributeId)) { + throw new StatusResponseError( + `Illegal read of wildcard cluster with non-global attribute #${attributeId}`, + Status.InvalidAction, + ); + } + + if (endpointId === undefined) { + this.#addProducer(function* (this: AttributeResponse) { + this.#wildcardPathFlags = wpf; + for (const endpoint of this.#node) { + yield* this.#readEndpointForWildcard(endpoint, path); + } + }); + return; + } + + const endpoint = this.#node[endpointId]; + if (endpoint) { + this.#addProducer(function (this: AttributeResponse) { + this.#wildcardPathFlags = wpf; + return this.#readEndpointForWildcard(endpoint, path); + }); + } + } + + /** + * Validate a concrete path and update internal state. + */ + #addConcrete(path: ReadResult.ConcreteAttributePath) { + const { nodeId, endpointId, clusterId, attributeId } = path; + if (nodeId !== undefined && this.#nodeId !== nodeId) { + this.#addStatus(path, Status.UnsupportedNode); + } + + // Resolve path elements + const endpoint = this.#node[endpointId]; + const cluster = endpoint?.[clusterId]; + const attribute = cluster?.type.attributes[attributeId]; + let limits; + if (attribute === undefined) { + // We still need to authorize the user for access even though this path doesn't resolve. Spec is not + // explicit on what privilege level we should require as normally that information comes from the resolved + // attribute. So attempt to resolve via the active model + const modelAttr = this.#node.matter + .member(path.clusterId, [ElementTag.Cluster]) + ?.member(path.attributeId, [ElementTag.Attribute]); + + if (modelAttr) { + // OK cluster doesn't exist at that location, but we do understand semantically, so use limits from the + // model + limits = AccessControl(modelAttr as AttributeModel).limits; + } else { + // We've got no idea. This effectively falls back to "view" privilege + limits = FallbackLimits; + } + } else { + limits = attribute.limits; + } + + // Validate access. Order here prescribed by 1.4 core spec 8.4.3.2 + switch (this.#session.authorityAt(limits.readLevel)) { + case AccessControl.Authority.Granted: + break; + + case AccessControl.Authority.Unauthorized: + this.#addStatus(path, Status.UnsupportedAccess); + return; + + case AccessControl.Authority.Restricted: + this.#addStatus(path, Status.AccessRestricted); + return; + + default: + throw new InternalError( + `Unsupported authorization state ${this.#session.authorityAt(limits.readLevel)}`, + ); + } + if (endpoint === undefined) { + this.#addStatus(path, Status.UnsupportedEndpoint); + return; + } + if (cluster === undefined) { + this.#addStatus(path, Status.UnsupportedCluster); + return; + } + if (attribute === undefined) { + this.#addStatus(path, Status.UnsupportedAttribute); + return; + } + if (!limits.readable) { + this.#addStatus(path, Status.UnsupportedRead); + return; + } + + // Skip if version is unchanged + const skipVersion = this.#versions?.[path.endpointId]?.[path.clusterId]; + if (skipVersion !== undefined && skipVersion === cluster.version) { + return; + } + + // This path contributes an attribute value + this.#addProducer(function* () { + // Update internal state for target endpoint + if (this.#currentEndpoint !== endpoint) { + if (this.#chunk) { + yield this.#chunk; + this.#chunk = undefined; + } + this.#currentEndpoint = endpoint; + this.#currentCluster = cluster; + this.#currentState = cluster.open(this.#session); + } else if (this.#currentCluster !== cluster) { + this.#currentCluster = cluster; + this.#currentState = cluster.open(this.#session); + } else if (this.#currentState === undefined) { + this.#currentState = cluster.open(this.#session); + } + + // Perform actual read + this.#addValue(path, this.#currentState); + }); + } + + /** + * Starts new chunk or adds to current chunk all values from {@link endpoint} selected by {@link path}. + * + * Emits previous chunk if it exists and was not for this endpoint. This means that our chunk size is one endpoint + * worth of data, except for the initial error chunk if there are path errors. + * + * {@link this.#wildcardPathFlags} to numeric bitmap must be set prior to invocation. + * + * TODO - skip endpoints for which subject is unauthorized + */ + *#readEndpointForWildcard(endpoint: EndpointProtocol, path: AttributePath) { + if (endpoint.wildcardPathFlags & this.#wildcardPathFlags) { + return; + } + + if (this.#currentEndpoint !== endpoint) { + if (this.#chunk) { + yield this.#chunk; + this.#chunk = undefined; + } + this.#currentEndpoint = endpoint; + this.#currentCluster = undefined; + } + + const { clusterId } = path; + if (clusterId === undefined) { + for (const cluster of endpoint) { + this.#readClusterForWildcard(cluster, path); + } + } else { + const cluster = endpoint[clusterId]; + if (cluster !== undefined) { + this.#readClusterForWildcard(cluster, path); + } + } + } + + /** + * Read values from a specific {@link cluster} for a wildcard path. + * + * Depends on state initialized by {@link #readEndpointForWildcard}. + * + * TODO - skip clusters for which subject is unauthorized + */ + #readClusterForWildcard(cluster: ClusterProtocol, path: AttributePath) { + if (cluster.type.wildcardPathFlags & this.#wildcardPathFlags) { + return; + } + + if (this.#currentCluster !== cluster) { + this.#currentCluster = cluster; + this.#currentState = undefined; + } + + const skipVersion = this.#versions?.[this.#currentEndpoint!.id]?.[cluster.type.id]; + if (skipVersion !== undefined && skipVersion === cluster.version) { + return; + } + + const { attributeId } = path; + if (attributeId === undefined) { + for (const attribute of cluster.type.attributes) { + this.#readAttributeForWildcard(attribute, path); + } + } else { + const attribute = cluster.type.attributes[attributeId]; + if (attribute !== undefined) { + this.#readAttributeForWildcard(attribute, path); + } + } + } + + /** + * Read values from a specific {@link attribute} for a wildcard path. + * + * Depends on state initialized by {@link #readClusterForWildcard}. + */ + #readAttributeForWildcard(attribute: AttributeTypeProtocol, path: AttributePath) { + if (attribute.wildcardPathFlags & this.#wildcardPathFlags) { + return; + } + + if ( + !attribute.limits.readable || + this.#session.authorityAt(attribute.limits.readLevel, this.#currentCluster!.location) !== + AccessControl.Authority.Granted + ) { + return; + } + + if (this.#currentState === undefined) { + this.#currentState = this.#currentCluster!.open(this.#session); + } + this.#addValue( + { + ...path, + endpointId: this.#currentEndpoint?.id as EndpointNumber, + clusterId: this.#currentCluster?.type.id as ClusterId, + attributeId: attribute.id, + }, + this.#currentState[attribute.id], + ); + } + + /** + * Add a function that produces data. These functions are run after validation of input paths. + */ + #addProducer(producer: (this: AttributeResponse) => Iterable) { + if (this.#dataProducers) { + this.#dataProducers.push(producer); + } else { + this.#dataProducers = [producer]; + } + } + + /** + * Add a status value. + */ + #addStatus(path: ReadResult.ConcreteAttributePath, status: Status) { + const report: ReadResult.GlobalAttributeStatus = { + kind: "attr-status", + path, + status, + }; + + if (this.#chunk) { + this.#chunk.push(report); + } else { + this.#chunk = [report]; + } + } + + /** + * Add an attribute value. + */ + #addValue(path: ReadResult.ConcreteAttributePath, value: unknown) { + const report: ReadResult.AttributeValue = { + kind: "attr-value", + path, + value, + }; + + if (this.#chunk) { + this.#chunk.push(report); + } else { + this.#chunk = [report]; + } + } + + /** + * The node ID used to filter paths with node ID specified. Unsure if this is ever actually used. + */ + get #nodeId() { + if (this.#cachedNodeId === undefined) { + this.#cachedNodeId = + (this.#session.fabric && this.#node.nodeIdFor(this.#session.fabric)) ?? NodeId.UNSPECIFIED_NODE_ID; + } + return this.#cachedNodeId; + } +} diff --git a/packages/protocol/src/action/server/ServerInteraction.ts b/packages/protocol/src/action/server/ServerInteraction.ts new file mode 100644 index 000000000..8423b9138 --- /dev/null +++ b/packages/protocol/src/action/server/ServerInteraction.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2022-2025 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Interactable } from "#action/Interactable.js"; +import { NodeProtocol } from "#action/protocols.js"; +import { Invoke } from "#action/request/Invoke.js"; +import { Read } from "#action/request/Read.js"; +import { Subscribe } from "#action/request/Subscribe.js"; +import { Write } from "#action/request/Write.js"; +import { InvokeResult } from "#action/response/InvokeResult.js"; +import { ReadResult } from "#action/response/ReadResult.js"; +import { SubscribeResult } from "#action/response/SubscribeResult.js"; +import { WriteResult } from "#action/response/WriteResult.js"; +import { AccessControl } from "#action/server/AccessControl.js"; +import { NotImplementedError } from "#general"; +import { AttributeResponse } from "./AttributeResponse.js"; + +/** + * Implementation of server interaction. + * + * This implementation currently focuses on read of attribute data with other actions to be implemented later. Until + * completion there will be redundancy with other components including: + * + * - InteractionServer (significant overlap with this class) + * + * - InteractionEndpointStructure ({@link NodeProtocol} is largely duplicative) + */ +export class ServerInteraction + implements Interactable +{ + #node: NodeProtocol; + + constructor(node: NodeProtocol) { + this.#node = node; + } + + async *read(request: Read, session: SessionT): ReadResult { + // TODO - validate request + + if (Read.isAttribute(request)) { + yield* new AttributeResponse(this.#node, session, request); + } + + // TODO - event reads + } + + subscribe(_request: Subscribe, _session?: SessionT): SubscribeResult { + // TODO + throw new NotImplementedError(); + } + + write(_request: T, _session?: SessionT): WriteResult { + // TODO + throw new NotImplementedError(); + } + + invoke(_request: T, _session?: SessionT): InvokeResult { + // TODO + throw new NotImplementedError(); + } +} diff --git a/packages/protocol/src/action/server/index.ts b/packages/protocol/src/action/server/index.ts new file mode 100644 index 000000000..4ef88ac96 --- /dev/null +++ b/packages/protocol/src/action/server/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2022-2025 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from "./AccessControl.js"; +export * from "./AttributeResponse.js"; +export * from "./ServerInteraction.js"; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index deb955572..6376ee5ea 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from "./action/index.js"; export * from "./ble/index.js"; export * from "./certificate/index.js"; export * from "./cluster/index.js"; diff --git a/packages/protocol/src/interaction/AccessControlManager.ts b/packages/protocol/src/interaction/AccessControlManager.ts index 13fcbe1e2..320c230d1 100644 --- a/packages/protocol/src/interaction/AccessControlManager.ts +++ b/packages/protocol/src/interaction/AccessControlManager.ts @@ -30,7 +30,7 @@ export type AclExtensionEntry = AccessControl.AccessControlExtension; export type AclExtensionList = AclExtensionEntry[]; export type AclEndpointContext = { - number: EndpointNumber; + id: EndpointNumber; deviceTypes: DeviceTypeId[]; }; @@ -163,7 +163,7 @@ export class AccessControlManager { } logger.notice( - `Failed access control check for ${endpoint.number}/0x${toHex(clusterId)} and fabricIndex ${session.associatedFabric.fabricIndex}, acl=`, + `Failed access control check for ${endpoint.id}/0x${toHex(clusterId)} and fabricIndex ${session.associatedFabric.fabricIndex}, acl=`, this.#getAccessControlEntriesForFabric(session.associatedFabric), "with ISD=", this.#getIsdFromMessage(session), @@ -180,7 +180,7 @@ export class AccessControlManager { * Determines the granted privileges for the given session, endpoint, and cluster ID and returns them. */ getGrantedPrivileges(session: SecureSession, endpoint: AclEndpointContext, clusterId: ClusterId): AccessLevel[] { - const endpointId = endpoint.number; + const endpointId = endpoint.id; const fabric = session.fabric; const subjectDesc = this.#getIsdFromMessage(session); const acl = fabric ? this.#getAccessControlEntriesForFabric(fabric) : [ImplicitDefaultPaseAclEntry]; diff --git a/packages/protocol/src/interaction/InteractionServer.ts b/packages/protocol/src/interaction/InteractionServer.ts index 8ebd7785c..9fd9ce9c3 100644 --- a/packages/protocol/src/interaction/InteractionServer.ts +++ b/packages/protocol/src/interaction/InteractionServer.ts @@ -589,7 +589,6 @@ export class InteractionServer implements ProtocolHandler, InteractionRecipient * This can currently only be used for subscriptions because errors are ignored! */ protected readEndpointAttributesForSubscription( - _endpointId: EndpointNumber, attributes: { path: AttributePath; attribute: AnyAttributeServer }[], exchange: MessageExchange, isFabricFiltered: boolean, @@ -1079,8 +1078,8 @@ export class InteractionServer implements ProtocolHandler, InteractionRecipient readAttribute: (path, attribute, offline) => this.readAttribute(path, attribute, exchange, isFabricFiltered, message, offline), - readEndpointAttributesForSubscription: (endpointId, attributes) => - this.readEndpointAttributesForSubscription(endpointId, attributes, exchange, isFabricFiltered, message), + readEndpointAttributesForSubscription: attributes => + this.readEndpointAttributesForSubscription(attributes, exchange, isFabricFiltered, message), readEvent: (path, event, eventFilters) => this.readEvent(path, eventFilters, event, exchange, isFabricFiltered, message), diff --git a/packages/protocol/src/interaction/ServerSubscription.ts b/packages/protocol/src/interaction/ServerSubscription.ts index 921937400..b3ba63e72 100644 --- a/packages/protocol/src/interaction/ServerSubscription.ts +++ b/packages/protocol/src/interaction/ServerSubscription.ts @@ -136,7 +136,6 @@ export interface ServerSubscriptionContext { offline?: boolean, ): { version: number; value: unknown }; readEndpointAttributesForSubscription( - endpointId: EndpointNumber, attributes: { path: AttributePath; attribute: AnyAttributeServer; offline?: boolean }[], ): { path: AttributePath; attribute: AnyAttributeServer; version: number; value: unknown }[]; readEvent( @@ -736,7 +735,6 @@ export class ServerSubscription extends Subscription { const endpointAttributes = attributesPerCluster.get(endpointId)!; attributesPerCluster.delete(endpointId); for (const { path, attribute, value, version } of this.#context.readEndpointAttributesForSubscription( - endpointId, endpointAttributes, )) { if (value === undefined) continue; diff --git a/packages/testing/src/cli.ts b/packages/testing/src/cli.ts index 2f9c8f990..7aed11da0 100644 --- a/packages/testing/src/cli.ts +++ b/packages/testing/src/cli.ts @@ -123,7 +123,7 @@ export async function main(argv = process.argv) { const progress = pkg.start("Inspecting"); const runner = new TestRunner(pkg, progress, args); inspect(await defaultDescriptor(runner)); - progress.shutdown(); + progress.close(); return; } @@ -156,7 +156,7 @@ export async function main(argv = process.argv) { await runner.runWeb(manual); } - progress.shutdown(); + progress.close(); if (args.forceExit) { process.exit(0); diff --git a/packages/tools/package.json b/packages/tools/package.json index 89da2520d..5952e14cd 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -58,6 +58,7 @@ }, "homepage": "https://github.com/project-chip/matter.js#readme", "dependencies": { + "@microsoft/tsdoc": "^0.15.1", "ansi-colors": "^4.1.3", "commander": "^13.1.0", "esbuild": "^0.25.0", @@ -65,15 +66,16 @@ "typescript": "~5.7.3", "type-fest": "^4.34.1", "typedoc": "^0.27.7", - "typedoc-github-theme": "^0.2.1", - "@microsoft/tsdoc": "^0.15.1" + "typedoc-github-theme": "^0.2.1" }, "optionalDependencies": { "@esbuild/linux-x64": "^0.24.2" }, "devDependencies": { + "@types/madge": "^5.0.3", "@types/minimatch": "^5.1.2", - "@types/node": "^22.10.10" + "@types/node": "^22.10.10", + "detective-typescript": "^14.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/tools/src/building/builder.ts b/packages/tools/src/building/builder.ts index 44a71d085..6c130512a 100644 --- a/packages/tools/src/building/builder.ts +++ b/packages/tools/src/building/builder.ts @@ -67,12 +67,12 @@ export class Builder { try { await this.#doBuild(project, progress); } catch (e: any) { - progress.shutdown(); + progress.close(); process.stderr.write(`${e.stack ?? e.message}\n\n`); process.exit(1); } - progress.shutdown(); + progress.close(); } async #doBuild(project: Project, progress: Progress) { diff --git a/packages/tools/src/building/cli.ts b/packages/tools/src/building/cli.ts index f8b95c378..5d080d8ca 100644 --- a/packages/tools/src/building/cli.ts +++ b/packages/tools/src/building/cli.ts @@ -6,6 +6,7 @@ import { commander } from "../util/commander.js"; import { Package } from "../util/package.js"; +import { reportCycles } from "./cycles.js"; import { buildDocs, mergeDocs } from "./docs.js"; import { Graph } from "./graph.js"; import { ProjectBuilder, Target } from "./project-builder.js"; @@ -19,6 +20,7 @@ enum Mode { DisplayGraph, BuildDocs, SyncTsconfigs, + Circular, } interface Args { @@ -91,6 +93,13 @@ export async function main(argv = process.argv) { mode = Mode.BuildDocs; }); + program + .command("cycles") + .description("find circular dependencies") + .action(() => { + mode = Mode.Circular; + }); + program.action(() => {}); const args = program.parse(argv).opts(); @@ -144,7 +153,7 @@ export async function main(argv = process.argv) { break; case Mode.BuildDocs: { - const progress = pkg.start("Documenting"); + using progress = pkg.start("Documenting"); if (pkg.isWorkspace) { const graph = await Graph.load(); for (const node of graph.nodes) { @@ -158,5 +167,20 @@ export async function main(argv = process.argv) { } break; } + + case Mode.Circular: { + using progress = pkg.start("Analyzing dependencies"); + if (pkg.isWorkspace) { + const graph = await Graph.load(); + for (const node of graph.nodes) { + if (node.pkg.isLibrary) { + await reportCycles(node.pkg, progress); + } + } + } else { + await reportCycles(pkg, progress); + } + break; + } } } diff --git a/packages/tools/src/building/cycles.ts b/packages/tools/src/building/cycles.ts new file mode 100644 index 000000000..7b33a7862 --- /dev/null +++ b/packages/tools/src/building/cycles.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2022-2025 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFile } from "fs/promises"; +import { Package } from "../util/package.js"; +import { Progress } from "../util/progress.js"; + +// @ts-expect-error we don't have types for detective-typescript +import detective from "detective-typescript"; +import { dirname, relative, resolve } from "path"; +import { std } from "../ansi-text/std.js"; +import { ansi } from "../ansi-text/text-builder.js"; + +export async function reportCycles(pkg: Package, progress: Progress) { + const cycles = await progress.run(pkg.name, () => identifyCycles(pkg, progress)); + if (cycles) { + printCycles(pkg, cycles); + } +} + +async function identifyCycles(pkg: Package, progress: Progress) { + const deps = {} as Record; + for (const filename of await pkg.glob("{src,test}/**/*.ts")) { + const contents = await readFile(filename, "utf-8"); + const fileDeps = detective(contents, { + skipTypeImports: true, + skipAsyncImports: true, + }); + deps[filename] = resolveDeps(pkg, filename, fileDeps); + } + + const cycles = [] as string[][]; + for (const filename in deps) { + visit(filename, []); + } + + function visit(filename: string, breadcrumb: string[]) { + progress.refresh(); + const fileDeps = deps[filename] ?? deps[filename.replace(/\.js$/, ".ts")]; + if (fileDeps === undefined) { + return; + } + + const previousIndex = breadcrumb.indexOf(filename); + if (previousIndex !== -1) { + const newCycle = breadcrumb.slice(previousIndex); + for (const cycle of cycles) { + const filenameOffset = cycle.indexOf(filename); + if (cycle.length !== newCycle.length) { + continue; + } + if (filenameOffset === -1) { + continue; + } + + let i = 0; + for (i = 0; i < newCycle.length; i++) { + if (newCycle[i] !== cycle[(filenameOffset + i) % newCycle.length]) { + break; + } + } + + if (i === newCycle.length) { + return; + } + } + cycles.push(newCycle); + return; + } + + breadcrumb = [...breadcrumb, filename]; + for (const dep of fileDeps) { + visit(dep, breadcrumb); + } + } + + return cycles.length ? cycles : undefined; +} + +function printCycles(pkg: Package, cycles: string[][]) { + std.out(ansi.red("Cycles detected:"), "\n"); + const src = pkg.resolve("src"); + for (const cycle of cycles) { + std.out(" ", cycle.map(name => ansi.bright.blue(relative(src, name))).join(" → "), " ↩\n"); + } +} + +function resolveDeps(pkg: Package, sourceFilename: string, deps: string[]) { + const dir = dirname(sourceFilename); + const aliases = pkg.importAliases; + const resolved = Array(); + + for (let dep of deps) { + let base = dir; + if (dep.startsWith("#")) { + dep = aliases.rewrite(dep); + base = pkg.path; + } + if (dep.startsWith("./")) { + resolved.push(resolve(base, dep)); + } + } + + return resolved; +} diff --git a/packages/tools/src/building/graph.ts b/packages/tools/src/building/graph.ts index 74a5aab10..8ea0cd63d 100644 --- a/packages/tools/src/building/graph.ts +++ b/packages/tools/src/building/graph.ts @@ -134,7 +134,7 @@ export class Graph { progress.info("built", formatTime(node.info.timestamp ?? 0)); progress.info("dirty", node.isDirty ? colors.dim.red("yes") : colors.dim.green("no")); progress.info("dependencies", node.dependencies.map(formatDep).join(", ")); - progress.shutdown(); + progress.close(); } } diff --git a/packages/tools/src/building/project-builder.ts b/packages/tools/src/building/project-builder.ts index 9cab5aecc..9d0e0caed 100644 --- a/packages/tools/src/building/project-builder.ts +++ b/packages/tools/src/building/project-builder.ts @@ -67,12 +67,12 @@ export class ProjectBuilder { try { await this.#doBuild(project, progress); } catch (e: any) { - progress.shutdown(); + progress.close(); process.stderr.write(`${e.stack ?? e.message}\n\n`); process.exit(1); } - progress.shutdown(); + progress.close(); } async #doBuild(project: Project, progress: Progress) { diff --git a/packages/tools/src/util/bootstrap.mjs b/packages/tools/src/util/bootstrap.mjs index 8e781c0ce..2dcab09ee 100644 --- a/packages/tools/src/util/bootstrap.mjs +++ b/packages/tools/src/util/bootstrap.mjs @@ -73,7 +73,14 @@ async function bootstrap() { await new Promise(resolve => { const proc = spawn( esbuild, - ["src/**/*.ts", "--outdir=dist/esm", "--format=esm", "--log-level=warning", "--sourcemap=inline"], + [ + "src/**/*.ts", + "--outdir=dist/esm", + "--format=esm", + "--log-level=warning", + "--sourcemap=inline", + "--target=es2022", + ], options, ); diff --git a/packages/tools/src/util/import-aliases.ts b/packages/tools/src/util/import-aliases.ts new file mode 100644 index 000000000..133589ca6 --- /dev/null +++ b/packages/tools/src/util/import-aliases.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2022-2025 Project CHIP Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Parses package.json "imports" fields and rewrites local imports according to the import definitions. + */ +export class ImportAliases { + #parent?: ImportAliases; + #direct: Record = {}; + #wildcard = Array(); + + constructor(localAliases?: Record, parent?: ImportAliases) { + for (const path in localAliases) { + const resolvesTo = localAliases[path]; + + let wildcardAt = path.indexOf("*"); + if (wildcardAt === -1) { + this.#direct[path] = resolvesTo; + continue; + } + + const inputPrefix = path.substring(0, wildcardAt); + const inputSuffix = path.substring(wildcardAt + 1); + + wildcardAt = resolvesTo.indexOf("*"); + if (wildcardAt === -1) { + this.#wildcard.push({ + inputPrefix, + inputSuffix, + includeGlob: false, + outputPrefix: resolvesTo, + outputSuffix: "", + }); + } else { + this.#wildcard.push({ + inputPrefix, + inputSuffix, + includeGlob: true, + outputPrefix: resolvesTo.substring(0, wildcardAt), + outputSuffix: resolvesTo.substring(wildcardAt + 1), + }); + } + } + this.#parent = parent; + } + + rewrite(path: string): string { + if (path.startsWith("#")) { + const direct = this.#direct[path]; + if (direct) { + return direct; + } + for (const alias of this.#wildcard) { + if (path.startsWith(alias.inputPrefix) && path.endsWith(alias.inputSuffix)) { + if (alias.includeGlob) { + return ( + alias.outputPrefix + + path.substring(alias.inputPrefix.length, path.length - alias.inputSuffix.length) + + alias.outputSuffix + ); + } + return alias.outputPrefix; + } + } + } + return this.#parent?.rewrite(path) ?? path; + } +} + +interface WildcardAlias { + inputPrefix: string; + inputSuffix: string; + includeGlob: boolean; + outputPrefix: string; + outputSuffix: string; +} diff --git a/packages/tools/src/util/package.ts b/packages/tools/src/util/package.ts index 547347bfc..bbf099ecc 100644 --- a/packages/tools/src/util/package.ts +++ b/packages/tools/src/util/package.ts @@ -10,6 +10,7 @@ import { dirname, join, relative, resolve } from "path"; import { ignoreError, ignoreErrorSync } from "./errors.js"; import { isFile, maybeReadJsonSync, maybeStatSync } from "./file.js"; import { globSync } from "./glob.js"; +import { ImportAliases } from "./import-aliases.js"; import { Progress } from "./progress.js"; import { toolsPath } from "./tools-path.cjs"; @@ -53,7 +54,7 @@ export class Package { hasTests: boolean; hasConfig: boolean; isLibrary: boolean; - #aliases?: Record; + #importAliases?: ImportAliases; constructor({ path = ".", @@ -314,7 +315,11 @@ export class Package { if (existsSync(join(path, "package.json"))) { result = new Package({ path }); } else { - result = find(dirname(path)); + const parentDir = dirname(path); + if (parentDir === path) { + return null; + } + result = find(parentDir); } packageForPath[path] = result; } @@ -334,17 +339,17 @@ export class Package { throw new Error(`Cannot find package.json for "${path}"`); } - get aliases(): Record { - if (this.#aliases !== undefined) { - return this.#aliases; + get importAliases(): ImportAliases { + if (this.#importAliases !== undefined) { + return this.#importAliases; } - this.#aliases = { - ...Package.maybeForPath(dirname(this.path))?.aliases, - ...this.json.imports, - }; + this.#importAliases = new ImportAliases( + this.json.imports, + Package.maybeForPath(dirname(this.path))?.importAliases, + ); - return this.#aliases; + return this.#importAliases; } get modules() { diff --git a/packages/tools/src/util/progress.ts b/packages/tools/src/util/progress.ts index 77ea1564c..60b197ccf 100644 --- a/packages/tools/src/util/progress.ts +++ b/packages/tools/src/util/progress.ts @@ -155,7 +155,7 @@ export class Progress { std.out.write(` ${ansi.yellow("Warning:")} ${text}\n`); } - shutdown() { + close() { if (this.#refreshInterval) { clearInterval(this.#refreshInterval); this.#refreshInterval = undefined; @@ -163,6 +163,10 @@ export class Progress { writeStatus(""); } + [Symbol.dispose]() { + this.close(); + } + refresh() { if (this.#updateSpinner()) { this.#writeOngoing(); diff --git a/packages/tools/src/versioning/cli.ts b/packages/tools/src/versioning/cli.ts index 2b2e28246..1e51fc34e 100644 --- a/packages/tools/src/versioning/cli.ts +++ b/packages/tools/src/versioning/cli.ts @@ -48,5 +48,5 @@ export async function main(argv = process.argv) { await progress.run(`Tagging version ${progress.emphasize(versioner.version)}`, () => versioner.tag()); } - progress.shutdown(); + progress.close(); } diff --git a/packages/types/src/globals/Status.ts b/packages/types/src/globals/Status.ts index 9f120526a..da3fc7f8c 100644 --- a/packages/types/src/globals/Status.ts +++ b/packages/types/src/globals/Status.ts @@ -231,5 +231,10 @@ export enum Status { * * @see {@link MatterSpecification.v13.Core} § 8.10.1 */ - NoCommandResponse = 204 + NoCommandResponse = 204, + + /** + * Matter 1.4, temporarily patched in manually. + */ + AccessRestricted = 0x9d, } diff --git a/packages/types/src/protocol/messages/TlvDataReport.ts b/packages/types/src/protocol/messages/TlvDataReport.ts index 25d50fd2e..3140939f2 100644 --- a/packages/types/src/protocol/messages/TlvDataReport.ts +++ b/packages/types/src/protocol/messages/TlvDataReport.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvArray } from "../../tlv/TlvArray.js"; import { TlvBoolean } from "../../tlv/TlvBoolean.js"; import { TlvUInt32, TlvUInt8 } from "../../tlv/TlvNumber.js"; @@ -28,3 +29,5 @@ export const TlvDataReport = TlvObject({ suppressResponse: TlvOptionalField(4, TlvBoolean), interactionModelRevision: TlvField(0xff, TlvUInt8), }); + +export type DataReport = TypeFromSchema; diff --git a/packages/types/src/protocol/messages/TlvInvokeRequest.ts b/packages/types/src/protocol/messages/TlvInvokeRequest.ts index 1585b6bb6..d37298683 100644 --- a/packages/types/src/protocol/messages/TlvInvokeRequest.ts +++ b/packages/types/src/protocol/messages/TlvInvokeRequest.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvArray } from "../../tlv/TlvArray.js"; import { TlvBoolean } from "../../tlv/TlvBoolean.js"; import { TlvUInt8 } from "../../tlv/TlvNumber.js"; @@ -23,3 +24,5 @@ export const TlvInvokeRequest = TlvObject({ invokeRequests: TlvField(2, TlvArray(TlvCommandData)), interactionModelRevision: TlvField(0xff, TlvUInt8), }); + +export type InvokeRequest = TypeFromSchema; diff --git a/packages/types/src/protocol/messages/TlvInvokeResponse.ts b/packages/types/src/protocol/messages/TlvInvokeResponse.ts index 494fa5273..b132e89cb 100644 --- a/packages/types/src/protocol/messages/TlvInvokeResponse.ts +++ b/packages/types/src/protocol/messages/TlvInvokeResponse.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvArray } from "../../tlv/TlvArray.js"; import { TlvBoolean } from "../../tlv/TlvBoolean.js"; import { TlvUInt8 } from "../../tlv/TlvNumber.js"; @@ -21,3 +22,5 @@ export const TlvInvokeResponse = TlvObject({ moreChunkedMessages: TlvOptionalField(2, TlvBoolean), interactionModelRevision: TlvField(0xff, TlvUInt8), }); + +export type InvokeResponse = TypeFromSchema; diff --git a/packages/types/src/protocol/messages/TlvReadRequest.ts b/packages/types/src/protocol/messages/TlvReadRequest.ts index 426e6553d..8eaf8cbae 100644 --- a/packages/types/src/protocol/messages/TlvReadRequest.ts +++ b/packages/types/src/protocol/messages/TlvReadRequest.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvArray } from "../../tlv/TlvArray.js"; import { TlvBoolean } from "../../tlv/TlvBoolean.js"; import { TlvUInt8 } from "../../tlv/TlvNumber.js"; @@ -32,3 +33,5 @@ export const TlvReadRequest = TlvObject({ dataVersionFilters: TlvOptionalField(4, TlvArray(TlvDataVersionFilter)), interactionModelRevision: TlvField(0xff, TlvUInt8), }); + +export type ReadRequest = TypeFromSchema; diff --git a/packages/types/src/protocol/messages/TlvSubscribeRequest.ts b/packages/types/src/protocol/messages/TlvSubscribeRequest.ts index 8eca59483..f427cdc93 100644 --- a/packages/types/src/protocol/messages/TlvSubscribeRequest.ts +++ b/packages/types/src/protocol/messages/TlvSubscribeRequest.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvArray } from "../../tlv/TlvArray.js"; import { TlvBoolean } from "../../tlv/TlvBoolean.js"; import { TlvUInt16, TlvUInt8 } from "../../tlv/TlvNumber.js"; @@ -41,3 +42,5 @@ export const TlvSubscribeRequest = TlvObject({ dataVersionFilters: TlvOptionalField(8, TlvArray(TlvDataVersionFilter)), interactionModelRevision: TlvField(0xff, TlvUInt8), }); + +export type SubscribeRequest = TypeFromSchema; diff --git a/packages/types/src/protocol/messages/TlvSubscribeResponse.ts b/packages/types/src/protocol/messages/TlvSubscribeResponse.ts index 75475e120..7c16314bf 100644 --- a/packages/types/src/protocol/messages/TlvSubscribeResponse.ts +++ b/packages/types/src/protocol/messages/TlvSubscribeResponse.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvUInt16, TlvUInt32, TlvUInt8 } from "../../tlv/TlvNumber.js"; import { TlvField, TlvObject } from "../../tlv/TlvObject.js"; @@ -17,3 +18,5 @@ export const TlvSubscribeResponse = TlvObject({ maxInterval: TlvField(2, TlvUInt16), interactionModelRevision: TlvField(0xff, TlvUInt8), }); + +export type SubscribeResponse = TypeFromSchema; diff --git a/packages/types/src/protocol/messages/TlvTimedRequest.ts b/packages/types/src/protocol/messages/TlvTimedRequest.ts index 5487aa4f6..80285e66f 100644 --- a/packages/types/src/protocol/messages/TlvTimedRequest.ts +++ b/packages/types/src/protocol/messages/TlvTimedRequest.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvUInt16, TlvUInt8 } from "../../tlv/TlvNumber.js"; import { TlvField, TlvObject } from "../../tlv/TlvObject.js"; @@ -14,3 +15,5 @@ export const TlvTimedRequest = TlvObject({ timeout: TlvField(0, TlvUInt16), interactionModelRevision: TlvField(0xff, TlvUInt8), }); + +export type TimedRequest = TypeFromSchema; diff --git a/packages/types/src/protocol/messages/TlvWriteRequest.ts b/packages/types/src/protocol/messages/TlvWriteRequest.ts index 8ab8e7f60..330fabea6 100644 --- a/packages/types/src/protocol/messages/TlvWriteRequest.ts +++ b/packages/types/src/protocol/messages/TlvWriteRequest.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvArray } from "../../tlv/TlvArray.js"; import { TlvBoolean } from "../../tlv/TlvBoolean.js"; import { TlvUInt8 } from "../../tlv/TlvNumber.js"; @@ -24,3 +25,5 @@ export const TlvWriteRequest = TlvObject({ moreChunkedMessages: TlvOptionalField(3, TlvBoolean), interactionModelRevision: TlvField(0xff, TlvUInt8), }); + +export type WriteRequest = TypeFromSchema; diff --git a/packages/types/src/protocol/messages/TlvWriteResponse.ts b/packages/types/src/protocol/messages/TlvWriteResponse.ts index ca7e43507..a094ee6a5 100644 --- a/packages/types/src/protocol/messages/TlvWriteResponse.ts +++ b/packages/types/src/protocol/messages/TlvWriteResponse.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvArray } from "../../tlv/TlvArray.js"; import { TlvUInt8 } from "../../tlv/TlvNumber.js"; import { TlvField, TlvObject } from "../../tlv/TlvObject.js"; @@ -16,3 +17,5 @@ export const TlvWriteResponse = TlvObject({ writeResponses: TlvField(0, TlvArray(TlvAttributeStatus)), interactionModelRevision: TlvField(0xff, TlvUInt8), }); + +export type WriteResponse = TypeFromSchema; diff --git a/packages/types/src/protocol/types/TlvAttributePath.ts b/packages/types/src/protocol/types/TlvAttributePath.ts index 0d768344f..3f7c61dac 100644 --- a/packages/types/src/protocol/types/TlvAttributePath.ts +++ b/packages/types/src/protocol/types/TlvAttributePath.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvAttributeId } from "../../datatype/AttributeId.js"; import { TlvClusterId } from "../../datatype/ClusterId.js"; import { TlvEndpointNumber } from "../../datatype/EndpointNumber.js"; @@ -26,3 +27,5 @@ export const TlvAttributePath = TlvTaggedList({ listIndex: TlvOptionalField(5, TlvNullable(TlvUInt16)), wildcardPathFlags: TlvOptionalField(6, TlvBitmap(TlvUInt32, WildcardPathFlagsBitmap)), }); + +export type AttributePath = TypeFromSchema; diff --git a/packages/types/src/protocol/types/TlvAttributeReport.ts b/packages/types/src/protocol/types/TlvAttributeReport.ts index a374f7f31..d8f9ac661 100644 --- a/packages/types/src/protocol/types/TlvAttributeReport.ts +++ b/packages/types/src/protocol/types/TlvAttributeReport.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvObject, TlvOptionalField } from "../../tlv/TlvObject.js"; import { TlvAttributeReportData } from "./TlvAttributeReportData.js"; import { TlvAttributeStatus } from "./TlvAttributeStatus.js"; @@ -15,3 +16,5 @@ export const TlvAttributeReport = TlvObject({ attributeStatus: TlvOptionalField(0, TlvAttributeStatus), attributeData: TlvOptionalField(1, TlvAttributeReportData), }); + +export type AttributeReport = TypeFromSchema; diff --git a/packages/types/src/protocol/types/TlvAttributeReportData.ts b/packages/types/src/protocol/types/TlvAttributeReportData.ts index f45572a60..b8804d53c 100644 --- a/packages/types/src/protocol/types/TlvAttributeReportData.ts +++ b/packages/types/src/protocol/types/TlvAttributeReportData.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvAny } from "../../tlv/TlvAny.js"; import { TlvUInt32 } from "../../tlv/TlvNumber.js"; import { TlvField, TlvObject, TlvOptionalField } from "../../tlv/TlvObject.js"; @@ -15,3 +16,5 @@ export const TlvAttributeReportData = TlvObject({ path: TlvField(1, TlvAttributePath), data: TlvField(2, TlvAny), }); + +export type AttributeReportData = TypeFromSchema; diff --git a/packages/types/src/protocol/types/TlvAttributeStatus.ts b/packages/types/src/protocol/types/TlvAttributeStatus.ts index ccf55660d..82e0d6e26 100644 --- a/packages/types/src/protocol/types/TlvAttributeStatus.ts +++ b/packages/types/src/protocol/types/TlvAttributeStatus.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvField, TlvObject } from "../../tlv/TlvObject.js"; import { TlvAttributePath } from "./TlvAttributePath.js"; import { TlvStatus } from "./TlvStatus.js"; @@ -15,3 +16,5 @@ export const TlvAttributeStatus = TlvObject({ path: TlvField(0, TlvAttributePath), status: TlvField(1, TlvStatus), }); + +export type AttributeStatus = TypeFromSchema; diff --git a/packages/types/src/protocol/types/TlvCommandData.ts b/packages/types/src/protocol/types/TlvCommandData.ts index 16dc37d8b..31c2875a4 100644 --- a/packages/types/src/protocol/types/TlvCommandData.ts +++ b/packages/types/src/protocol/types/TlvCommandData.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvAny } from "../../tlv/TlvAny.js"; import { TlvUInt16 } from "../../tlv/TlvNumber.js"; import { TlvField, TlvObject, TlvOptionalField } from "../../tlv/TlvObject.js"; @@ -17,3 +18,5 @@ export const TlvCommandData = TlvObject({ commandFields: TlvOptionalField(1, TlvAny), commandRef: TlvOptionalField(2, TlvUInt16), }); + +export type CommandData = TypeFromSchema; diff --git a/packages/types/src/protocol/types/TlvDataVersionFilter.ts b/packages/types/src/protocol/types/TlvDataVersionFilter.ts index 47da02899..7850b74c5 100644 --- a/packages/types/src/protocol/types/TlvDataVersionFilter.ts +++ b/packages/types/src/protocol/types/TlvDataVersionFilter.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvUInt32 } from "../../tlv/TlvNumber.js"; import { TlvField, TlvObject } from "../../tlv/TlvObject.js"; import { TlvClusterPath } from "./TlvClusterPath.js"; @@ -15,3 +16,5 @@ export const TlvDataVersionFilter = TlvObject({ path: TlvField(0, TlvClusterPath), dataVersion: TlvField(1, TlvUInt32), }); + +export type DataVersionFilter = TypeFromSchema; diff --git a/packages/types/src/protocol/types/TlvEventData.ts b/packages/types/src/protocol/types/TlvEventData.ts index 404fb5d09..69238f0ee 100644 --- a/packages/types/src/protocol/types/TlvEventData.ts +++ b/packages/types/src/protocol/types/TlvEventData.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { EventPriority } from "../../cluster/Cluster.js"; import { TlvEventNumber } from "../../datatype/EventNumber.js"; import { TlvAny } from "../../tlv/TlvAny.js"; @@ -24,3 +25,5 @@ export const TlvEventData = TlvObject({ deltaSystemTimestamp: TlvOptionalField(6, TlvSysTimeMS), data: TlvOptionalField(7, TlvAny), }); + +export type EventData = TypeFromSchema; diff --git a/packages/types/src/protocol/types/TlvEventFilter.ts b/packages/types/src/protocol/types/TlvEventFilter.ts index f85255642..9cf936d03 100644 --- a/packages/types/src/protocol/types/TlvEventFilter.ts +++ b/packages/types/src/protocol/types/TlvEventFilter.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvNodeId } from "../../datatype/NodeId.js"; import { TlvUInt64 } from "../../tlv/TlvNumber.js"; import { TlvField, TlvObject, TlvOptionalField } from "../../tlv/TlvObject.js"; @@ -15,3 +16,5 @@ export const TlvEventFilter = TlvObject({ nodeId: TlvOptionalField(0, TlvNodeId), eventMin: TlvField(1, TlvUInt64), }); + +export type EventFilter = TypeFromSchema; diff --git a/packages/types/src/protocol/types/TlvEventPath.ts b/packages/types/src/protocol/types/TlvEventPath.ts index 24ffeff64..8331e646b 100644 --- a/packages/types/src/protocol/types/TlvEventPath.ts +++ b/packages/types/src/protocol/types/TlvEventPath.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvClusterId } from "../../datatype/ClusterId.js"; import { TlvEndpointNumber } from "../../datatype/EndpointNumber.js"; import { TlvEventId } from "../../datatype/EventId.js"; @@ -21,3 +22,5 @@ export const TlvEventPath = TlvTaggedList({ eventId: TlvOptionalField(3, TlvEventId), isUrgent: TlvOptionalField(4, TlvBoolean), }); + +export type EventPath = TypeFromSchema; diff --git a/packages/types/src/protocol/types/TlvEventReport.ts b/packages/types/src/protocol/types/TlvEventReport.ts index 6cca3fc2f..6f399ac6c 100644 --- a/packages/types/src/protocol/types/TlvEventReport.ts +++ b/packages/types/src/protocol/types/TlvEventReport.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvObject, TlvOptionalField } from "../../tlv/TlvObject.js"; import { TlvEventData } from "./TlvEventData.js"; import { TlvEventStatus } from "./TlvEventStatus.js"; @@ -15,3 +16,5 @@ export const TlvEventReport = TlvObject({ eventStatus: TlvOptionalField(0, TlvEventStatus), eventData: TlvOptionalField(1, TlvEventData), }); + +export type EventReport = TypeFromSchema; diff --git a/packages/types/src/protocol/types/TlvEventStatus.ts b/packages/types/src/protocol/types/TlvEventStatus.ts index ef1c7fd28..a2f5e2ed8 100644 --- a/packages/types/src/protocol/types/TlvEventStatus.ts +++ b/packages/types/src/protocol/types/TlvEventStatus.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvField, TlvObject } from "../../tlv/TlvObject.js"; import { TlvEventPath } from "./TlvEventPath.js"; import { TlvStatus } from "./TlvStatus.js"; @@ -15,3 +16,5 @@ export const TlvEventStatus = TlvObject({ path: TlvField(0, TlvEventPath), status: TlvField(1, TlvStatus), }); + +export type EventStatus = TypeFromSchema; diff --git a/packages/types/src/protocol/types/TlvInvokeResponseData.ts b/packages/types/src/protocol/types/TlvInvokeResponseData.ts index e02cfbfab..346015652 100644 --- a/packages/types/src/protocol/types/TlvInvokeResponseData.ts +++ b/packages/types/src/protocol/types/TlvInvokeResponseData.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { TypeFromSchema } from "#tlv/TlvSchema.js"; import { TlvObject, TlvOptionalField } from "../../tlv/TlvObject.js"; import { TlvCommandData } from "./TlvCommandData.js"; import { TlvCommandStatus } from "./TlvCommandStatus.js"; @@ -15,3 +16,5 @@ export const TlvInvokeResponseData = TlvObject({ command: TlvOptionalField(0, TlvCommandData), status: TlvOptionalField(1, TlvCommandStatus), }); + +export type InvokeResponseData = TypeFromSchema;